diff --git a/.ciagent/.env.secrets.example b/.ciagent/.env.secrets.example new file mode 100644 index 0000000..2a88a8e --- /dev/null +++ b/.ciagent/.env.secrets.example @@ -0,0 +1,29 @@ +# Praxis — Operator-tier secrets template (v0.4, TASK-05-02). +# Copy to .ciagent/.env.secrets and fill in real values. +# .env.secrets is gitignored (verified in .gitignore: .env.secrets). +# This file (.env.secrets.example) is committed as documentation. + +# ─── Operator tier (v0.4) ─────────────────────────────────────────────────── +# Postgres password. Generate: openssl rand -base64 32 +PRAXIS_PG_PASSWORD= + +# Full Postgres DSN. host=postgres is the docker-compose service DNS name. +# postgresql://praxis:${PRAXIS_PG_PASSWORD}@postgres:5432/praxis +PRAXIS_PG_DSN= + +# Cookie signing secret (>=32 bytes). Generate: openssl rand -base64 48 +PRAXIS_COOKIE_SECRET= + +# Bootstrap operator credentials (scripts/create-operator.py). +PRAXIS_BOOTSTRAP_OPERATOR_USER= +PRAXIS_BOOTSTRAP_OPERATOR_PASS= + +# VC issuer root key (nacl.SecretBox, 32 bytes). Generate: +# python3 -c "import nacl.utils; print(nacl.utils.random(32).hex())" +PRAXIS_VC_ISSUER_KEY= + +# Issuer URL (public base for VC identifiers). +PRAXIS_ISSUER_URL=https://praxis.example/issuers/v0.4 + +# Cookie Secure flag — set false ONLY for the HTTP pilot (R-AUTH-01, G-031). +PRAXIS_COOKIE_SECURE=true \ No newline at end of file diff --git a/.ciagent/VERIFY-P1.md b/.ciagent/VERIFY-P1.md index fe057b0..09f612d 100644 --- a/.ciagent/VERIFY-P1.md +++ b/.ciagent/VERIFY-P1.md @@ -1,55 +1,238 @@ -# P1 Verification Matrix — REQ-ID → Test Mapping - -> **Phase:** P1 (Mastery Core + VC Issuance) -> **Slices covered:** SLICE-01 → SLICE-09 (Wave 1–5) — SLICE-09 COMPLETE -> **Status:** verified — all 13 P1 REQ-IDs have covering tests -> **Date:** 2026-08-03 (updated by ci-verifier after SLICE-09 completion) -> **Authority:** lead-developer (TASK-08-03) + ci-verifier (4-layer verify) - -This matrix confirms every P1 REQ-ID has at least one covering test. Tests live -under `tests/` (pytest) or `scripts/` (smoke scripts, runnable standalone). -SLICE-09 (VC issuer + verification + interop/rotation) is now complete — all -three previously-pending REQ-IDs (REQ-MAST-03, REQ-NFR-VC-01, REQ-NFR-VC-02) are -covered. All 13 P1 REQ-IDs are green. - ---- - -## REQ-ID → Test Coverage Matrix - -| REQ-ID | Slice | Covering Tests | Status | -|--------|-------|----------------|--------| -| REQ-MAST-01 (rubric schema + scoring) | SLICE-01, 03 | `tests/test_rubric_schema.py` (load valid rubric, reject invalid weights, reject missing levels, criterion lookup, weight-sum validation) · `tests/test_rubric_scoring.py` (rule-based scoring, signal→level mapping, conjunctive floor) · `tests/test_evidence_extractor_integration.py` (LLM-extract → score end-to-end, JSON-schema validation) | ✅ covered | -| REQ-MAST-02 (mastery score + gate logic) | SLICE-07 | `tests/test_rubric_scoring.py::test_*mastery_score*` (compute_scenario_score, compute_path_score, check_gate) · `tests/test_mastery_integration.py` (end-to-end scoring flow, theta update, progress advancement, gate event recorded, determinism, scoring_inconclusive short-circuit, failure-does-not-add-to-passed) · `scripts/test_mastery_e2e.py` (3 sessions → gate opens at ≥3 distinct passed AND score ≥3.5) | ✅ covered | -| REQ-MAST-03 (VC issuer — formative-tier) | SLICE-09 | `tests/test_vc_issuer.py` (key generation, sign/verify round-trip, tamper detection, JCS determinism, status list set/get, revocation invalidates) · `tests/test_vc_integration.py` (issue→verify round-trip, revoke→verify fails, tamper→verify fails, key rotation: old VC verifies against archived key) · `tests/test_vc_interop.py` (W3C VC 2.0 schema conformance, JCS canonical JSON, Ed25519 sig = 64 bytes, `credentialTier: formative` in payload) · `tests/test_vc_key_rotation_drill.py` (issue N with key A, rotate to B, issue M, verify all N+M verify, revoke one each) | ✅ covered | -| REQ-MAST-04 (principle — accepted) | — | — | ✅ accepted (no test — principle only) | -| REQ-SCEN-02 (IRT dynamic difficulty) | SLICE-04 | `tests/test_irt.py` (P_success correctness, theta update convergence, cold-start fallback, select_scenario targeting, sigma_sq shrinkage) · `tests/test_irt_selection_integration.py` (library.select_for_theta targets the right P for a given theta + path) | ✅ covered | -| REQ-SCEN-03 (scenario library ≥6 CS scenarios) | SLICE-02, 06 | `tests/test_scenario_library.py` (load index, list_by_path, select_for_theta, MIN_COVERAGE validation, reject invalid semver, AI-variation backref validation) · `tests/test_scenario_library_content.py` (all 6 scenarios load, rubric_criteria reference valid ids, MIN_COVERAGE per criterion, semver valid, index.yaml in sync with files) | ✅ covered | -| REQ-SCEN-04 (expert-authored format + AI-variation hooks) | SLICE-02, 06 | `tests/test_scenario_library.py` (generated_from + intent_hash fields validated, AI-variation backref validation) · `tests/test_scenario_library_content.py` (expert-authored scenarios all carry version + author: expert) | ✅ covered | -| REQ-PATH-02 (6-week path structure) | SLICE-05 | `tests/test_path_engine.py` (load path, validate exactly 6 weeks, week numbers sequential, gate check, week advancement caps at 6, path completion) | ✅ covered | -| REQ-NFR-MAST-01 (deterministic scoring) | SLICE-03 | `tests/test_rubric_scoring.py` (determinism tests — same evidence+rubric → same scores, repeated runs identical) · `tests/test_evidence_extractor_integration.py::test_end_to_end_extraction_to_scoring_deterministic` · `tests/test_mastery_integration.py::test_mastery_flow_is_deterministic` | ✅ covered | -| REQ-NFR-MAST-02 (gate auditability — SQLite) | SLICE-07, 08 | `tests/test_mastery_integration.py` (gate event recorded per scored session, scenarios_passed + rubric_scores persisted, scoring_inconclusive records no event) · `tests/test_gate_audit_log.py` (query by learner, by path, by date range via SQL, JSON evidence reconstructable, 3 events distinct + queryable) | ✅ covered | -| REQ-NFR-VC-01 (tamper-evidence + interop) | SLICE-09 | `tests/test_vc_issuer.py` (tamper detection — flip a byte → verify fails; JCS canonicalization determinism) · `tests/test_vc_interop.py` (W3C VC 2.0 schema conformance + Ed25519 signature-format checks; staging-gated full validation via `PRAXIS_RUN_VC_INTEROP=1`) · `tests/test_vc_integration.py` (tamper payload → verify fails) | ✅ covered | -| REQ-NFR-VC-02 (revocation latency — next verify call) | SLICE-09 | `tests/test_vc_issuer.py` (status list set/get, revocation invalidates verification) · `tests/test_vc_integration.py` (revoke → GET /vc/verify → valid: false, status: revoked — status list fetched on every verify, no cache) | ✅ covered | -| REQ-NFR-IRT-01 (IRT < 100ms) | SLICE-04 | `tests/test_irt.py` (P_success + update_theta + select_scenario latency budget verified in the IRT unit tests) | ✅ covered | - ---- - -## Smoke Scripts (not pytest — runnable standalone) - -| Script | Purpose | Covers | -|--------|---------|--------| -| `scripts/test_mastery_e2e.py` | End-to-end P1 mastery smoke (3 sessions → gate opens) | REQ-MAST-02, REQ-NFR-MAST-01, REQ-NFR-MAST-02 (audit), REQ-PATH-02 (progress advance) | -| `scripts/test_real_llm_evidence.py` | Real-LLM evidence extraction (staging-gated, requires `PRAXIS_RUN_REAL_LLM_TESTS=1` + `OLLAMA_API_KEY`) | REQ-MAST-01 (extraction prompt works against real model, fuzzy-matched quotes) — grill Axis 7 FIX #1 | - ---- +# Praxis — v0.4 Phase 1 Verification (Operator Foundation) ## Summary +- Verdict: **APPROVE_WITH_NOTES** +- Layers: structural **PASS**, behavioral **PASS**, security **PASS**, quality **PASS** +- REQ coverage: **5/5** (REQ-MT-01, REQ-AUTH-01, REQ-NFR-AUTH-01, REQ-NFR-MT-01, REQ-MT-02 schema foundation) +- Grill MUSTs honored: **4/4 P1-applicable** (G-008, G-011, G-027, G-031); G-038 + G-041 are P2-scoped (tracked for P2 verify) +- P0 fixes applied: **0** (none needed — the one prior fix `0a95102` was applied during execution, before verify) +- P1+ flagged: **4** (non-blocking, for post-hoc review in P3) -- **P1 REQ-IDs total:** 13 (7 functional + 6 NFR) -- **Covered (all slices complete incl. SLICE-09):** 13 ✅ -- **Pending:** 0 -- **SLICE-08 sign-off:** all Wave 1–4 REQ-IDs (10/10) have covering tests in `tests/` or `scripts/`. -- **SLICE-09 sign-off:** all 3 previously-pending VC REQ-IDs (REQ-MAST-03, REQ-NFR-VC-01, REQ-NFR-VC-02) now covered by 4 new test files (`test_vc_issuer.py`, `test_vc_integration.py`, `test_vc_interop.py`, `test_vc_key_rotation_drill.py`). -- **Milestone ship (v0.1.4 → v0.1.5) gate:** UNBLOCKED — all 13 P1 REQ-IDs covered. P1 is green. +> Note: This file previously held the v0.3 P1 verification matrix (mastery core + VC issuance). That content is superseded by the v0.3 ship (v0.1.5, 13/13 REQ covered). This file now holds the v0.4 P1 (Operator Foundation) verification report. -**P1 note (non-blocking, post-hoc):** The VC interop test (TASK-09-07) implements W3C VC 2.0 schema conformance + signature-format validation rather than verification against a live external W3C verifier process. This satisfies the *structure* of the grill Axis 3 MUST #1 (crypto claims are validated against the W3C VC 2.0 schema + Ed25519 format, not just self-consistency), but a live external-verifier interop run (e.g., `@digitalcredentials/vc` or `digitalbazaar/vc-verifier`) remains a recommended P2 follow-up for the staging environment where the full `PRAXIS_RUN_VC_INTEROP=1` validation runs. \ No newline at end of file +## Layer 1 — Structural + +### File existence (all P1 files present) +| File | Exists | Notes | +|------|--------|-------| +| `docker-compose.yml` (extended) | YES | postgres:16-slim service + praxis-net + pgdata/pgbackups volumes | +| `pyproject.toml` (extended) | YES | asyncpg>=0.29, argon2-cffi>=23.1, slowapi>=0.1 added | +| `db/pg_migrate.py` | YES | 71 LOC, asyncpg migration runner with retry | +| `db/pg_migrations/0001_operator_tier.sql` | YES | 5 tables, gen_random_uuid(), no partitioning | +| `db/pg_schema.sql` | YES | reference schema | +| `db/pg_store.py` | YES | 280 LOC, full PgStore (operator CRUD, cohort, issuer keys, credentials, gate events) | +| `server/__main__.py` (extended) | YES | lifespan + SessionMiddleware + auth routes + VC migration + verification swap | +| `server/auth/__init__.py` | YES | package marker | +| `server/auth/passwords.py` | YES | argon2id hash/verify/rehash | +| `server/auth/cookies.py` | YES | SessionMiddleware kwargs, G-031 reframe documented | +| `server/auth/rate_limit.py` | YES | slowapi 5/min in-memory | +| `server/auth/dependencies.py` | YES | current_operator dep (401/503) | +| `server/auth/routes.py` | YES | login/logout/me, rate-limited | +| `server/auth/models.py` | YES | Operator dataclass | +| `server/vc/issuer_keys.py` (refactored) | YES | IssuerKeyStore Protocol (runtime_checkable) | +| `server/vc/migrate_keys.py` | YES | archive-before-activate + G-027 first-boot | +| `server/vc/verification.py` (extended) | YES | G-011 two-store fallback | +| `scripts/backup-pg.sh` | YES | POSIX-sh, pg_dump -Fc, 7-day rolling, restore drill comments | +| `scripts/create-operator.py` | YES | argon2id, idempotent, --update, retry | +| `scripts/proxmox/lxc-clone.sh` (extended) | YES | memory bumped 4096->6144 | +| `.env.example` (extended) | YES | operator vars documented | +| `.ciagent/.env.secrets.example` | YES | operator secrets template | +| `.ciagent/config.json` (extended) | YES | operator secrets scope added | +| `tests/test_pg_store.py` | YES | skips gracefully without PRAXIS_PG_DSN | +| `tests/test_auth.py` | YES | 310 LOC, mocked PgStore | +| `tests/test_vc_migration.py` | YES | 354 LOC, R-VC-MIG-01 + G-027 + G-011 | +| `tests/test_create_operator.py` | YES | 217 LOC, idempotent + --update | +| `tests/test_backup_restore.py` | YES | G-008 drill (skips without Postgres) | +| `tests/test_p1_auth_integration.py` | YES | e2e auth flow (skips without Postgres) | +| `tests/test_p1_vc_migration_e2e.py` | YES | R-VC-MIG-01 e2e (skips without Postgres) | + +### Import resolution +- `python3 -c "import server.__main__"` -> OK (Pipecat + all v0.4 modules load) +- `python3 -c "import db.pg_store, db.pg_migrate, server.auth.routes, server.auth.passwords, server.auth.cookies, server.auth.rate_limit, server.auth.dependencies, server.vc.migrate_keys"` -> all imports OK +- `IssuerKeyStore` Protocol: both `PraxisStore` and `PgStore` pass `isinstance(store, IssuerKeyStore)` (runtime_checkable) -> OK + +### No stubs / TODOs +- `grep -rE "TODO|FIXME|XXX|HACK|NotImplementedError" *.py` in new code -> 0 matches +- All methods have full implementations (no `pass` stubs) + +### Exports exist +- `passwords.__all__` = [hash_password, verify_password, needs_rehash] -> all defined +- `cookies.__all__` = [get_session_middleware_kwargs] -> defined +- `rate_limit.__all__` = [limiter, rate_limit_login, reset_login_rate_limit] -> all defined +- `dependencies.__all__` = [current_operator] -> defined +- `routes.__all__` = [router] -> defined +- `migrate_keys.__all__` = [migrate_issuer_keys] -> defined +- `pg_store.__all__` = [PgStore] -> defined +- `pg_migrate.__all__` = [apply_pg_migrations] -> defined + +### Install + compose +- `pip install -e . --break-system-packages` -> Successfully installed praxis-server-0.1.0 +- `docker compose config` -> exit 0 (validates; postgres service has no `ports:` -> internal network only per D-040) +- New deps importable: asyncpg 0.31.0, argon2 25.1.0, slowapi (installed) + +## Layer 2 — Behavioral + +### Test suite +- `pytest tests/ --tb=line` -> **272 passed, 33 skipped, 0 failed** (113.76s) +- Skips are graceful: + - 12 `test_pg_store.py` skips: `PRAXIS_PG_DSN not set -> Postgres integration tests skipped (dev mode)` + - `test_p1_auth_integration.py` + `test_p1_vc_migration_e2e.py` + `test_backup_restore.py` skip without Postgres (G-008/R-VC-MIG-01 drills require live PG) + - 7 `test_pending_keys.py` skips: voice-service keys not provisioned (pre-existing, unrelated to P1) + - 1 `test_vc_interop.py` skip: `PRAXIS_RUN_VC_INTEROP=1` opt-in (pre-existing) + +### SLICE acceptance criteria + +**SLICE-01 (Postgres DB foundation):** +- docker-compose postgres service with healthcheck (pg_isready, 10s/5ret/5s) PASS +- asyncpg pool lifespan (min=1, max=10, command_timeout=10) PASS +- pg_migrate.py idempotent (tracking table `_pg_migrations`, retry 3x/2s) PASS +- 5 tables in 0001_operator_tier.sql (operators, issued_credentials, mastery_gate_events, cohort_aggregates, issuer_keys) PASS +- cohort_aggregates NOT partitioned (plain table + index) PASS +- gen_random_uuid() used (PG16 core, no extension) PASS +- PgStore: all methods implemented (operator CRUD, cohort read/write, issuer keys, credentials, gate events) PASS +- Graceful degradation verified: server starts without Postgres, `/health` returns 200, auth returns 503 PASS + +**SLICE-02 (DevOps config):** +- `.env.example` documents all operator vars (PRAXIS_PG_PASSWORD, PRAXIS_PG_DSN, PRAXIS_COOKIE_SECRET, PRAXIS_COOKIE_SECURE, PRAXIS_BOOTSTRAP_OPERATOR_USER/PASS, PRAXIS_VC_ISSUER_KEY) PASS +- CT memory bumped 4096->6144 in lxc-clone.sh PASS +- `scripts/backup-pg.sh`: POSIX-sh, pg_dump -Fc, %u day-of-week rolling 7-file, non-empty check, restore drill comments PASS +- G-008 backup-restore drill: `tests/test_backup_restore.py` seeds all 5 tables -> pg_dump -> drop schema -> pg_restore --clean --if-exists -> verify row counts PASS (skips without PG) + +**SLICE-03 (Operator auth):** +- argon2id: PasswordHasher defaults (time_cost=3, memory_cost=64MiB, parallelism=4) -> exceeds OWASP PASS +- verify_password returns False on mismatch (no exception) PASS +- needs_rehash delegates to check_needs_rehash PASS +- Signed cookies: SessionMiddleware with `praxis_op`, max_age=28800 (8h), https_only, same_site="strict", path="/" PASS +- `https_only` + `same_site` kwargs verified valid for Starlette SessionMiddleware (fix `0a95102` correct) PASS +- Missing PRAXIS_COOKIE_SECRET -> ephemeral random + WARNING PASS +- PRAXIS_COOKIE_SECURE=false -> WARNING with G-031 reframe text PASS +- Rate limit: slowapi Limiter 5/minute, in-memory, per-IP (get_remote_address) PASS +- current_operator: 401 on missing cookie, 503 on no Postgres, 401 + session.clear() on inactive PASS +- login: rate-limited, verify_password, sets session["operator_id"], updates last_login_at, rehashes if needed PASS +- logout: Depends(current_operator), clears session PASS +- me: Depends(current_operator), returns operator info PASS + +**SLICE-04 (VC key migration):** +- IssuerKeyStore Protocol (runtime_checkable) -> both stores implement it PASS +- PgStore.get_public_key_row queries by id (not status) -> superseded keys found PASS (R-VC-MIG-01 fallback) +- migrate_keys.py: archive-before-activate (step 2 before step 3) PASS +- G-027 first-boot: if SQLite has no active key -> skip archive, generate fresh only PASS +- Idempotent: if Postgres has active key -> no-op PASS +- verification.py: G-011 two-store fallback (PG for keys -> SQLite for v0.3 creds -> SQLite-only if no PG) PASS +- Tests: R-VC-MIG-01 ordering test (instrumented, verifies archive index < supersede index < fresh index) PASS + +**SLICE-05 (Bootstrap CLI):** +- scripts/create-operator.py: env-provided creds, argon2id hash, ON CONFLICT DO NOTHING (idempotent) PASS +- --update flag: ON CONFLICT DO UPDATE (rehash) PASS +- Missing env -> exit 1 with clear error PASS +- Retry 3x/5s on connection failure (R-BOOT-01) PASS +- config.json operator secrets scope added PASS +- .ciagent/.env.secrets.example committed (no real secrets) PASS +- .gitignore: `.env.secrets` ignored, `!.ciagent/.env.secrets.example` whitelisted PASS + +**SLICE-06 (P1 integration):** +- __main__.py lifespan: creates pool, applies migrations, runs VC key migration (idempotent, non-fatal) PASS +- SessionMiddleware added (after CORS -> outermost for cookie signing) PASS +- auth_router mounted before StaticFiles PASS +- /vc/verify uses pg_store for key lookup, falls back to SQLite for v0.3 creds PASS +- VC key migration runs on first boot (_maybe_migrate_issuer_keys) PASS +- 503 on auth routes when no Postgres PASS +- Learner voice loop unaffected (REQ-NFR-MT-01): /health returns 200 regardless of Postgres PASS + +### REQ coverage +| REQ-ID | Covered by | Verification | +|--------|-----------|--------------| +| REQ-MT-01 | SLICE-01, SLICE-04, SLICE-06 | docker-compose postgres + asyncpg pool + PgStore + IssuerKeyStore protocol + verification swap PASS | +| REQ-AUTH-01 | SLICE-03, SLICE-05, SLICE-06 | argon2id + signed cookies + rate limit + current_operator dep + bootstrap CLI PASS | +| REQ-NFR-AUTH-01 | SLICE-03, SLICE-06 | argon2id (PasswordHasher defaults), httpOnly+secure+SameSite=Strict, 5/min rate limit, 8h expiry PASS | +| REQ-NFR-MT-01 | SLICE-01, SLICE-02, SLICE-06 | postgres internal network only (no ports), 6GB CT, graceful degradation, voice loop unaffected PASS | +| REQ-MT-02 (schema) | SLICE-01 | cohort_aggregates table + PgStore.upsert_cohort_aggregate PASS (pipeline is P2) | + +### Grill MUSTs honored +| MUST | Honored | Evidence | +|------|---------|----------| +| G-008 (backup drill) | YES | `tests/test_backup_restore.py` -> seeds 5 tables, pg_dump, drop, pg_restore --clean --if-exists, verify counts. `scripts/backup-pg.sh` has restore drill comments. | +| G-011 (two-store fallback) | YES | `server/vc/verification.py` _lookup_credential + _lookup_public_key implement (a)/(b)/(c). Tests: `test_verification_fallback_sqlite_when_pg_missing_credential` (G-011b) + `test_verification_sqlite_only_when_no_pg` (G-011c). | +| G-027 (first-boot no v0.3 key) | YES | `migrate_keys.py` line 80-87: if v03_row is None -> archived_key_id=None, skip archive. Tests: `test_migration_g027_first_boot_no_v03_key` + e2e `test_g027_first_boot_no_v03_key`. | +| G-031 (R-AUTH-01 reframe) | YES | `cookies.py` docstring + WARNING text: "primary R-AUTH-01 mitigation is k-anon defense-in-depth... this flag is the secondary mitigation." | +| G-038 (differencing-attack test) | N/A P2 | Scoped to P2 (TASK-07-05/TASK-10-03 -> cohort aggregation). Not a P1 deliverable. Tracked for P2 verify. | +| G-041 (SPA fallback subclass) | N/A P2 | Scoped to P2 (TASK-10-01 -> React Router). Not a P1 deliverable. Tracked for P2 verify. | + +### R-VC-MIG-01 mitigation +- **Archived-before-active:** `migrate_keys.py` calls `_archive_v03_public_key` (step 2) BEFORE `_generate_fresh_v04_key` (step 3). Verified by instrumented test `test_migration_archives_before_activating_r_vc_mig_01` (asserts v03_idx < sup_idx < fresh_idx). +- **Idempotent:** if `get_active_signing_key_row()` returns non-None -> returns `{None, None}` (no-op). Test `test_migration_idempotent_when_active_key_exists`. +- **Cannot replay to overwrite:** `init_issuer_key` uses `ON CONFLICT (id) DO NOTHING` -> existing keys are not overwritten. + +### Graceful degradation +- Verified empirically: server starts without Postgres (PRAXIS_PG_DSN unset), `/health` -> 200, `/api/operator/me` -> 503, `/api/operator/login` -> 503. Learner voice loop unaffected (SQLite path intact). + +## Layer 3 — Security (STRIDE) + +| Threat | Surface | Mitigation | Verified | Disposition | +|--------|---------|------------|----------|-------------| +| **Spoofing** | operator auth | argon2id (PasswordHasher defaults: time=3, mem=64MiB, par=4) + signed cookies (itsdangerous HMAC-SHA256) | No plaintext passwords in code; cookie signature checked by SessionMiddleware; verify_password catches VerifyMismatchError -> False | accept (low) | +| **Tampering** | VC key migration | archived-before-active + idempotent + ON CONFLICT DO NOTHING | Instrumented ordering test; idempotency test; get_public_key_row queries by id (not status) so superseded keys cannot be silently replaced | accept (low) | +| **Repudiation** | auth audit | last_login_at updated on successful login | `routes.py:86` calls `pg_store.update_last_login(op_id)`; `pg_store.py:46-51` executes `UPDATE operators SET last_login_at = now()` | accept (low) | +| **Info Disclosure** | operator cookies + cohort data | k-anon defense-in-depth (G-031) + cookie contains only operator_id (no PII) | `routes.py:85` sets only `session["operator_id"]`; `dependencies.py:33` reads only `operator_id`; Operator dataclass has id/username/display_name/role (no PII beyond operator's own name) | accept (low) | +| **Denial of Service** | login endpoint | slowapi 5/min per IP | `rate_limit.py` Limiter wired; `__main__.py:123-124` registers limiter + RateLimitExceeded handler; test verifies decorator factory | accept (medium -> in-memory counter lost on restart, R-AUTH-03 accepted pilot risk) | +| **Elevation of Privilege** | /api/operator/* routes | single operator role + current_operator dep on every protected route | logout + me use `Depends(current_operator)`; no RBAC bypass possible (single role, no role-check logic to bypass); login is NOT auth-gated (correct -> entry point) | accept (low) | + +**Cookie PII check:** The signed cookie (praxis_op) payload contains ONLY `{operator_id: ""}`. No username, display_name, role, or learner data in the cookie. Verified by inspecting `routes.py:85` and `dependencies.py:33`. + +**SQL injection check:** All PgStore queries use asyncpg parameterized bindings ($1, $2, ...). The one f-string in `set_credential_status` (`f"UPDATE ... SET status = $1{extra} WHERE id = $2"`) injects only a static fragment (`", revoked_at = now()"`) -> user-controlled values (status, cred_id) are bound parameters. SAFE. + +**Argon2id params:** PasswordHasher() defaults (time_cost=3, memory_cost=65536 KiB = 64MiB, parallelism=4) exceed OWASP minimums (time>=3, mem>=64MiB, par>=4). Verified via import + hash timing (~119ms hash, ~98ms verify). + +## Layer 4 — Quality (multi-persona review) + +### Correctness +- Migration script handles all 3 cases: (a) active key exists -> no-op, (b) v0.3 key exists -> archive+generate, (c) no v0.3 key -> generate only. Logic is sound. +- Auth flow: login sets session -> me reads session -> logout clears session. Inactive operator -> 401 + session.clear() (invalidates cookie). Edge cases covered. +- Verification two-store fallback: tries PG for credential -> falls back to SQLite -> tries PG for key -> falls back to SQLite. Order is correct (PG preferred for v0.4 keys, SQLite fallback for v0.3 creds). +- `_maybe_migrate_issuer_keys` is wrapped in try/except -> migration failure is non-fatal (v0.3 SQLite path remains). Correct for graceful degradation. + +### Testing +- 272 tests pass, 33 skip gracefully (Postgres-requiring tests skip with clear messages; voice-service-key tests pre-existing). +- Mock-based equivalents exist for all Postgres-requiring paths: `test_auth.py` (mocked PgStore), `test_vc_migration.py` (mocked stores), `test_create_operator.py` (mocked PgStore). +- R-VC-MIG-01 has both a mocked unit test (`test_migration_archives_before_activating_r_vc_mig_01`) AND an e2e test (`test_p1_vc_migration_e2e.py` -> requires PG). +- Coverage gap: rate limiting is tested at the decorator level (`test_rate_limit_login_decorator`) but the full 6th-attempt->429 path is only in the PG-requiring `test_p1_auth_integration.py`. The mock-based path verifies the decorator is callable but not the 429 behavior. **P1+ flag** (non-blocking -> the 429 path is tested when PG is available). + +### Security +- Input validation: LoginBody is a Pydantic BaseModel (username/password validated as str). No raw user input reaches SQL. +- Injection vectors: parameterized queries throughout. The one f-string is static-fragment only. **No injection vectors found.** +- Cookie secret: if unset -> ephemeral random + WARNING (dev only). For pilot, `.env.secrets.example` documents generation (`openssl rand -base64 48`). +- Weak PRAXIS_COOKIE_SECRET: if an attacker knows the secret, they can forge cookies. Mitigation: secret is in `.env.secrets` (gitignored), injected via lxc.environment. **P1+ flag** (document minimum length requirement -> currently no validation that secret >=32 bytes). + +### Performance +- asyncpg pool: min=1, max=10, command_timeout=10s. Appropriate for single-instance pilot. +- **Argon2id blocking:** hash ~119ms, verify ~98ms -> SYNC calls in the async login route handler (`routes.py:79, 88`). This blocks the event loop for ~100-300ms per login (verify + potential rehash). For a single-operator pilot with low-frequency logins, this is acceptable (R-AUTH-02 explicitly accepts this). **P1+ flag** (offload to `asyncio.to_thread` / `run_in_executor` if login frequency increases or multi-operator). +- No other blocking calls in async paths. Pool.acquire() is async. All PgStore methods are async. +- Voice loop (WebRTC -> Pipecat) does NOT touch Postgres -> it uses SQLite (D-007 preserved). No perf impact on the <600ms latency budget (C-8). + +### Maintainability +- IssuerKeyStore Protocol is clean (runtime_checkable, 4 methods, both stores implement it). Duck-typing formalized without breaking existing PraxisStore. +- Module structure: `server/auth/` package (passwords, cookies, rate_limit, dependencies, routes, models) -> clear separation of concerns. +- `db/pg_store.py` is a single class with clear method groups (operator CRUD, cohort, issuer keys, credentials, gate events). No god-class anti-pattern. +- Naming: consistent `get_*_row` / `set_*` / `insert_*` / `upsert_*` conventions. `learner_ref` is opaque (not FK) per D-031. +- Coupling: `verification.py` depends on the IssuerKeyStore protocol (not concrete PgStore/PraxisStore) -> clean dependency inversion. + +### Adversarial +- **Weak PRAXIS_COOKIE_SECRET:** if the secret is short or predictable, cookies can be forged. No length validation in `cookies.py` (only checks non-empty). **P1+ flag** (add `len(secret) >= 32` check with WARNING). +- **Postgres exposed despite internal network:** docker-compose has no `ports:` on postgres service (D-040 honored). An attacker would need to compromise the LXC CT or praxis-net bridge. Mitigated by network isolation. +- **Rate limit bypass via restart:** R-AUTH-03 accepted -> in-memory counter resets on restart. For a single-instance pilot, restarts are operator-initiated and rare. Documented in `rate_limit.py`. +- **Migration replay attack:** `init_issuer_key` uses `ON CONFLICT (id) DO NOTHING` -> re-running migration cannot overwrite an existing key. An attacker with DB access could insert a key directly, but DB access is already game-over. Not a v0.4 concern. + +## P0 Fixes Applied +None. No P0 issues found. (The one fix commit `0a95102` -> SessionMiddleware kwargs `https_only`/`same_site` instead of `secure`/`samesite` -> was applied during execution, before this verify run. Verified correct: `inspect.signature(SessionMiddleware.__init__)` confirms `https_only` and `same_site` are the valid parameter names.) + +## P1+ Flagged for Post-Hoc Review +1. **Argon2id blocking event loop** (`server/auth/routes.py:79,88`): `verify_password` + `hash_password` (rehash) are sync calls in the async login handler, blocking ~100-300ms. Acceptable for single-operator pilot (R-AUTH-02). If login frequency increases, offload to `asyncio.to_thread`. **Non-blocking.** +2. **Rate limit 429 not tested in mock path** (`tests/test_auth.py:303`): only the decorator factory is tested in the mock-based suite; the full 6th-attempt->429 path is in the PG-requiring integration test. Add a mock-based 429 test for CI coverage without Postgres. **Non-blocking.** +3. **No PRAXIS_COOKIE_SECRET length validation** (`server/auth/cookies.py:41`): only checks non-empty, not >=32 bytes. A short secret weakens the HMAC signature. Add `len(secret) >= 32` check with WARNING. **Non-blocking.** +4. **`set_credential_status` status field not validated** (`db/pg_store.py:223`): accepts any string for `status` (no enum check). Currently only called with "revoked" from operator code, but a future caller could pass arbitrary strings. Consider a CHECK constraint on the `issued_credentials.status` column or a Python enum. **Non-blocking.** + +--- + +## Verification Result + +Phase 1 (Operator Foundation) is **APPROVED_WITH_NOTES**. All 4 layers pass. All 5 P1-scoped REQ-IDs are covered. All 4 P1-applicable grill MUSTs are honored (G-038 + G-041 are P2-scoped, tracked for P2 verify). No P0 issues. 4 P1+ items flagged for post-hoc review in P3 (non-blocking). The phase is ready for ship (v0.1.7) -> the orchestrator delegates to ship after this verify. \ No newline at end of file diff --git a/.ciagent/config.json b/.ciagent/config.json index d1317cf..94251e0 100644 --- a/.ciagent/config.json +++ b/.ciagent/config.json @@ -99,6 +99,10 @@ { "name": "voice", "env_vars": ["DEEPGRAM_API_KEY", "CARTESIA_API_KEY", "OLLAMA_API_KEY"] + }, + { + "name": "operator", + "env_vars": ["PRAXIS_PG_PASSWORD", "PRAXIS_COOKIE_SECRET", "PRAXIS_BOOTSTRAP_OPERATOR_USER", "PRAXIS_BOOTSTRAP_OPERATOR_PASS", "PRAXIS_VC_ISSUER_KEY"] } ] }, diff --git a/.env.example b/.env.example index 7b15122..6805930 100644 --- a/.env.example +++ b/.env.example @@ -53,8 +53,58 @@ CARTESIA_VOICE_ID=a3536a36-1d18-4efb-a95a-7c44b7b5e384 # PROXMOX_TEMPLATE_VOLID=local:vztmpl/debian-12-standard_12.2-1_amd64.tar.zst # PROXMOX_LXC_VMID=auto # PROXMOX_TLS_SKIP_VERIFY=true -# PROXMOX_MEMORY_MB=4096 +# v0.4: bumped to 6144 (Postgres ~400MB + praxis ~500MB + Docker ~200MB +# + build headroom ~1GB + margin — REQ-NFR-MT-01). +# PROXMOX_MEMORY_MB=6144 # ─── CI/Gitea (operational — not voice) ─────────────────────────────────────── # GITEA_TOKEN is provisioned in .ciagent/.env.secrets (not this file). -# PRAXIS_VERSION (git ref to deploy, default: main) \ No newline at end of file +# PRAXIS_VERSION (git ref to deploy, default: main) + +# ─── v0.4 Operator Tier (Postgres + Auth) ──────────────────────────────────── +# These configure the operator surface (cohort dashboard, auth, VC migration). +# Real values are secrets — put them in .ciagent/.env.secrets, not here. +# This file is documentation-only (committed); .env.secrets is gitignored. + +# Postgres password. Secret. Used in the DSN below + docker-compose postgres +# service (POSTGRES_PASSWORD). Generate with: openssl rand -base64 32 +PRAXIS_PG_PASSWORD= + +# Postgres DSN (D-050). host=postgres is the docker-compose service DNS name +# on the praxis-net bridge. Format: +# postgresql://praxis:${PRAXIS_PG_PASSWORD}@postgres:5432/praxis +# When unset/empty, the server starts in graceful no-pool mode (learner voice +# loop works; operator auth + cohort endpoints return 503). +PRAXIS_PG_DSN= + +# Cookie signing secret (D-056, R-AUTH-01). >=32 random bytes, base64 or hex. +# Secret. Generate with: openssl rand -base64 48 +# When unset, the server generates an ephemeral random secret (dev ONLY — +# sessions won't survive a restart; NOT for pilot/production). +PRAXIS_COOKIE_SECRET= + +# Cookie Secure flag (D-041, R-AUTH-01, G-031). Default true (HTTPS). +# Set to false ONLY for the HTTP pilot (no TLS in the LXC pilot — D-030). +# NOTE (G-031): the PRIMARY mitigation for a sniffed cookie is the k-anon +# defense-in-depth (the cohort dashboard reads only k-anonymized aggregates, +# so a sniffed operator cookie leaks NO learner PII). This flag is the +# SECONDARY mitigation (operational convenience for when TLS arrives). +PRAXIS_COOKIE_SECURE=true + +# Bootstrap operator credentials (D-052). Secret. Used by +# scripts/create-operator.py on first run to create the initial operator. +# If either is missing, the CLI exits 1 (R-BOOT-02). +PRAXIS_BOOTSTRAP_OPERATOR_USER= +PRAXIS_BOOTSTRAP_OPERATOR_PASS= + +# VC issuer root key (v0.3 + v0.4). Secret. Used by nacl.SecretBox to encrypt +# Ed25519 private keys at rest (D-042). In v0.4 the migration script +# (server/vc/migrate_keys.py) uses this to encrypt the fresh v0.4 keypair; +# the v0.3 root key is kept for the v0.3 SQLite verification path (R-VC-MIG-02). +# Generate with: python3 -c "import nacl.utils; print(nacl.utils.random(32).hex())" +PRAXIS_VC_ISSUER_KEY= + +# Issuer URL (D-042). The public base URL for VC issuer + key identifiers. +# v0.4 changes the default to /issuers/v0.4 (v0.3 VCs keep their v0.3 URLs +# embedded in their proofs — verification fetches keys by id, not by URL). +PRAXIS_ISSUER_URL=https://praxis.example/issuers/v0.4 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 0510b7d..769f816 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,8 @@ venv/ .env.secrets .env.* !.env.example +!.env.secrets.example +!.ciagent/.env.secrets.example # SQLite *.db diff --git a/db/pg_migrate.py b/db/pg_migrate.py new file mode 100644 index 0000000..421ec5f --- /dev/null +++ b/db/pg_migrate.py @@ -0,0 +1,71 @@ +"""Postgres migration runner — applies db/pg_migrations/*.sql in order. + +Mirrors db/migrate.py: ordered .sql files tracked in a `_pg_migrations` +table so re-running is idempotent. Uses an asyncpg pool. Retries on +connection failure (3 attempts, 2s backoff — R-MT-02 mitigation). +""" + +from __future__ import annotations + +import asyncio +import datetime as _dt +from pathlib import Path + +import asyncpg + +_DEFAULT_MIGRATIONS_DIR = Path(__file__).resolve().parent / "pg_migrations" +_RETRY_ATTEMPTS = 3 +_RETRY_BACKOFF_S = 2.0 + + +async def apply_pg_migrations( + pool: asyncpg.Pool, + migrations_dir: Path | None = None, +) -> list[str]: + """Apply all pending Postgres migrations in order. Returns applied names. + + Idempotent — no-op if all migrations are already applied. Each migration + runs within a transaction; the `_pg_migrations` tracking row is inserted + in the same transaction so a failure rolls back cleanly. + """ + mdir = migrations_dir or _DEFAULT_MIGRATIONS_DIR + if not mdir.exists(): + return [] + + async def _run() -> list[str]: + async with pool.acquire() as conn: + await conn.execute( + "CREATE TABLE IF NOT EXISTS _pg_migrations (" + "id TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now()" + ")" + ) + rows = await conn.fetch("SELECT id FROM _pg_migrations") + applied_ids = {r["id"] for r in rows} + applied: list[str] = [] + for sql_path in sorted(mdir.glob("*.sql")): + mid = sql_path.stem + if mid in applied_ids: + continue + sql = sql_path.read_text(encoding="utf-8") + async with conn.transaction(): + await conn.execute(sql) + await conn.execute( + "INSERT INTO _pg_migrations (id) VALUES ($1)", mid + ) + applied.append(mid) + return applied + + last_exc: Exception | None = None + for attempt in range(1, _RETRY_ATTEMPTS + 1): + try: + return await _run() + except (asyncpg.PostgresConnectionError, ConnectionError, OSError) as exc: + last_exc = exc + if attempt < _RETRY_ATTEMPTS: + await asyncio.sleep(_RETRY_BACKOFF_S) + continue + assert last_exc is not None + raise last_exc + + +__all__ = ["apply_pg_migrations"] \ No newline at end of file diff --git a/db/pg_migrations/0001_operator_tier.sql b/db/pg_migrations/0001_operator_tier.sql new file mode 100644 index 0000000..6c42dcc --- /dev/null +++ b/db/pg_migrations/0001_operator_tier.sql @@ -0,0 +1,59 @@ +-- Praxis v0.4 operator-tier schema migration 0001. +-- Creates the 5 operator-tier tables. Uses gen_random_uuid() (PG16 core). +-- Idempotent via IF NOT EXISTS (also safe through pg_migrate tracking). + +CREATE TABLE IF NOT EXISTS operators ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + display_name TEXT, + role TEXT NOT NULL DEFAULT 'operator', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE TABLE IF NOT EXISTS issued_credentials ( + id UUID PRIMARY KEY, + operator_id UUID REFERENCES operators(id), + learner_ref TEXT NOT NULL, + vc_type TEXT, + payload_jsonb JSONB NOT NULL, + signature_b64 TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + issued_at TIMESTAMPTZ NOT NULL DEFAULT now(), + revoked_at TIMESTAMPTZ +); + +CREATE TABLE IF NOT EXISTS mastery_gate_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + learner_ref TEXT NOT NULL, + scenario_id TEXT, + path_id TEXT NOT NULL, + gate_outcome TEXT, + rubric_scores_jsonb JSONB, + recorded_at TIMESTAMPTZ NOT NULL DEFAULT now(), + source TEXT NOT NULL DEFAULT 'sync' +); + +CREATE TABLE IF NOT EXISTS cohort_aggregates ( + path TEXT NOT NULL, + metric TEXT NOT NULL, + window_start DATE NOT NULL, + window_end DATE NOT NULL, + value NUMERIC, + cell_count INTEGER NOT NULL DEFAULT 0, + cell_suppressed BOOLEAN NOT NULL DEFAULT FALSE, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (path, metric, window_start) +); +CREATE INDEX IF NOT EXISTS cohort_aggregates_path_window_idx + ON cohort_aggregates (path, window_start); + +CREATE TABLE IF NOT EXISTS issuer_keys ( + id TEXT PRIMARY KEY, + public_key TEXT NOT NULL, + private_key_enc BYTEA, + status TEXT NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); \ No newline at end of file diff --git a/db/pg_schema.sql b/db/pg_schema.sql new file mode 100644 index 0000000..ab8f441 --- /dev/null +++ b/db/pg_schema.sql @@ -0,0 +1,71 @@ +-- Praxis v0.4 operator-tier Postgres schema (reference). +-- Applied in order by db/pg_migrate.py via db/pg_migrations/*.sql. +-- The canonical migration is 0001_operator_tier.sql; this file is the +-- human-readable reference (kept in sync). Uses gen_random_uuid() which +-- is in PG16 core (no extension needed — R-MT-05 verified). +-- +-- Tables: +-- operators — operator accounts (argon2id password hash) +-- issued_credentials — VC issuance log (learner_ref is opaque, no FK) +-- mastery_gate_events — mastery gate audit log (REQ-NFR-MAST-02) +-- cohort_aggregates — k-anonymized cohort metrics (plain table, D-050) +-- issuer_keys — Ed25519 issuer key lifecycle (active/superseded) +-- +-- No cross-DB FKs (D-031). learner_ref is an opaque string in Postgres. + +CREATE TABLE IF NOT EXISTS operators ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + display_name TEXT, + role TEXT NOT NULL DEFAULT 'operator', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ +); + +CREATE TABLE IF NOT EXISTS issued_credentials ( + id UUID PRIMARY KEY, + operator_id UUID REFERENCES operators(id), + learner_ref TEXT NOT NULL, + vc_type TEXT, + payload_jsonb JSONB NOT NULL, + signature_b64 TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + issued_at TIMESTAMPTZ NOT NULL DEFAULT now(), + revoked_at TIMESTAMPTZ +); + +CREATE TABLE IF NOT EXISTS mastery_gate_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + learner_ref TEXT NOT NULL, + scenario_id TEXT, + path_id TEXT NOT NULL, + gate_outcome TEXT, + rubric_scores_jsonb JSONB, + recorded_at TIMESTAMPTZ NOT NULL DEFAULT now(), + source TEXT NOT NULL DEFAULT 'sync' +); + +CREATE TABLE IF NOT EXISTS cohort_aggregates ( + path TEXT NOT NULL, + metric TEXT NOT NULL, + window_start DATE NOT NULL, + window_end DATE NOT NULL, + value NUMERIC, + cell_count INTEGER NOT NULL DEFAULT 0, + cell_suppressed BOOLEAN NOT NULL DEFAULT FALSE, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (path, metric, window_start) +); +-- Plain table, NOT partitioned (D-050..D-053; add partitioning post-pilot). +CREATE INDEX IF NOT EXISTS cohort_aggregates_path_window_idx + ON cohort_aggregates (path, window_start); + +CREATE TABLE IF NOT EXISTS issuer_keys ( + id TEXT PRIMARY KEY, + public_key TEXT NOT NULL, + private_key_enc BYTEA, + status TEXT NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); \ No newline at end of file diff --git a/db/pg_store.py b/db/pg_store.py new file mode 100644 index 0000000..18655e6 --- /dev/null +++ b/db/pg_store.py @@ -0,0 +1,280 @@ +"""Postgres store — operator-tier access layer (D-040, D-050, TASK-01-06). + +Async access via an asyncpg.Pool. Implements the IssuerKeyStore protocol +(server/vc/issuer_keys.py) so VC verification can use either PraxisStore +(SQLite, v0.3) or PgStore (Postgres, v0.4). No cross-DB joins (D-031); +`learner_ref` is an opaque string in Postgres (not a FK to SQLite). +""" + +from __future__ import annotations + +import json +import uuid +from typing import Any + +import asyncpg + + +class PgStore: + """Async Postgres store for the v0.4 operator tier.""" + + def __init__(self, pool: asyncpg.Pool) -> None: + self.pool = pool + + # ── Operator CRUD ──────────────────────────────────────────────────── + + async def get_operator_by_username(self, username: str) -> dict | None: + async with self.pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT id, username, password_hash, display_name, role, " + "is_active, created_at, last_login_at " + "FROM operators WHERE username = $1", + username, + ) + return dict(row) if row else None + + async def get_operator_by_id(self, operator_id: str) -> dict | None: + async with self.pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT id, username, password_hash, display_name, role, " + "is_active, created_at, last_login_at " + "FROM operators WHERE id = $1", + operator_id, + ) + return dict(row) if row else None + + async def update_last_login(self, operator_id: str) -> None: + async with self.pool.acquire() as conn: + await conn.execute( + "UPDATE operators SET last_login_at = now() WHERE id = $1", + operator_id, + ) + + async def insert_operator( + self, + username: str, + password_hash: str, + display_name: str | None = None, + *, + on_conflict_update: bool = False, + ) -> str | None: + """Insert an operator (idempotent on username). Returns the id, or + None if the row already existed and on_conflict_update is False.""" + async with self.pool.acquire() as conn: + if on_conflict_update: + row = await conn.fetchrow( + "INSERT INTO operators (username, password_hash, display_name) " + "VALUES ($1, $2, $3) " + "ON CONFLICT (username) DO UPDATE SET " + "password_hash = excluded.password_hash, " + "display_name = excluded.display_name " + "RETURNING id", + username, + password_hash, + display_name, + ) + return str(row["id"]) if row else None + row = await conn.fetchrow( + "INSERT INTO operators (username, password_hash, display_name) " + "VALUES ($1, $2, $3) " + "ON CONFLICT (username) DO NOTHING " + "RETURNING id", + username, + password_hash, + display_name, + ) + return str(row["id"]) if row else None + + # ── Cohort aggregate read/write ────────────────────────────────────── + + async def get_cohort_aggregates( + self, + path: str, + metric: str, + since_date: Any, + ) -> list[dict]: + async with self.pool.acquire() as conn: + rows = await conn.fetch( + "SELECT path, metric, window_start, window_end, value, " + "cell_count, cell_suppressed, updated_at " + "FROM cohort_aggregates " + "WHERE path = $1 AND metric = $2 AND window_start >= $3 " + "ORDER BY window_start", + path, + metric, + since_date, + ) + return [dict(r) for r in rows] + + async def upsert_cohort_aggregate( + self, + path: str, + metric: str, + window_start: Any, + window_end: Any, + value: float | None, + cell_count: int, + cell_suppressed: bool, + ) -> None: + async with self.pool.acquire() as conn: + await conn.execute( + "INSERT INTO cohort_aggregates " + "(path, metric, window_start, window_end, value, cell_count, " + "cell_suppressed, updated_at) " + "VALUES ($1, $2, $3, $4, $5, $6, $7, now()) " + "ON CONFLICT (path, metric, window_start) DO UPDATE SET " + "window_end = excluded.window_end, value = excluded.value, " + "cell_count = excluded.cell_count, " + "cell_suppressed = excluded.cell_suppressed, " + "updated_at = now()", + path, + metric, + window_start, + window_end, + value, + cell_count, + cell_suppressed, + ) + + # ── IssuerKeyStore protocol (D-051, TASK-04-02) ────────────────────── + + async def init_issuer_key( + self, + key_id: str, + public_key: str, + private_key_enc: bytes | None, + ) -> None: + async with self.pool.acquire() as conn: + await conn.execute( + "INSERT INTO issuer_keys (id, public_key, private_key_enc, status) " + "VALUES ($1, $2, $3, 'active') " + "ON CONFLICT (id) DO NOTHING", + key_id, + public_key, + private_key_enc if private_key_enc is not None else b"", + ) + + async def get_active_signing_key_row(self) -> dict | None: + async with self.pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT id, public_key, private_key_enc, status, created_at " + "FROM issuer_keys WHERE status = 'active' " + "ORDER BY created_at DESC LIMIT 1" + ) + return dict(row) if row else None + + async def get_public_key_row(self, key_id: str) -> dict | None: + # Queries by id (NOT status) so superseded keys are found too — + # this is the R-VC-MIG-01 verification fallback (D-051). + async with self.pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT id, public_key, private_key_enc, status, created_at " + "FROM issuer_keys WHERE id = $1", + key_id, + ) + return dict(row) if row else None + + async def set_issuer_key_superseded(self, key_id: str) -> None: + async with self.pool.acquire() as conn: + await conn.execute( + "UPDATE issuer_keys SET status = 'superseded' WHERE id = $1", + key_id, + ) + + # ── Credential methods ─────────────────────────────────────────────── + + async def insert_credential( + self, + cred_id: str, + learner_ref: str, + payload_json: str, + signature_b64: str, + *, + operator_id: str | None = None, + vc_type: str = "MasteryCredential", + ) -> None: + async with self.pool.acquire() as conn: + await conn.execute( + "INSERT INTO issued_credentials " + "(id, operator_id, learner_ref, vc_type, payload_jsonb, " + "signature_b64, status) " + "VALUES ($1, $2, $3, $4, $5::jsonb, $6, 'active')", + cred_id, + operator_id, + learner_ref, + vc_type, + payload_json, + signature_b64, + ) + + async def get_credential(self, cred_id: str) -> dict | None: + # Returns a row shaped like PraxisStore.get_credential so the + # verification code can use either store interchangeably. + async with self.pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT id, learner_ref, " + "payload_jsonb::text AS vc_payload_json, signature_b64, " + "status, issued_at " + "FROM issued_credentials WHERE id = $1", + cred_id, + ) + return dict(row) if row else None + + async def set_credential_status(self, cred_id: str, status: str) -> None: + extra = ", revoked_at = now()" if status == "revoked" else "" + async with self.pool.acquire() as conn: + await conn.execute( + f"UPDATE issued_credentials SET status = $1{extra} WHERE id = $2", + status, + cred_id, + ) + + async def list_credentials(self, operator_id: str | None = None) -> list[dict]: + async with self.pool.acquire() as conn: + if operator_id is None: + rows = await conn.fetch( + "SELECT id, learner_ref, vc_type, status, issued_at, " + "revoked_at FROM issued_credentials ORDER BY issued_at DESC" + ) + else: + rows = await conn.fetch( + "SELECT id, learner_ref, vc_type, status, issued_at, " + "revoked_at FROM issued_credentials " + "WHERE operator_id = $1 ORDER BY issued_at DESC", + operator_id, + ) + return [dict(r) for r in rows] + + # ── Mastery gate event ─────────────────────────────────────────────── + + async def record_gate_event( + self, + learner_ref: str, + path_id: str, + scenario_id: str | None = None, + gate_outcome: str | None = None, + rubric_scores_jsonb: Any | None = None, + ) -> str: + event_id = str(uuid.uuid4()) + scores_json = ( + rubric_scores_jsonb + if isinstance(rubric_scores_jsonb, str) + else (json.dumps(rubric_scores_jsonb) if rubric_scores_jsonb is not None else None) + ) + async with self.pool.acquire() as conn: + await conn.execute( + "INSERT INTO mastery_gate_events " + "(id, learner_ref, scenario_id, path_id, gate_outcome, " + "rubric_scores_jsonb, source) " + "VALUES ($1, $2, $3, $4, $5, $6::jsonb, 'sync')", + event_id, + learner_ref, + scenario_id, + path_id, + gate_outcome, + scores_json, + ) + return event_id + + +__all__ = ["PgStore"] \ No newline at end of file diff --git a/db/store.py b/db/store.py index c074f4e..36d8226 100644 --- a/db/store.py +++ b/db/store.py @@ -338,7 +338,7 @@ class PraxisStore: async with self._connect() as db: db.row_factory = aiosqlite.Row cur = await db.execute( - "SELECT id, public_key, status, created_at " + "SELECT id, public_key, private_key_enc, status, created_at " "FROM issuer_keys WHERE id = ?", (key_id,), ) diff --git a/docker-compose.yml b/docker-compose.yml index bd443c1..e63ef14 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ -# Praxis v0.2 — Docker Compose service definition -# Runs the praxis server inside a Docker container (inside an LXC CT). -# Per RESEARCH.md Q4/Q8 / ARCHITECTURE.md §v0.2 Deployment Architecture. +# Praxis — Docker Compose service definition (v0.2 + v0.4 Postgres). +# Runs the praxis server + a Postgres 16 service inside a Docker-in-LXC CT. +# Per ARCHITECTURE.md §v0.2 Deployment + §v0.4 Operator-Tier Architecture. services: praxis: @@ -34,6 +34,13 @@ services: DEEPGRAM_REGION: "${DEEPGRAM_REGION:-na}" # Cartesia (D-014) CARTESIA_VOICE_ID: "${CARTESIA_VOICE_ID:-a3536a36-1d18-4efb-a95a-7c44b7b5e384}" + # v0.4 operator tier — Postgres DSN (D-050). Empty → graceful no-pool mode. + PRAXIS_PG_DSN: "${PRAXIS_PG_DSN:-}" + # v0.4 auth (D-041, D-056). Empty → server generates ephemeral secret (dev only). + PRAXIS_COOKIE_SECRET: "${PRAXIS_COOKIE_SECRET:-}" + PRAXIS_COOKIE_SECURE: "${PRAXIS_COOKIE_SECURE:-true}" + PRAXIS_VC_ISSUER_KEY: "${PRAXIS_VC_ISSUER_KEY:-}" + PRAXIS_ISSUER_URL: "${PRAXIS_ISSUER_URL:-https://praxis.example/issuers/v0.4}" env_file: # /etc/praxis/server.env is written by install-service.sh with # secrets injected via lxc.environment (G-101 fix: GITEA_TOKEN baked @@ -43,7 +50,45 @@ services: # `docker compose up` in production (so secrets are present at runtime). - path: /etc/praxis/server.env required: false + depends_on: + postgres: + condition: service_healthy + networks: + - praxis-net + + postgres: + image: postgres:16-slim + restart: unless-stopped + environment: + POSTGRES_USER: praxis + POSTGRES_PASSWORD: "${PRAXIS_PG_PASSWORD:-}" + POSTGRES_DB: praxis + PGDATA: /var/lib/postgresql/data/pgdata + env_file: + - path: /etc/praxis/server.env + required: false + volumes: + - pgdata:/var/lib/postgresql/data + - pgbackups:/backups + healthcheck: + test: ["CMD-SHELL", "pg_isready -U praxis -d praxis"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - praxis-net + # No `ports:` — Postgres is NOT exposed to the LXC host bridge (D-040). + # The praxis service reaches it via the praxis-net bridge using the + # service-DNS name `postgres`. volumes: praxis-data: - driver: local \ No newline at end of file + driver: local + pgdata: + driver: local + pgbackups: + driver: local + +networks: + praxis-net: + driver: bridge \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index a62c6f3..b8db8dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,13 @@ dependencies = [ "pynacl>=1.5", "canonicaljson>=2.0", "base58>=2.1", + # v0.4 operator tier — Postgres pool (D-050), argon2id passwords (D-041), + # slowapi rate limiting (D-041). RESEARCH-v0.4 §new-deps. + "asyncpg>=0.29", + "argon2-cffi>=23.1", + "slowapi>=0.1", + # SessionMiddleware uses itsdangerous for signed cookies (D-056). + "itsdangerous>=2.1", ] [project.optional-dependencies] diff --git a/scripts/backup-pg.sh b/scripts/backup-pg.sh new file mode 100755 index 0000000..9708409 --- /dev/null +++ b/scripts/backup-pg.sh @@ -0,0 +1,50 @@ +#!/bin/sh +# Praxis v0.4 — Nightly Postgres backup (D-055, G-008). +# +# Host-side cron script (decoupled from praxis service uptime — +# RESEARCH-v0.4 §1.5). Runs pg_dump inside the postgres container and +# writes a compressed custom-format dump to the pgbackups volume. +# +# The %u date format = day-of-week 1..7 (Monday=1, Sunday=7) → rolling +# 7-file retention with zero cleanup logic (D-055). Re-running overwrites +# the same day-of-week file. +# +# Cron entry (host, 03:30 CT nightly): +# 30 3 * * * /opt/praxis/scripts/backup-pg.sh +# +# Restore drill (G-008 — run at least once in staging to prove the backup +# is valid; NEVER restore into a live DB without stopping praxis first): +# docker compose stop praxis +# docker compose exec postgres pg_restore -U praxis -d praxis \ +# --clean --if-exists /backups/praxis-3.dump +# # verify: \d operators; SELECT count(*) FROM operators; (etc. for all 5 tables) +# docker compose start praxis +# +# POSIX-sh compatible (no bashisms). Exit 0 on success, 1 on failure. +# Args: none. Env: COMPOSE_PROJECT_DIR (default: current dir). + +set -eu + +PROJECT_DIR="${COMPOSE_PROJECT_DIR:-$(pwd)}" +cd "$PROJECT_DIR" + +DOW="$(date +%u)" +DUMP_FILE="/backups/praxis-${DOW}.dump" + +echo "backup-pg: dumping praxis DB → ${DUMP_FILE} (day-of-week ${DOW})" + +# -Fc = custom compressed format (works with pg_restore --clean --if-exists). +# -T stops the container from streaming while dumping? No — pg_dump is +# consistent within a transaction; the praxis service can stay up. +docker compose exec -T postgres pg_dump -U praxis -Fc praxis -f "$DUMP_FILE" + +# Verify the dump is non-empty (sanity — a 0-byte dump means failure). +SIZE=$(docker compose exec -T postgres stat -c '%s' "$DUMP_FILE" 2>/dev/null || echo 0) +if [ "$SIZE" -le 0 ]; then + echo "backup-pg: ERROR — dump file is empty (${DUMP_FILE})" >&2 + exit 1 +fi + +echo "backup-pg: OK — ${DUMP_FILE} is ${SIZE} bytes" +echo "backup-pg: restore drill (G-008): docker compose exec postgres pg_restore -U praxis -d praxis --clean --if-exists ${DUMP_FILE}" +exit 0 \ No newline at end of file diff --git a/scripts/create-operator.py b/scripts/create-operator.py new file mode 100755 index 0000000..376eb24 --- /dev/null +++ b/scripts/create-operator.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Praxis v0.4 — Operator bootstrap CLI (TASK-05-01, D-052). + +Creates the initial operator from env-provided credentials. Idempotent +(ON CONFLICT DO NOTHING). The --update flag forces a rehash + update. + +Env: + PRAXIS_BOOTSTRAP_OPERATOR_USER — operator username (required) + PRAXIS_BOOTSTRAP_OPERATOR_PASS — operator password (required) + PRAXIS_PG_DSN — Postgres DSN (required) + +Exit: 0 on success (created or already-exists), 1 on missing env / DB error. +Retries on connection failure (3 attempts, 5s backoff — R-BOOT-01). + +Run: + PRAXIS_BOOTSTRAP_OPERATOR_USER=admin PRAXIS_BOOTSTRAP_OPERATOR_PASS=... \ + PRAXIS_PG_DSN=postgresql://praxis:...@postgres:5432/praxis \ + python3 scripts/create-operator.py +""" + +from __future__ import annotations + +import argparse +import asyncio +import os +import sys + +from argon2 import PasswordHasher + +_ph = PasswordHasher() +_RETRY_ATTEMPTS = 3 +_RETRY_BACKOFF_S = 5.0 + + +async def create_operator(update: bool = False) -> int: + user = os.environ.get("PRAXIS_BOOTSTRAP_OPERATOR_USER", "").strip() + pw = os.environ.get("PRAXIS_BOOTSTRAP_OPERATOR_PASS", "") + dsn = os.environ.get("PRAXIS_PG_DSN", "").strip() + if not user or not pw: + print( + "create-operator: ERROR — PRAXIS_BOOTSTRAP_OPERATOR_USER and " + "PRAXIS_BOOTSTRAP_OPERATOR_PASS must be set (R-BOOT-02).", + file=sys.stderr, + ) + return 1 + if not dsn: + print( + "create-operator: ERROR — PRAXIS_PG_DSN must be set.", + file=sys.stderr, + ) + return 1 + + import asyncpg + from db.pg_migrate import apply_pg_migrations + from db.pg_store import PgStore + + last_exc: Exception | None = None + for attempt in range(1, _RETRY_ATTEMPTS + 1): + try: + pool = await asyncpg.create_pool( + dsn=dsn, min_size=1, max_size=3, command_timeout=10 + ) + try: + await apply_pg_migrations(pool) + store = PgStore(pool) + pw_hash = _ph.hash(pw) + display = user + oid = await store.insert_operator( + user, pw_hash, display, on_conflict_update=update + ) + if update: + print(f"create-operator: updated operator {user!r} (id={oid})") + elif oid is not None: + print(f"create-operator: created operator {user!r} (id={oid})") + else: + print(f"create-operator: operator {user!r} already exists (no change)") + return 0 + finally: + await pool.close() + except (asyncpg.PostgresConnectionError, ConnectionError, OSError) as exc: + last_exc = exc + if attempt < _RETRY_ATTEMPTS: + print( + f"create-operator: connection attempt {attempt} failed " + f"({exc}); retrying in {_RETRY_BACKOFF_S}s (R-BOOT-01)...", + file=sys.stderr, + ) + await asyncio.sleep(_RETRY_BACKOFF_S) + continue + print(f"create-operator: ERROR — could not connect after {_RETRY_ATTEMPTS} " + f"attempts: {last_exc}", file=sys.stderr) + return 1 + + +def main() -> int: + parser = argparse.ArgumentParser(description="Create the initial Praxis operator.") + parser.add_argument( + "--update", action="store_true", + help="Force rehash + update if the operator already exists.", + ) + args = parser.parse_args() + return asyncio.run(create_operator(update=args.update)) + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/scripts/proxmox/lxc-clone.sh b/scripts/proxmox/lxc-clone.sh index 9b3beb6..cf18158 100755 --- a/scripts/proxmox/lxc-clone.sh +++ b/scripts/proxmox/lxc-clone.sh @@ -40,7 +40,10 @@ upid=$(pve_curl POST "$create_path" \ "hostname=${hostname}" \ "storage=${storage}" \ "rootfs=${storage}:16" \ - "memory=${PROXMOX_MEMORY_MB:-4096}" \ + # v0.4: 6144MB default (was 4096 in v0.2). Postgres ~400MB + praxis + # ~500MB + Docker daemon ~200MB + build headroom ~1GB + margin + # (REQ-NFR-MT-01). Override with PROXMOX_MEMORY_MB if needed. + "memory=${PROXMOX_MEMORY_MB:-6144}" \ "net0=name=eth0,bridge=vmbr0,ip=dhcp" \ "arch=amd64" \ "features=nesting=1") diff --git a/server/__main__.py b/server/__main__.py index 133122c..61b00ff 100644 --- a/server/__main__.py +++ b/server/__main__.py @@ -14,6 +14,7 @@ no audio/no tokens at runtime, not a crash. from __future__ import annotations import os +from contextlib import asynccontextmanager from typing import Any from loguru import logger @@ -27,14 +28,25 @@ try: except ImportError: # pragma: no cover pass -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles from pipecat.transports.smallwebrtc.connection import SmallWebRTCConnection +from slowapi.errors import RateLimitExceeded +from slowapi import _rate_limit_exceeded_handler +from db.pg_migrate import apply_pg_migrations +from db.pg_store import PgStore from db.store import PraxisStore +from server.auth.cookies import get_session_middleware_kwargs +from server.auth.rate_limit import limiter +from server.auth.routes import router as auth_router from server.pipeline import build_pipeline +from server.vc.issuer_keys import _load_root_key +from server.vc.migrate_keys import migrate_issuer_keys from server.vc.verification import verify_credential +from starlette.middleware.sessions import SessionMiddleware _store = PraxisStore() @@ -47,6 +59,58 @@ HOST = _env("PRAXIS_HOST", "0.0.0.0") PORT = int(_env("PRAXIS_PORT", "8789")) +@asynccontextmanager +async def lifespan(app: FastAPI): + """v0.4 — create the asyncpg Postgres pool on startup, close on shutdown. + + Graceful degradation (D-050, REQ-NFR-MT-01): if PRAXIS_PG_DSN is unset, + the server starts without Postgres — the learner voice loop (SQLite) + is unaffected. app.state.pg_pool / app.state.pg_store are None in that + case and auth/operator routes return 503. + """ + dsn = os.environ.get("PRAXIS_PG_DSN", "").strip() + if not dsn: + logger.warning( + "PRAXIS_PG_DSN not set — starting without Postgres (dev/no-pool mode). " + "Operator auth + cohort endpoints will be unavailable (503). " + "Learner voice loop (SQLite) is unaffected." + ) + app.state.pg_pool = None + app.state.pg_store = None + try: + yield + finally: + return + import asyncpg + + logger.info("Creating asyncpg Postgres pool (min=1, max=10, D-050)") + pool = await asyncpg.create_pool( + dsn=dsn, + min_size=1, + max_size=10, + command_timeout=10, + ) + app.state.pg_pool = pool + app.state.pg_store = PgStore(pool) + try: + applied = await apply_pg_migrations(pool) + if applied: + logger.info(f"Postgres migrations applied: {applied}") + else: + logger.info("Postgres migrations up to date") + # VC key migration (TASK-06-03, R-VC-MIG-01, G-027) — runs once on + # first boot, idempotent. Non-fatal on failure (v0.3 SQLite path + # remains intact for verification). + await _maybe_migrate_issuer_keys() + try: + yield + finally: + pass + finally: + await pool.close() + logger.info("Postgres pool closed") + + class WebRTCOffer(BaseModel): """Client→server WebRTC offer (SDP + type).""" @@ -54,13 +118,19 @@ class WebRTCOffer(BaseModel): type: str = "offer" -app = FastAPI(title="Praxis v0.1 voice server", version="0.1.0") +app = FastAPI(title="Praxis v0.1 voice server", version="0.1.0", lifespan=lifespan) +# slowapi rate-limit state + 429 handler (D-041, TASK-03-03). +app.state.limiter = limiter +app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) app.add_middleware( CORSMiddleware, allow_origins=["*"], # dev — the client is a separate Vite origin allow_methods=["*"], allow_headers=["*"], ) +# SessionMiddleware (signed cookies, D-056) — added AFTER CORS so it is +# the outermost middleware (signs cookies before CORS headers are added). +app.add_middleware(SessionMiddleware, **get_session_middleware_kwargs()) @app.get("/health") @@ -123,20 +193,60 @@ async def webrtc_offer(offer: WebRTCOffer) -> dict[str, str]: @app.get("/vc/verify/{credential_id}") async def vc_verify(credential_id: str) -> dict[str, Any]: - """Public, unauthenticated VC verification endpoint (D-043). + """Public, unauthenticated VC verification endpoint (D-043, G-011). - Returns {valid, status, issuer, credential, mastery, credentialTier, - verifiedAt}. 404 if the credential id is not found. No PII beyond what - the credential asserts. + Two-store fallback (G-011, binding contract): + (a) If Postgres is available (app.state.pg_store), use it for issuer + key lookup (active + superseded keys). + (b) If the credential is not in Postgres issued_credentials, fall back + to SQLite (v0.3 credentials remain in SQLite — D-051). + (c) If Postgres is NOT available, use the v0.3 SQLite path for both. + The VC key migration (TASK-04-03) runs once on first boot (idempotent) + inside the lifespan — see _maybe_migrate_issuer_keys. """ await _store.init() - result = await verify_credential(_store, credential_id) + pg_store = getattr(app.state, "pg_store", None) + result = await verify_credential( + _store, credential_id, + pg_store=pg_store, sqlite_store=_store, + ) if result is None: raise HTTPException(status_code=404, detail="credential not found") return result -# ── Static client serving (D-023, REQ-DEPLOY-13) ──────────────────── +async def _maybe_migrate_issuer_keys() -> None: + """Run the VC key migration on first boot (TASK-06-03, R-VC-MIG-01). + + Idempotent — no-op if Postgres already has an active issuer key. G-027: + if SQLite has no v0.3 active key (fresh deploy), skips archive and only + generates a fresh v0.4 keypair. + """ + pg_store = getattr(app.state, "pg_store", None) + if pg_store is None: + return + try: + await _store.init() + root_key = _load_root_key() + result = await migrate_issuer_keys(_store, pg_store, root_key) + if result["new_key_id"] is not None: + logger.info( + f"VC key migration: archived v0.3 key={result['archived_key_id']}, " + f"generated fresh v0.4 key={result['new_key_id']}" + ) + else: + logger.info("VC key migration: active key already present (no-op)") + except Exception as exc: + logger.error(f"VC key migration failed (non-fatal — v0.3 path intact): {exc}") + + +# ── Operator auth routes (TASK-06-02, D-057) ─────────────────────────── +# Mounted BEFORE the StaticFiles mount so /api/operator/* is matched by +# the router (routes-before-static-mount constraint, carry-forward v0.2). +app.include_router(auth_router) + + +# ── Static client serving (D-023, REQ-DEPLOY-13) ────────────────────── # Mount client/dist as StaticFiles at "/" AFTER all API routes so they # take precedence. html=True serves index.html for "/" (SPA root). # The client has no React Router (single-view state machine: start→live diff --git a/server/auth/__init__.py b/server/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/auth/cookies.py b/server/auth/cookies.py new file mode 100644 index 0000000..156f570 --- /dev/null +++ b/server/auth/cookies.py @@ -0,0 +1,68 @@ +"""Signed cookie configuration (TASK-03-02, D-041, D-056, R-AUTH-01, G-031). + +Returns kwargs for Starlette SessionMiddleware (itsdangerous HMAC-SHA256 +signed cookies — D-056, stateless, no sessions table). The cookie name is +`praxis_op` (distinct from any future learner cookie). + +R-AUTH-01 / G-031 reframe: the PRIMARY mitigation for a sniffed operator +cookie is the k-anonymity defense-in-depth — the cohort dashboard reads +only k-anonymized aggregates, so a sniffed cookie leaks NO learner PII. +The `PRAXIS_COOKIE_SECURE` flag is the SECONDARY mitigation (operational +convenience for when TLS arrives). It defaults to true; the HTTP pilot +(LXC, no TLS — D-030) sets it to false with a logged WARNING. +""" + +from __future__ import annotations + +import os +import secrets + +from loguru import logger + +_COOKIE_MAX_AGE_S = 28800 # 8h (D-041) + + +def _env_bool(key: str, default: bool) -> bool: + raw = os.environ.get(key, "").strip().lower() + if raw in ("true", "1", "yes", "on"): + return True + if raw in ("false", "0", "no", "off"): + return False + return default + + +def get_session_middleware_kwargs() -> dict: + """Return kwargs for Starlette SessionMiddleware. + + If PRAXIS_COOKIE_SECRET is unset, generate an ephemeral random secret + and log a WARNING (dev only — sessions won't survive a restart and this + MUST NOT be used in pilot/production). + """ + secret = os.environ.get("PRAXIS_COOKIE_SECRET", "").strip() + if not secret: + secret = secrets.token_urlsafe(48) + logger.warning( + "PRAXIS_COOKIE_SECRET not set — generated an ephemeral random secret. " + "Sessions will NOT survive a server restart. This is dev-only; set " + "PRAXIS_COOKIE_SECRET (>=32 bytes) for pilot/production." + ) + secure = _env_bool("PRAXIS_COOKIE_SECURE", True) + if not secure: + logger.warning( + "Cookie Secure flag disabled (PRAXIS_COOKIE_SECURE=false) — HTTP pilot " + "mode (R-AUTH-01). Do not use in production. NOTE (G-031): the primary " + "R-AUTH-01 mitigation is k-anon defense-in-depth (cohort dashboard reads " + "only k-anonymized aggregates → sniffed cookie leaks no PII); this flag " + "is the secondary mitigation." + ) + return { + "secret_key": secret, + "session_cookie": "praxis_op", + "max_age": _COOKIE_MAX_AGE_S, + "https_only": secure, + "same_site": "strict", + "path": "/", + } + + +__all__ = ["get_session_middleware_kwargs"] \ No newline at end of file diff --git a/server/auth/dependencies.py b/server/auth/dependencies.py new file mode 100644 index 0000000..849a1e9 --- /dev/null +++ b/server/auth/dependencies.py @@ -0,0 +1,56 @@ +"""current_operator dependency (TASK-03-04, D-057). + +Server-side auth enforcement: every `/api/operator/*` protected route uses +`Depends(current_operator)`. The dependency NEVER trusts the client (D-057) +— it reads the signed-cookie session, fetches the operator from Postgres, +and 401s on any gap (missing/invalid/expired cookie, unknown id, inactive +operator). The cookie is the authz *token*; the Postgres lookup is the +authz *decision*. +""" + +from __future__ import annotations + +from fastapi import HTTPException, Request, status + +from server.auth.models import Operator + + +async def current_operator(request: Request) -> Operator: + """Resolve the authenticated operator from the signed-cookie session. + + Raises 401 on: missing session, missing operator_id, no Postgres store + (503 actually — operator tier unavailable), unknown operator id, or an + inactive operator (session is cleared in the latter case so the client + cookie is invalidated). + """ + pg_store = getattr(request.app.state, "pg_store", None) + if pg_store is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="operator tier unavailable (no Postgres)", + ) + session = request.session + op_id = session.get("operator_id") if session else None + if not op_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="not authenticated", + ) + row = await pg_store.get_operator_by_id(op_id) + if row is None or not row.get("is_active"): + # Inactive/unknown → clear the session so the cookie is invalidated. + if session: + session.clear() + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="not authenticated", + ) + return Operator( + id=str(row["id"]), + username=row["username"], + display_name=row.get("display_name"), + role=row.get("role", "operator"), + ) + + +__all__ = ["current_operator"] \ No newline at end of file diff --git a/server/auth/models.py b/server/auth/models.py new file mode 100644 index 0000000..95d64e1 --- /dev/null +++ b/server/auth/models.py @@ -0,0 +1,18 @@ +"""Auth data models (TASK-03-04).""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class Operator: + """The authenticated operator injected into protected routes (D-057).""" + + id: str + username: str + display_name: str | None + role: str + + +__all__ = ["Operator"] \ No newline at end of file diff --git a/server/auth/passwords.py b/server/auth/passwords.py new file mode 100644 index 0000000..35b3820 --- /dev/null +++ b/server/auth/passwords.py @@ -0,0 +1,44 @@ +"""Argon2id password hashing (TASK-03-01, D-041, REQ-NFR-AUTH-01). + +Uses argon2-cffi PasswordHasher with defaults that exceed OWASP minimums +(time_cost=3, memory_cost=64MiB, parallelism=4 — RESEARCH-v0.4 §2.1). +Single operator, low-frequency logins → hashing latency < 1s is +acceptable (R-AUTH-02). +""" + +from __future__ import annotations + +from argon2 import PasswordHasher +from argon2.exceptions import VerifyMismatchError + +_ph = PasswordHasher() + + +def hash_password(plain: str) -> str: + """Hash a plaintext password with argon2id. Returns the encoded hash string.""" + return _ph.hash(plain) + + +def verify_password(stored_hash: str, plain: str) -> bool: + """Verify a plaintext password against a stored argon2id hash. + + Returns False on mismatch (no exception) so the login flow can apply a + uniform 401 + rate-limit-increment path on any auth failure. + """ + try: + _ph.verify(stored_hash, plain) + return True + except VerifyMismatchError: + return False + except Exception: + return False + + +def needs_rehash(stored_hash: str) -> bool: + """True if the stored hash was produced with weaker params than the + current PasswordHasher defaults. The login flow rehashes + updates the + store when this returns True (param upgrades without forcing a reset).""" + return _ph.check_needs_rehash(stored_hash) + + +__all__ = ["hash_password", "verify_password", "needs_rehash"] \ No newline at end of file diff --git a/server/auth/rate_limit.py b/server/auth/rate_limit.py new file mode 100644 index 0000000..535e0e9 --- /dev/null +++ b/server/auth/rate_limit.py @@ -0,0 +1,34 @@ +"""Login rate limiting (TASK-03-03, D-041). + +slowapi Limiter with an in-memory backend (single-instance — D-041). +5 login attempts per minute per client IP. On exceed → 429 + Retry-After. + +R-AUTH-03 (in-memory counter lost on restart) is an accepted pilot risk +(RESEARCH-v0.4 §2.5) — a restart at most resets the counter, which slightly +widens the brute-force window but does not enable it (argon2id + 5/min is +still the binding control). A hand-rolled counter is the documented +fallback if slowapi is ever removed. +""" + +from __future__ import annotations + +from slowapi import Limiter +from slowapi.util import get_remote_address + +limiter = Limiter(key_func=get_remote_address, storage_uri="memory://") + + +def reset_login_rate_limit() -> None: + """Clear the in-memory rate-limit counters (test helper + restart-safe).""" + try: + limiter.reset() + except Exception: + pass + + +def rate_limit_login(): + """Decorator factory: 5 login attempts per minute per IP (D-041).""" + return limiter.limit("5/minute") + + +__all__ = ["limiter", "rate_limit_login", "reset_login_rate_limit"] \ No newline at end of file diff --git a/server/auth/routes.py b/server/auth/routes.py new file mode 100644 index 0000000..a6eabb9 --- /dev/null +++ b/server/auth/routes.py @@ -0,0 +1,118 @@ +"""Auth route handlers — login, logout, me (TASK-03-05, D-041, D-056, D-057). + +APIRouter(prefix="/api/operator") with: + POST /login — rate-limited 5/min (TASK-03-03), NOT auth-gated. + POST /logout — auth-gated (Depends(current_operator)). + GET /me — auth-gated (React route guard — D-057). + +Stateless cookies (D-056): logout clears the server-side session; the +client also clears its cookie. No sessions table. +""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from pydantic import BaseModel + +from server.auth.dependencies import current_operator +from server.auth.models import Operator +from server.auth.passwords import hash_password, needs_rehash, verify_password +from server.auth.rate_limit import rate_limit_login + +router = APIRouter(prefix="/api/operator", tags=["operator-auth"]) + + +class LoginBody(BaseModel): + username: str + password: str + + +class OperatorOut(BaseModel): + id: str + username: str + display_name: str | None + role: str = "operator" + + +class LoginResponse(BaseModel): + operator: OperatorOut + + +class MeResponse(BaseModel): + operator: OperatorOut + + +class OkResponse(BaseModel): + ok: bool = True + + +def _operator_out(op: Operator) -> OperatorOut: + return OperatorOut( + id=op.id, + username=op.username, + display_name=op.display_name, + role=op.role, + ) + + +@router.post("/login", response_model=LoginResponse) +@rate_limit_login() +async def login(body: LoginBody, request: Request) -> LoginResponse: + """Rate-limited login (5/min per IP — D-041). + + On success: sets `request.session["operator_id"]` (signed cookie via + SessionMiddleware) + updates last_login_at. On needs_rehash → rehash + + update the store. On failure → 401 (no cookie set). + """ + pg_store = getattr(request.app.state, "pg_store", None) + if pg_store is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="operator tier unavailable (no Postgres)", + ) + row = await pg_store.get_operator_by_username(body.username) + if row is None or not row.get("is_active"): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="invalid credentials", + ) + if not verify_password(row["password_hash"], body.password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="invalid credentials", + ) + op_id = str(row["id"]) + request.session["operator_id"] = op_id + await pg_store.update_last_login(op_id) + if needs_rehash(row["password_hash"]): + new_hash = hash_password(body.password) + async with pg_store.pool.acquire() as conn: + await conn.execute( + "UPDATE operators SET password_hash = $1 WHERE id = $2", + new_hash, op_id, + ) + return LoginResponse( + operator=OperatorOut( + id=op_id, + username=row["username"], + display_name=row.get("display_name"), + role=row.get("role", "operator"), + ) + ) + + +@router.post("/logout", response_model=OkResponse) +async def logout(request: Request, op: Operator = Depends(current_operator)) -> OkResponse: + # Stateless (D-056): clearing the server session invalidates the signed + # cookie's payload; the client also clears its cookie. + request.session.clear() + return OkResponse(ok=True) + + +@router.get("/me", response_model=MeResponse) +async def me(op: Operator = Depends(current_operator)) -> MeResponse: + """React route guard endpoint (D-057). 200 → render; 401 → redirect.""" + return MeResponse(operator=_operator_out(op)) + + +__all__ = ["router"] \ No newline at end of file diff --git a/server/vc/issuer_keys.py b/server/vc/issuer_keys.py index b881d58..20d02f3 100644 --- a/server/vc/issuer_keys.py +++ b/server/vc/issuer_keys.py @@ -13,6 +13,7 @@ import base64 import os import uuid from dataclasses import dataclass +from typing import Any, Protocol, runtime_checkable import nacl.secret import nacl.signing @@ -22,6 +23,27 @@ 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: @@ -63,7 +85,7 @@ def _decrypt_private_key(private_key_enc: bytes, root_key: bytes) -> nacl.signin return nacl.signing.SigningKey(seed) -async def init_issuer_key(store: PraxisStore, root_key: bytes | None = None) -> KeyPair: +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 @@ -75,7 +97,7 @@ async def init_issuer_key(store: PraxisStore, root_key: bytes | None = None) -> async def get_active_signing_key( - store: PraxisStore, root_key: bytes | None = None + 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() @@ -89,18 +111,19 @@ async def get_active_signing_key( return kp, row["private_key_enc"] -async def _fetch_private_key_enc(store: PraxisStore, key_id: str) -> bytes: - async with store._connect() as db: - db.row_factory = None - cur = await db.execute( - "SELECT private_key_enc FROM issuer_keys WHERE id = ?", (key_id,) - ) - row = await cur.fetchone() - return bytes(row[0]) if row else b"" +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: PraxisStore, key_id: str + store: IssuerKeyStore, key_id: str ) -> nacl.signing.VerifyKey: row = await store.get_public_key_row(key_id) if row is None: @@ -119,6 +142,7 @@ async def rotate_key(store: PraxisStore, root_key: bytes | None = None) -> KeyPa __all__ = [ + "IssuerKeyStore", "KeyPair", "init_issuer_key", "get_active_signing_key", diff --git a/server/vc/migrate_keys.py b/server/vc/migrate_keys.py new file mode 100644 index 0000000..546143d --- /dev/null +++ b/server/vc/migrate_keys.py @@ -0,0 +1,94 @@ +"""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"] \ No newline at end of file diff --git a/server/vc/verification.py b/server/vc/verification.py index 4ccde48..7d0a2c5 100644 --- a/server/vc/verification.py +++ b/server/vc/verification.py @@ -1,11 +1,24 @@ -"""Public VC verification (SLICE-09 TASK-09-04, D-043, REQ-NFR-VC-02). +"""Public VC verification (SLICE-09 TASK-09-04, D-043, REQ-NFR-VC-02; +v0.4 TASK-04-04 two-store fallback per G-011). `GET /vc/verify/` — public, unauthenticated. Fetches the -credential from SQLite, fetches the issuer public key, validates the Ed25519 -signature against the JCS-canonicalized payload, checks the Bitstring Status -List (no cache — fetched on every verify call, REQ-NFR-VC-02). Returns JSON +credential + issuer public key, validates the Ed25519 signature against +the JCS-canonicalized payload, checks the Bitstring Status List (no cache +— fetched on every verify call, REQ-NFR-VC-02). Returns JSON {valid, status, issuer, credential, mastery, credentialTier, verifiedAt}. No PII beyond what the credential asserts. + +G-011 two-store fallback semantics (binding contract): + (a) If Postgres is available (pg_store is not None), use it for issuer + key lookup (both active AND superseded keys — get_public_key_row + queries by id, not status). + (b) If Postgres is available but the credential is not found in its + issued_credentials table, fall back to SQLite issued_credentials + (v0.3 credentials remain in SQLite — D-051 "no re-issuance"). + (c) If Postgres is NOT available (pg_store is None), use the existing + v0.3 SQLite path for BOTH keys and credentials (full v0.3 compat). +The key store used for verification is always the one that holds the key +row found by key_id; the credential store is whichever store had the row. """ from __future__ import annotations @@ -17,7 +30,7 @@ from typing import Any from db.store import PraxisStore from server.vc.issuer import verify_proof, extract_key_id, CREDENTIAL_TIER -from server.vc.issuer_keys import get_public_key_for_verification +from server.vc.issuer_keys import IssuerKeyStore, get_public_key_for_verification from server.vc.status_list import BitstringStatusList @@ -26,26 +39,35 @@ def _now_iso() -> str: async def verify_credential( - store: PraxisStore, credential_id: str + store: IssuerKeyStore, + credential_id: str, + *, + pg_store: IssuerKeyStore | None = None, + sqlite_store: PraxisStore | None = None, ) -> dict[str, Any] | None: - row = await store.get_credential(credential_id) + """Verify a VC. Returns the verification result dict, or None if the + credential id is not found in any store. + + Per G-011: + - If pg_store is provided, try it first for BOTH credential + key + lookup; fall back to sqlite_store for the credential if Postgres + doesn't have it (v0.3 credentials stay in SQLite). + - If pg_store is None, use `store` (the v0.3 SQLite path) for both. + """ + row = await _lookup_credential(credential_id, store, pg_store, sqlite_store) if row is None: return None secured_doc = json.loads(row["vc_payload_json"]) key_id = extract_key_id(secured_doc) if key_id is None: return _invalid(row, secured_doc) - try: - verify_key = await get_public_key_for_verification(store, key_id) - except KeyError: + # Key lookup: prefer pg_store (G-011a) for v0.4 keys + archived v0.3 + # keys; fall back to `store` (SQLite) if pg_store doesn't have the key. + verify_key = await _lookup_public_key(key_id, store, pg_store) + if verify_key is None: return _invalid(row, secured_doc) sig_valid = verify_proof(secured_doc, verify_key) - revoked = False - cs = secured_doc.get("credentialStatus") or {} - idx_str = cs.get("statusListIndex") - if idx_str is not None: - sl = BitstringStatusList(store, "default") - revoked = await sl.get_status(int(idx_str)) + revoked = await _check_revocation(secured_doc, store, sqlite_store or store) status = "revoked" if revoked else "active" valid = bool(sig_valid and not revoked) subject = secured_doc.get("credentialSubject") or {} @@ -73,6 +95,54 @@ async def verify_credential( } +async def _lookup_credential( + credential_id: str, + store: IssuerKeyStore, + pg_store: IssuerKeyStore | None, + sqlite_store: PraxisStore | None, +) -> dict | None: + """G-011(b): try Postgres first, fall back to SQLite for v0.3 creds.""" + if pg_store is not None: + row = await pg_store.get_credential(credential_id) + if row is not None: + return row + if sqlite_store is not None: + return await sqlite_store.get_credential(credential_id) + return None + # G-011(c): no Postgres — v0.3 SQLite path. + return await store.get_credential(credential_id) + + +async def _lookup_public_key( + key_id: str, + store: IssuerKeyStore, + pg_store: IssuerKeyStore | None, +): + """G-011(a): prefer Postgres for key lookup (finds active + superseded); + fall back to `store` (SQLite) if Postgres doesn't have the key.""" + if pg_store is not None: + try: + vk = await get_public_key_for_verification(pg_store, key_id) + return vk + except KeyError: + pass + try: + return await get_public_key_for_verification(store, key_id) + except KeyError: + return None + + +async def _check_revocation( + secured_doc: dict, store: IssuerKeyStore, status_store: PraxisStore +) -> bool: + cs = secured_doc.get("credentialStatus") or {} + idx_str = cs.get("statusListIndex") + if idx_str is None: + return False + sl = BitstringStatusList(status_store, "default") + return await sl.get_status(int(idx_str)) + + def _invalid(row: dict, secured_doc: dict) -> dict[str, Any]: subject = secured_doc.get("credentialSubject") or {} return { diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..56a1a4c --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,310 @@ +"""Auth unit tests (TASK-03-06) — mocked PgStore, no real Postgres. + +Covers: password hash/verify/rehash, cookie config (secure flag, missing +secret), rate limiter threshold, current_operator dependency (401/503 +cases, active/inactive), login/logout/me route handlers. +""" + +from __future__ import annotations + +import os +import types +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from starlette.middleware.sessions import SessionMiddleware + +from server.auth.cookies import get_session_middleware_kwargs +from server.auth.dependencies import current_operator +from server.auth.models import Operator +from server.auth.passwords import hash_password, needs_rehash, verify_password +from server.auth.rate_limit import limiter, rate_limit_login, reset_login_rate_limit +from server.auth.routes import router + + +@pytest.fixture(autouse=True) +def _reset_limiter(): + reset_login_rate_limit() + yield + reset_login_rate_limit() + + +# ── Passwords ─────────────────────────────────────────────────────────────── + + +def test_password_hash_verify_roundtrip(): + h = hash_password("correct horse battery staple") + assert h.startswith("$argon2id$") + assert verify_password(h, "correct horse battery staple") is True + + +def test_password_verify_wrong_returns_false(): + h = hash_password("secret-1") + assert verify_password(h, "secret-2") is False + # no exception raised — uniform 401 path + assert verify_password(h, "") is False + + +def test_needs_rehash_false_for_current_defaults(): + h = hash_password("x") + assert needs_rehash(h) is False + + +def test_needs_rehash_true_for_weak_hash(): + # A hash produced with weaker params triggers rehash. + from argon2 import PasswordHasher + weak = PasswordHasher(time_cost=1, memory_cost=8, parallelism=1).hash("x") + assert needs_rehash(weak) is True + + +# ── Cookie config ─────────────────────────────────────────────────────────── + + +def test_cookie_kwargs_defaults(monkeypatch): + monkeypatch.setenv("PRAXIS_COOKIE_SECRET", "x" * 48) + monkeypatch.setenv("PRAXIS_COOKIE_SECURE", "true") + kw = get_session_middleware_kwargs() + assert kw["session_cookie"] == "praxis_op" + assert kw["max_age"] == 28800 + # Starlette SessionMiddleware: https_only (not secure), same_site (not samesite), + # httponly is always True (no kwarg). path is the cookie path. + assert kw["https_only"] is True + assert kw["same_site"] == "strict" + assert kw["path"] == "/" + + +def test_cookie_secure_false(monkeypatch): + monkeypatch.setenv("PRAXIS_COOKIE_SECRET", "x" * 48) + monkeypatch.setenv("PRAXIS_COOKIE_SECURE", "false") + kw = get_session_middleware_kwargs() + assert kw["https_only"] is False + + +def test_cookie_secret_unset_generates_random(monkeypatch): + monkeypatch.delenv("PRAXIS_COOKIE_SECRET", raising=False) + kw = get_session_middleware_kwargs() + assert kw["secret_key"] + assert len(kw["secret_key"]) >= 32 + + +# ── current_operator dependency ───────────────────────────────────────────── + + +def _make_app_with_store(store) -> FastAPI: + app = FastAPI() + app.state.pg_store = store + app.add_middleware(SessionMiddleware, secret_key="test-secret-1234567890abcdef") + app.include_router(router) + return app + + +def _mock_store(operator_row=None): + store = MagicMock() + store.get_operator_by_id = AsyncMock(return_value=operator_row) + return store + + +def test_current_operator_no_cookie_401(): + app = _make_app_with_store(_mock_store(operator_row=None)) + with TestClient(app) as client: + r = client.get("/api/operator/me") + assert r.status_code == 401 + + +def test_current_operator_no_postgres_503(): + app = FastAPI() + app.state.pg_store = None + app.add_middleware(SessionMiddleware, secret_key="test-secret-1234567890abcdef") + app.include_router(router) + with TestClient(app) as client: + r = client.get("/api/operator/me") + assert r.status_code == 503 + + +def test_current_operator_inactive_401(): + op = { + "id": "11111111-1111-1111-1111-111111111111", + "username": "ghost", + "display_name": "Ghost", + "role": "operator", + "is_active": False, + } + store = _mock_store(operator_row=op) + app = _make_app_with_store(store) + with TestClient(app) as client: + # seed a session by hitting login would need a real store; instead + # set the session directly via a cookie. Use TestClient's cookie jar. + # Easiest: POST /login with a mocked store that returns the op. + store.get_operator_by_username = AsyncMock(return_value=op) + store.update_last_login = AsyncMock() + store.pool = MagicMock() + conn = MagicMock() + conn.execute = AsyncMock() + cm = MagicMock() + cm.__aenter__ = AsyncMock(return_value=conn) + cm.__aexit__ = AsyncMock(return_value=None) + store.pool.acquire = MagicMock(return_value=cm) + # hash the password so verify works + op = dict(op) + op["password_hash"] = hash_password("pw") + store.get_operator_by_username = AsyncMock(return_value=op) + r = client.post("/api/operator/login", json={"username": "ghost", "password": "pw"}) + # inactive operator → 401 even with correct password + assert r.status_code == 401 + + +def test_current_operator_valid_cookie_returns_operator(): + op = { + "id": "22222222-2222-2222-2222-222222222222", + "username": "alice", + "display_name": "Alice", + "role": "operator", + "is_active": True, + "password_hash": hash_password("pw"), + } + store = _mock_store(operator_row=op) + store.get_operator_by_username = AsyncMock(return_value=op) + store.update_last_login = AsyncMock() + store.pool = MagicMock() + conn = MagicMock() + conn.execute = AsyncMock() + cm = MagicMock() + cm.__aenter__ = AsyncMock(return_value=conn) + cm.__aexit__ = AsyncMock(return_value=None) + store.pool.acquire = MagicMock(return_value=cm) + app = _make_app_with_store(store) + with TestClient(app) as client: + r = client.post("/api/operator/login", json={"username": "alice", "password": "pw"}) + assert r.status_code == 200 + assert r.json()["operator"]["username"] == "alice" + # cookie is now set; /me should work + r2 = client.get("/api/operator/me") + assert r2.status_code == 200 + assert r2.json()["operator"]["username"] == "alice" + + +# ── Login route ───────────────────────────────────────────────────────────── + + +def test_login_wrong_password_401_no_cookie(): + op = { + "id": "33333333-3333-3333-3333-333333333333", + "username": "bob", + "display_name": None, + "role": "operator", + "is_active": True, + "password_hash": hash_password("correct"), + } + store = _mock_store(operator_row=op) + store.get_operator_by_username = AsyncMock(return_value=op) + store.update_last_login = AsyncMock() + app = _make_app_with_store(store) + with TestClient(app) as client: + r = client.post("/api/operator/login", json={"username": "bob", "password": "wrong"}) + assert r.status_code == 401 + # no auth cookie set on failure + cookies = client.cookies.get("praxis_op") + assert not cookies + + +def test_login_unknown_user_401(): + store = _mock_store(operator_row=None) + store.get_operator_by_username = AsyncMock(return_value=None) + app = _make_app_with_store(store) + with TestClient(app) as client: + r = client.post("/api/operator/login", json={"username": "nobody", "password": "x"}) + assert r.status_code == 401 + + +def test_login_no_postgres_503(): + app = FastAPI() + app.state.pg_store = None + app.add_middleware(SessionMiddleware, secret_key="test-secret-1234567890abcdef") + app.include_router(router) + with TestClient(app) as client: + r = client.post("/api/operator/login", json={"username": "a", "password": "b"}) + assert r.status_code == 503 + + +# ── Logout ────────────────────────────────────────────────────────────────── + + +def test_logout_clears_session(): + op = { + "id": "44444444-4444-4444-4444-444444444444", + "username": "carol", + "display_name": "Carol", + "role": "operator", + "is_active": True, + "password_hash": hash_password("pw"), + } + store = _mock_store(operator_row=op) + store.get_operator_by_username = AsyncMock(return_value=op) + store.update_last_login = AsyncMock() + store.pool = MagicMock() + conn = MagicMock() + conn.execute = AsyncMock() + cm = MagicMock() + cm.__aenter__ = AsyncMock(return_value=conn) + cm.__aexit__ = AsyncMock(return_value=None) + store.pool.acquire = MagicMock(return_value=cm) + app = _make_app_with_store(store) + with TestClient(app) as client: + client.post("/api/operator/login", json={"username": "carol", "password": "pw"}) + r = client.post("/api/operator/logout") + assert r.status_code == 200 + assert r.json()["ok"] is True + # /me now 401 + r2 = client.get("/api/operator/me") + assert r2.status_code == 401 + + +# ── Rehash on login ───────────────────────────────────────────────────────── + + +def test_login_rehash_when_needed(): + from argon2 import PasswordHasher + weak_hasher = PasswordHasher(time_cost=1, memory_cost=8, parallelism=1) + op = { + "id": "55555555-5555-5555-5555-555555555555", + "username": "dave", + "display_name": "Dave", + "role": "operator", + "is_active": True, + "password_hash": weak_hasher.hash("pw"), + } + store = _mock_store(operator_row=op) + store.get_operator_by_username = AsyncMock(return_value=op) + store.update_last_login = AsyncMock() + store.pool = MagicMock() + executed = [] + conn = MagicMock() + async def _exec(*a, **kw): + executed.append(a) + conn.execute = _exec + cm = MagicMock() + cm.__aenter__ = AsyncMock(return_value=conn) + cm.__aexit__ = AsyncMock(return_value=None) + store.pool.acquire = MagicMock(return_value=cm) + app = _make_app_with_store(store) + with TestClient(app) as client: + r = client.post("/api/operator/login", json={"username": "dave", "password": "pw"}) + assert r.status_code == 200 + assert executed, "rehash UPDATE should have run" + # the second arg to execute is the new hash; verify it's argon2id + assert executed[0][1].startswith("$argon2id$") + + +# ── Rate limiter ──────────────────────────────────────────────────────────── + + +def test_rate_limit_login_decorator(): + # The decorator factory returns a decorator; applying it should not raise. + deco = rate_limit_login() + assert callable(deco) + + +def test_limiter_is_in_memory(): + assert getattr(limiter, "_storage_uri", "memory://") == "memory://" or limiter._storage is not None \ No newline at end of file diff --git a/tests/test_backup_restore.py b/tests/test_backup_restore.py new file mode 100644 index 0000000..6ed1a44 --- /dev/null +++ b/tests/test_backup_restore.py @@ -0,0 +1,139 @@ +"""Backup-restore drill test (G-008 binding — MUST run at least once in +staging/CI to prove the nightly pg_dump backup is valid). + +The drill: + 1. Seed the live Postgres with known row counts in all 5 operator-tier + tables (operators, issued_credentials, mastery_gate_events, + cohort_aggregates, issuer_keys). + 2. Run `pg_dump -Fc` to produce a compressed dump. + 3. Drop + recreate the schema (simulate a disaster), then run + `pg_restore --clean --if-exists`. + 4. Verify all 5 tables exist and the row counts match the seeded values. + +Skips gracefully when PRAXIS_PG_DSN is unset (no Postgres in dev/CI). +""" + +from __future__ import annotations + +import asyncio +import os +import subprocess +import uuid +from datetime import date + +import asyncpg +import pytest + +from db.pg_migrate import apply_pg_migrations +from db.pg_store import PgStore + +pytestmark = pytest.mark.skipif( + "PRAXIS_PG_DSN" not in os.environ, + reason="PRAXIS_PG_DSN not set — backup-restore drill skipped (G-008).", +) + +EXPECTED_TABLES = { + "operators", + "issued_credentials", + "mastery_gate_events", + "cohort_aggregates", + "issuer_keys", +} + + +async def _seed(store: PgStore, pool: asyncpg.Pool) -> dict[str, int]: + """Seed all 5 tables; return {table: row_count}.""" + oid = await store.insert_operator("drill-op", "$argon2id$h", "Drill Op") + assert oid is not None + kid = f"key-{uuid.uuid4().hex[:12]}" + await store.init_issuer_key(kid, "pub-drill", b"\x01\x02") + cid = f"vc-{uuid.uuid4().hex[:16]}" + await store.insert_credential(cid, "learner-drill", "{}", "sig", operator_id=oid) + await store.record_gate_event( + "learner-drill", "cs-refund", scenario_id="sc-1", gate_outcome="open" + ) + await store.upsert_cohort_aggregate( + "cs-refund", "sessions_count", date(2026, 8, 1), date(2026, 8, 7), + 5.0, 12, False, + ) + counts = {} + async with pool.acquire() as conn: + for t in EXPECTED_TABLES: + counts[t] = await conn.fetchval(f"SELECT count(*) FROM {t}") + return counts + + +@pytest.mark.asyncio +async def test_backup_restore_drill(tmp_path): + dsn = os.environ["PRAXIS_PG_DSN"] + dump_file = tmp_path / "praxis-drill.dump" + + pool = await asyncpg.create_pool(dsn=dsn, min_size=1, max_size=5, command_timeout=10) + try: + await apply_pg_migrations(pool) + async with pool.acquire() as conn: + await conn.execute( + "TRUNCATE operators, issued_credentials, mastery_gate_events, " + "cohort_aggregates, issuer_keys RESTART IDENTITY CASCADE" + ) + store = PgStore(pool) + seeded_counts = await _seed(store, pool) + + # 1. pg_dump -Fc to a local file (via psql host or docker). + # Use pg_dump directly if available on PATH; otherwise fall back to + # docker compose exec (the operator deployment path). + rc = subprocess.run( + ["pg_dump", "-Fc", "-f", str(dump_file), dsn], + capture_output=True, text=True, + ) + if rc.returncode != 0: + # Try docker compose path (production-like). + rc = subprocess.run( + ["docker", "compose", "exec", "-T", "postgres", + "pg_dump", "-U", "praxis", "-Fc", "praxis"], + capture_output=True, + ) + assert rc.returncode == 0, f"pg_dump failed: {rc.stderr!r}" + dump_file.write_bytes(rc.stdout) + assert dump_file.stat().st_size > 0, "dump file is empty" + + # 2. Drop the schema (simulate disaster). + async with pool.acquire() as conn: + for t in EXPECTED_TABLES: + await conn.execute(f'DROP TABLE IF EXISTS "{t}" CASCADE') + await conn.execute("DROP TABLE IF EXISTS _pg_migrations CASCADE") + + # 3. pg_restore --clean --if-exists from the dump file. + rc = subprocess.run( + ["pg_restore", "--clean", "--if-exists", "-d", dsn, str(dump_file)], + capture_output=True, text=True, + ) + if rc.returncode != 0: + rc = subprocess.run( + ["docker", "compose", "exec", "-T", "postgres", + "pg_restore", "-U", "praxis", "--clean", "--if-exists", + "-d", "praxis", "/backups/praxis-drill.dump"], + capture_output=True, text=True, + ) + # If we used the docker path, we have to copy the dump in first; + # for the local-pg_dump path this branch is skipped. Either way, + # a non-zero return here means restore failed. + assert rc.returncode == 0, f"pg_restore failed: {rc.stderr!r}" + + # 4. Verify all 5 tables exist + row counts match. + async with pool.acquire() as conn: + tables = { + r["tablename"] for r in await conn.fetch( + "SELECT tablename FROM pg_tables WHERE schemaname='public'" + ) + } + assert EXPECTED_TABLES.issubset(tables), ( + f"missing tables after restore: {EXPECTED_TABLES - tables}" + ) + for t in EXPECTED_TABLES: + count = await conn.fetchval(f"SELECT count(*) FROM {t}") + assert count == seeded_counts[t], ( + f"{t}: restored count {count} != seeded {seeded_counts[t]}" + ) + finally: + await pool.close() \ No newline at end of file diff --git a/tests/test_create_operator.py b/tests/test_create_operator.py new file mode 100644 index 0000000..5e7beef --- /dev/null +++ b/tests/test_create_operator.py @@ -0,0 +1,217 @@ +"""Bootstrap CLI test (TASK-05-03) — mocked PgStore, no real Postgres. + +Covers: create operator → exists; re-run → "already exists" (no update); +--update → password updated; missing env → exit 1; password is argon2id +(not plaintext). +""" + +from __future__ import annotations + +import importlib +import os +import sys +from unittest.mock import AsyncMock, MagicMock + +import pytest + + +def _load_cli_module(monkeypatch, env: dict, update: bool = False): + """Load scripts/create-operator.py as a module with a mocked asyncpg pool.""" + for k in ("PRAXIS_BOOTSTRAP_OPERATOR_USER", "PRAXIS_BOOTSTRAP_OPERATOR_PASS", + "PRAXIS_PG_DSN"): + monkeypatch.delenv(k, raising=False) + for k, v in env.items(): + if v is None: + monkeypatch.delenv(k, raising=False) + else: + monkeypatch.setenv(k, v) + # Import the script as a module by path. + import importlib.util + spec = importlib.util.spec_from_file_location( + "create_operator", "scripts/create-operator.py" + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def _make_mock_pool_store(operators: dict[str, dict] | None = None): + operators = operators if operators is not None else {} + + pool = MagicMock() + pool.close = AsyncMock() + + conn = MagicMock() + + async def acquire_ctx(): + return conn + + cm = MagicMock() + cm.__aenter__ = AsyncMock(return_value=conn) + cm.__aexit__ = AsyncMock(return_value=None) + pool.acquire = MagicMock(return_value=cm) + + store = MagicMock() + + async def insert_operator(username, password_hash, display_name, *, on_conflict_update=False): + if on_conflict_update: + operators[username] = { + "id": "11111111-1111-1111-1111-111111111111", + "username": username, + "password_hash": password_hash, + } + return operators[username]["id"] + if username in operators: + return None # already exists + operators[username] = { + "id": "11111111-1111-1111-1111-111111111111", + "username": username, + "password_hash": password_hash, + } + return operators[username]["id"] + + store.insert_operator = insert_operator + return pool, store, operators + + +@pytest.mark.asyncio +async def test_create_operator_creates(monkeypatch, capsys): + env = { + "PRAXIS_BOOTSTRAP_OPERATOR_USER": "admin", + "PRAXIS_BOOTSTRAP_OPERATOR_PASS": "secret-pw", + "PRAXIS_PG_DSN": "postgresql://praxis:x@localhost/praxis", + } + mod = _load_cli_module(monkeypatch, env) + pool, store, operators = _make_mock_pool_store() + + import asyncpg + async def fake_create_pool(**kw): + return pool + monkeypatch.setattr(asyncpg, "create_pool", fake_create_pool) + + from db.pg_migrate import apply_pg_migrations as _apm + import db.pg_migrate + async def fake_apply_migrations(p): + return ["0001_operator_tier"] + monkeypatch.setattr(db.pg_migrate, "apply_pg_migrations", fake_apply_migrations) + + import db.pg_store + monkeypatch.setattr(db.pg_store, "PgStore", lambda p: store) + + rc = await mod.create_operator(update=False) + assert rc == 0 + out = capsys.readouterr().out + assert "created" in out + assert "admin" in operators + h = operators["admin"]["password_hash"] + assert h.startswith("$argon2id$") + assert "secret-pw" not in h # not plaintext + + +@pytest.mark.asyncio +async def test_create_operator_already_exists(monkeypatch, capsys): + env = { + "PRAXIS_BOOTSTRAP_OPERATOR_USER": "admin", + "PRAXIS_BOOTSTRAP_OPERATOR_PASS": "secret-pw", + "PRAXIS_PG_DSN": "postgresql://praxis:x@localhost/praxis", + } + mod = _load_cli_module(monkeypatch, env) + pool, store, operators = _make_mock_pool_store({"admin": { + "id": "id1", "username": "admin", "password_hash": "$argon2id$old" + }}) + + import asyncpg + async def fake_create_pool(**kw): + return pool + monkeypatch.setattr(asyncpg, "create_pool", fake_create_pool) + + import db.pg_migrate + async def fake_apply_migrations(p): + return [] + monkeypatch.setattr(db.pg_migrate, "apply_pg_migrations", fake_apply_migrations) + + import db.pg_store + monkeypatch.setattr(db.pg_store, "PgStore", lambda p: store) + + rc = await mod.create_operator(update=False) + assert rc == 0 + out = capsys.readouterr().out + assert "already exists" in out + # password NOT updated + assert operators["admin"]["password_hash"] == "$argon2id$old" + + +@pytest.mark.asyncio +async def test_create_operator_update_rehashes(monkeypatch, capsys): + env = { + "PRAXIS_BOOTSTRAP_OPERATOR_USER": "admin", + "PRAXIS_BOOTSTRAP_OPERATOR_PASS": "new-pw", + "PRAXIS_PG_DSN": "postgresql://praxis:x@localhost/praxis", + } + mod = _load_cli_module(monkeypatch, env) + pool, store, operators = _make_mock_pool_store({"admin": { + "id": "id1", "username": "admin", "password_hash": "$argon2id$old" + }}) + + import asyncpg + async def fake_create_pool(**kw): + return pool + monkeypatch.setattr(asyncpg, "create_pool", fake_create_pool) + + import db.pg_migrate + async def fake_apply_migrations(p): + return [] + monkeypatch.setattr(db.pg_migrate, "apply_pg_migrations", fake_apply_migrations) + + import db.pg_store + monkeypatch.setattr(db.pg_store, "PgStore", lambda p: store) + + rc = await mod.create_operator(update=True) + assert rc == 0 + out = capsys.readouterr().out + assert "updated" in out + assert operators["admin"]["password_hash"].startswith("$argon2id$") + assert operators["admin"]["password_hash"] != "$argon2id$old" + + +@pytest.mark.asyncio +async def test_create_operator_missing_user_env(monkeypatch, capsys): + env = { + "PRAXIS_BOOTSTRAP_OPERATOR_PASS": "x", + "PRAXIS_PG_DSN": "postgresql://praxis:x@localhost/praxis", + } + mod = _load_cli_module(monkeypatch, env) + rc = await mod.create_operator(update=False) + assert rc == 1 + err = capsys.readouterr().err + assert "PRAXIS_BOOTSTRAP_OPERATOR_USER" in err + + +@pytest.mark.asyncio +async def test_create_operator_missing_pass_env(monkeypatch, capsys): + env = { + "PRAXIS_BOOTSTRAP_OPERATOR_USER": "admin", + "PRAXIS_PG_DSN": "postgresql://praxis:x@localhost/praxis", + } + mod = _load_cli_module(monkeypatch, env) + rc = await mod.create_operator(update=False) + assert rc == 1 + + +@pytest.mark.asyncio +async def test_create_operator_missing_dsn(monkeypatch, capsys): + env = { + "PRAXIS_BOOTSTRAP_OPERATOR_USER": "admin", + "PRAXIS_BOOTSTRAP_OPERATOR_PASS": "x", + } + mod = _load_cli_module(monkeypatch, env) + rc = await mod.create_operator(update=False) + assert rc == 1 + err = capsys.readouterr().err + assert "PRAXIS_PG_DSN" in err + + +def test_password_hash_is_argon2id(): + from argon2 import PasswordHasher + h = PasswordHasher().hash("test") + assert h.startswith("$argon2id$") \ No newline at end of file diff --git a/tests/test_p1_auth_integration.py b/tests/test_p1_auth_integration.py new file mode 100644 index 0000000..a5a473e --- /dev/null +++ b/tests/test_p1_auth_integration.py @@ -0,0 +1,115 @@ +"""P1 auth integration test (TASK-06-04) — end-to-end with Postgres. + +Requires a live Postgres instance. Skips gracefully when PRAXIS_PG_DSN is +unset. Tests the full auth flow through the FastAPI app (TestClient with +the real lifespan): create operator via the bootstrap CLI → POST /login → +GET /me → POST /logout → GET /me (401). Rate limiting, cookie attributes, +and learner-voice-loop-unaffected verification (REQ-NFR-MT-01). +""" + +from __future__ import annotations + +import os +import uuid +from unittest.mock import patch + +import pytest +from fastapi.testclient import TestClient + +pytestmark = pytest.mark.skipif( + "PRAXIS_PG_DSN" not in os.environ, + reason="PRAXIS_PG_DSN not set — P1 auth integration tests skipped.", +) + + +@pytest.fixture(scope="module") +async def _started_app(): + """Start the real FastAPI app with the lifespan (creates the pool + + applies migrations + runs VC key migration).""" + import asyncio + import server.__main__ as m + # Ensure the SQLite store is initialized (v0.3 path). + await m._store.init() + # Use a unique operator username per run to avoid collisions. + suffix = uuid.uuid4().hex[:8] + with TestClient(m.app) as client: + yield client, suffix, m + + +def test_full_auth_flow(_started_app): + client, suffix, m = _started_app + if m.app.state.pg_store is None: + pytest.skip("pg_store is None (no Postgres connected)") + username = f"intop-{suffix}" + pw = "integration-pw-123" + # Create operator via the store directly (bootstrap CLI path is + # covered in test_create_operator.py; here we exercise the HTTP flow). + import asyncio + from server.auth.passwords import hash_password + + async def _seed(): + await m.app.state.pg_store.insert_operator(username, hash_password(pw), username) + asyncio.get_event_loop().run_until_complete(_seed()) + + # POST /login + r = client.post("/api/operator/login", json={"username": username, "password": pw}) + assert r.status_code == 200, r.text + body = r.json() + assert body["operator"]["username"] == username + # Cookie set + cookie = client.cookies.get("praxis_op") + assert cookie, "praxis_op cookie should be set after login" + + # GET /me + r2 = client.get("/api/operator/me") + assert r2.status_code == 200 + assert r2.json()["operator"]["username"] == username + + # POST /logout + r3 = client.post("/api/operator/logout") + assert r3.status_code == 200 + assert r3.json()["ok"] is True + + # GET /me after logout → 401 + r4 = client.get("/api/operator/me") + assert r4.status_code == 401 + + +def test_me_without_cookie_401(_started_app): + client, suffix, m = _started_app + if m.app.state.pg_store is None: + pytest.skip("pg_store is None (no Postgres connected)") + # Use a fresh client (no cookie jar sharing). + import server.__main__ as m + with TestClient(m.app) as fresh: + r = fresh.get("/api/operator/me") + assert r.status_code == 401 + + +def test_login_wrong_password_401(_started_app): + client, suffix, m = _started_app + if m.app.state.pg_store is None: + pytest.skip("pg_store is None (no Postgres connected)") + username = f"wrong-{suffix}" + pw = "correct-pw" + import asyncio + from server.auth.passwords import hash_password + + async def _seed(): + await m.app.state.pg_store.insert_operator(username, hash_password(pw), username) + asyncio.get_event_loop().run_until_complete(_seed()) + import server.__main__ as m + from server.auth.rate_limit import reset_login_rate_limit + reset_login_rate_limit() + with TestClient(m.app) as fresh: + r = fresh.post("/api/operator/login", json={"username": username, "password": "wrong"}) + assert r.status_code == 401 + + +def test_learner_voice_loop_unaffected(_started_app): + """REQ-NFR-MT-01 — Postgres presence does not destabilize the learner + voice loop (/health works regardless of Postgres state).""" + client, suffix, m = _started_app + r = client.get("/health") + assert r.status_code == 200 + assert r.json()["status"] == "ok" \ No newline at end of file diff --git a/tests/test_p1_vc_migration_e2e.py b/tests/test_p1_vc_migration_e2e.py new file mode 100644 index 0000000..dc1ec48 --- /dev/null +++ b/tests/test_p1_vc_migration_e2e.py @@ -0,0 +1,209 @@ +"""VC migration e2e test (TASK-06-05, R-VC-MIG-01 — CRITICAL). + +The highest-severity v0.4 risk: a v0.3 VC MUST verify against a Postgres +store with the v0.3 public key archived as superseded. This test seeds +SQLite with a v0.3 issuer key + credential, runs the migration, and +verifies through the HTTP endpoint. + +Requires a live Postgres instance. Skips gracefully when PRAXIS_PG_DSN is +unset. +""" + +from __future__ import annotations + +import asyncio +import json +import os +import uuid +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +pytestmark = pytest.mark.skipif( + "PRAXIS_PG_DSN" not in os.environ, + reason="PRAXIS_PG_DSN not set — VC migration e2e test skipped (R-VC-MIG-01).", +) + + +@pytest.fixture +async def _e2e_env(tmp_path, monkeypatch): + """Set up a fresh SQLite store + Postgres pool + run migration.""" + import server.__main__ as m + from db.store import PraxisStore + from db.pg_migrate import apply_pg_migrations + from db.pg_store import PgStore + from server.vc.issuer import build_vc_payload, sign, issue_credential + from server.vc.issuer_keys import init_issuer_key, _load_root_key + from server.vc.migrate_keys import migrate_issuer_keys + + # Fresh SQLite store in a temp dir. + sqlite_path = tmp_path / "praxis-e2e.db" + monkeypatch.setenv("PRAXIS_DB_PATH", str(sqlite_path)) + sqlite_store = PraxisStore(str(sqlite_path)) + await sqlite_store.init() + + # Seed SQLite with a v0.3 issuer key + a v0.3-issued credential. + root_key = _load_root_key() + v03_kp = await init_issuer_key(sqlite_store, root_key) + v03_cred_id = await issue_credential( + sqlite_store, + signing_key=v03_kp.signing_key, + key_id=v03_kp.key_id, + learner_id="learner-e2e-v03", + path="cs-refund", + scenarios_passed=["sc-1"], + rubric_score=4.0, + completed_weeks=6, + evidence=[], + ) + + # Connect to Postgres + apply migrations + clean tables. + import asyncpg + pool = await asyncpg.create_pool( + dsn=os.environ["PRAXIS_PG_DSN"], min_size=1, max_size=3, command_timeout=10 + ) + await apply_pg_migrations(pool) + async with pool.acquire() as conn: + await conn.execute( + "TRUNCATE operators, issued_credentials, mastery_gate_events, " + "cohort_aggregates, issuer_keys RESTART IDENTITY CASCADE" + ) + + pg_store = PgStore(pool) + + yield { + "sqlite_store": sqlite_store, + "pg_store": pg_store, + "pool": pool, + "v03_kp": v03_kp, + "v03_cred_id": v03_cred_id, + "root_key": root_key, + } + + await pool.close() + + +@pytest.mark.asyncio +async def test_v03_vc_verifies_after_migration(_e2e_env): + """R-VC-MIG-01: v0.3 VC verifies against Postgres with archived key.""" + env = _e2e_env + from server.vc.migrate_keys import migrate_issuer_keys + from server.vc.verification import verify_credential + + # Run the migration. + result = await migrate_issuer_keys( + env["sqlite_store"], env["pg_store"], env["root_key"] + ) + assert result["archived_key_id"] == env["v03_kp"].key_id + assert result["new_key_id"] is not None + + # Verify Postgres has 1 superseded + 1 active key. + active = await env["pg_store"].get_active_signing_key_row() + assert active is not None + assert active["id"] == result["new_key_id"] + archived = await env["pg_store"].get_public_key_row(env["v03_kp"].key_id) + assert archived is not None + assert archived["status"] == "superseded" + + # R-VC-MIG-01 CRITICAL: verify the v0.3 credential through the + # two-store path (G-011: credential in SQLite, key in Postgres). + res = await verify_credential( + env["sqlite_store"], env["v03_cred_id"], + pg_store=env["pg_store"], sqlite_store=env["sqlite_store"], + ) + assert res is not None + assert res["valid"] is True, ( + "R-VC-MIG-01 FAIL: v0.3 VC did not verify against archived superseded key" + ) + assert res["status"] == "active" + + +@pytest.mark.asyncio +async def test_migration_idempotent_e2e(_e2e_env): + """Re-running the migration is a no-op.""" + env = _e2e_env + from server.vc.migrate_keys import migrate_issuer_keys + await migrate_issuer_keys(env["sqlite_store"], env["pg_store"], env["root_key"]) + result = await migrate_issuer_keys(env["sqlite_store"], env["pg_store"], env["root_key"]) + assert result["archived_key_id"] is None + assert result["new_key_id"] is None + + +@pytest.mark.asyncio +async def test_g027_first_boot_no_v03_key(_e2e_env): + """G-027: fresh deploy with no v0.3 key → skip archive, fresh key only.""" + env = _e2e_env + # Use a fresh SQLite store with NO v0.3 key. + from db.store import PraxisStore + from server.vc.migrate_keys import migrate_issuer_keys + import tempfile + fresh_path = Path(tempfile.mkdtemp()) / "fresh.db" + fresh_store = PraxisStore(str(fresh_path)) + await fresh_store.init() + result = await migrate_issuer_keys(fresh_store, env["pg_store"], env["root_key"]) + assert result["archived_key_id"] is None + assert result["new_key_id"] is not None + + +@pytest.mark.asyncio +async def test_v04_vc_verifies_after_migration(_e2e_env): + """A newly-issued v0.4 VC verifies against the active key in Postgres.""" + env = _e2e_env + from server.vc.migrate_keys import migrate_issuer_keys + from server.vc.verification import verify_credential + from server.vc.issuer import issue_credential + from server.vc.issuer_keys import get_active_signing_key + + await migrate_issuer_keys(env["sqlite_store"], env["pg_store"], env["root_key"]) + # Issue a v0.4 credential using the active Postgres key. + kp, _enc = await get_active_signing_key(env["pg_store"], env["root_key"]) + v04_cred_id = await issue_credential( + env["sqlite_store"], + signing_key=kp.signing_key, + key_id=kp.key_id, + learner_id="learner-e2e-v04", + path="cs-refund", + scenarios_passed=["sc-1", "sc-2"], + rubric_score=4.5, + completed_weeks=6, + evidence=[], + ) + # The credential is in SQLite; the key is in Postgres. Verify via the + # two-store path. + res = await verify_credential( + env["sqlite_store"], v04_cred_id, + pg_store=env["pg_store"], sqlite_store=env["sqlite_store"], + ) + assert res is not None + assert res["valid"] is True + + +@pytest.mark.asyncio +async def test_tampered_v03_vc_fails_e2e(_e2e_env): + """Tamper detection: a modified v0.3 credential fails verification.""" + env = _e2e_env + from server.vc.migrate_keys import migrate_issuer_keys + from server.vc.verification import verify_credential + + await migrate_issuer_keys(env["sqlite_store"], env["pg_store"], env["root_key"]) + # Fetch the v0.3 credential and tamper with its payload. + row = await env["sqlite_store"].get_credential(env["v03_cred_id"]) + assert row is not None + doc = json.loads(row["vc_payload_json"]) + doc["credentialSubject"]["rubricScore"] = 1.0 # tamper + await env["sqlite_store"].set_credential_status(env["v03_cred_id"], "active") + # Overwrite the payload in SQLite with the tampered version. + import aiosqlite + async with aiosqlite.connect(env["sqlite_store"].db_path) as db: + await db.execute( + "UPDATE issued_credentials SET vc_payload_json = ? WHERE id = ?", + (json.dumps(doc, sort_keys=True, separators=(",", ":")), env["v03_cred_id"]), + ) + await db.commit() + res = await verify_credential( + env["sqlite_store"], env["v03_cred_id"], + pg_store=env["pg_store"], sqlite_store=env["sqlite_store"], + ) + assert res is not None + assert res["valid"] is False \ No newline at end of file diff --git a/tests/test_pg_store.py b/tests/test_pg_store.py new file mode 100644 index 0000000..8db6183 --- /dev/null +++ b/tests/test_pg_store.py @@ -0,0 +1,221 @@ +"""PgStore + asyncpg pool integration test (TASK-01-07). + +Requires a live Postgres instance. Skips gracefully when PRAXIS_PG_DSN is +unset so the test suite has no hard CI dependency on Postgres. +""" + +from __future__ import annotations + +import os +import uuid +from datetime import date + +import asyncpg +import pytest + +from db.pg_migrate import apply_pg_migrations +from db.pg_store import PgStore + +pytestmark = pytest.mark.skipif( + "PRAXIS_PG_DSN" not in os.environ, + reason="PRAXIS_PG_DSN not set — Postgres integration tests skipped (dev mode).", +) + + +@pytest.fixture(scope="module") +async def pool() -> asyncpg.Pool: + p = await asyncpg.create_pool( + dsn=os.environ["PRAXIS_PG_DSN"], + min_size=1, + max_size=5, + command_timeout=10, + ) + try: + await apply_pg_migrations(p) + yield p + finally: + await p.close() + + +@pytest.fixture(autouse=True) +async def _clean_tables(pool: asyncpg.Pool): + async with pool.acquire() as conn: + await conn.execute( + "TRUNCATE operators, issued_credentials, mastery_gate_events, " + "cohort_aggregates, issuer_keys RESTART IDENTITY CASCADE" + ) + yield + + +@pytest.mark.asyncio +async def test_migration_creates_tables(pool: asyncpg.Pool): + async with pool.acquire() as conn: + tables = await conn.fetch( + "SELECT tablename FROM pg_tables WHERE schemaname = 'public' " + "ORDER BY tablename" + ) + names = {r["tablename"] for r in tables} + assert {"operators", "issued_credentials", "mastery_gate_events", + "cohort_aggregates", "issuer_keys"}.issubset(names) + + +@pytest.mark.asyncio +async def test_migration_idempotent(pool: asyncpg.Pool): + applied = await apply_pg_migrations(pool) + assert applied == [] + + +@pytest.mark.asyncio +async def test_operator_insert_and_lookup(pool: asyncpg.Pool): + store = PgStore(pool) + oid = await store.insert_operator( + "alice", "$argon2id$fakehash", "Alice" + ) + assert oid is not None + op = await store.get_operator_by_username("alice") + assert op is not None + assert op["username"] == "alice" + assert op["display_name"] == "Alice" + assert op["is_active"] is True + by_id = await store.get_operator_by_id(oid) + assert by_id is not None + assert by_id["id"] == op["id"] + + +@pytest.mark.asyncio +async def test_operator_insert_idempotent(pool: asyncpg.Pool): + store = PgStore(pool) + first = await store.insert_operator("bob", "$argon2id$h1", "Bob") + assert first is not None + second = await store.insert_operator("bob", "$argon2id$h2", "Bob") + assert second is None + + +@pytest.mark.asyncio +async def test_operator_on_conflict_update(pool: asyncpg.Pool): + store = PgStore(pool) + await store.insert_operator("carol", "$argon2id$old", "Carol") + updated = await store.insert_operator( + "carol", "$argon2id$new", "Carol", on_conflict_update=True + ) + assert updated is not None + op = await store.get_operator_by_username("carol") + assert op["password_hash"] == "$argon2id$new" + + +@pytest.mark.asyncio +async def test_update_last_login(pool: asyncpg.Pool): + store = PgStore(pool) + oid = await store.insert_operator("dave", "$argon2id$h", "Dave") + assert oid is not None + assert (await store.get_operator_by_id(oid))["last_login_at"] is None + await store.update_last_login(oid) + assert (await store.get_operator_by_id(oid))["last_login_at"] is not None + + +@pytest.mark.asyncio +async def test_cohort_aggregate_upsert_idempotent(pool: asyncpg.Pool): + store = PgStore(pool) + ws, we = date(2026, 8, 1), date(2026, 8, 7) + await store.upsert_cohort_aggregate( + "cs-refund", "sessions_count", ws, we, 42.0, 15, False + ) + await store.upsert_cohort_aggregate( + "cs-refund", "sessions_count", ws, we, 42.0, 15, False + ) + rows = await store.get_cohort_aggregates( + "cs-refund", "sessions_count", date(2026, 7, 1) + ) + assert len(rows) == 1 + assert rows[0]["value"] == 42.0 + assert rows[0]["cell_count"] == 15 + + +@pytest.mark.asyncio +async def test_cohort_aggregate_suppressed_cell(pool: asyncpg.Pool): + store = PgStore(pool) + ws, we = date(2026, 8, 1), date(2026, 8, 7) + await store.upsert_cohort_aggregate( + "cs-refund", "active_learners", ws, we, None, 9, True + ) + rows = await store.get_cohort_aggregates( + "cs-refund", "active_learners", date(2026, 7, 1) + ) + assert len(rows) == 1 + assert rows[0]["cell_suppressed"] is True + assert rows[0]["value"] is None + + +@pytest.mark.asyncio +async def test_issuer_key_init_active_then_superseded(pool: asyncpg.Pool): + store = PgStore(pool) + kid = f"key-{uuid.uuid4().hex[:12]}" + await store.init_issuer_key(kid, "pub-b64-aaa", b"\x01\x02\x03") + active = await store.get_active_signing_key_row() + assert active is not None + assert active["id"] == kid + assert active["status"] == "active" + await store.set_issuer_key_superseded(kid) + assert await store.get_active_signing_key_row() is None + archived = await store.get_public_key_row(kid) + assert archived is not None + assert archived["status"] == "superseded" + assert archived["public_key"] == "pub-b64-aaa" + + +@pytest.mark.asyncio +async def test_get_public_key_row_finds_superseded(pool: asyncpg.Pool): + store = PgStore(pool) + kid = f"key-{uuid.uuid4().hex[:12]}" + await store.init_issuer_key(kid, "pub-b64-bbb", b"\x04\x05") + await store.set_issuer_key_superseded(kid) + row = await store.get_public_key_row(kid) + assert row is not None + assert row["status"] == "superseded" + + +@pytest.mark.asyncio +async def test_credential_insert_and_get(pool: asyncpg.Pool): + store = PgStore(pool) + oid = await store.insert_operator("ed", "$argon2id$h", "Ed") + cid = f"vc-{uuid.uuid4().hex[:16]}" + await store.insert_credential( + cid, "learner-1", '{"id":"vc-x"}', "sig-b64", + operator_id=oid, + ) + row = await store.get_credential(cid) + assert row is not None + assert row["id"] == cid + assert row["learner_ref"] == "learner-1" + assert row["signature_b64"] == "sig-b64" + assert row["status"] == "active" + assert row["vc_payload_json"] == '{"id":"vc-x"}' + + +@pytest.mark.asyncio +async def test_credential_status_revoke(pool: asyncpg.Pool): + store = PgStore(pool) + cid = f"vc-{uuid.uuid4().hex[:16]}" + await store.insert_credential(cid, "learner-2", "{}", "sig") + await store.set_credential_status(cid, "revoked") + row = await store.get_credential(cid) + assert row["status"] == "revoked" + assert row["revoked_at"] is not None + + +@pytest.mark.asyncio +async def test_record_gate_event(pool: asyncpg.Pool): + store = PgStore(pool) + eid = await store.record_gate_event( + "learner-3", "cs-refund", scenario_id="sc-1", + gate_outcome="open", rubric_scores_jsonb=[{"c": "x", "l": 4}], + ) + assert eid is not None + async with pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT * FROM mastery_gate_events WHERE id = $1", eid + ) + assert row is not None + assert row["learner_ref"] == "learner-3" + assert row["gate_outcome"] == "open" + assert row["source"] == "sync" \ No newline at end of file diff --git a/tests/test_vc_migration.py b/tests/test_vc_migration.py new file mode 100644 index 0000000..e056009 --- /dev/null +++ b/tests/test_vc_migration.py @@ -0,0 +1,354 @@ +"""VC migration unit tests (TASK-04-05) — mocked stores. + +Covers: + - Migration script: v0.3 key archived as superseded, fresh key active, + idempotent re-run. + - G-027 first-boot path: no v0.3 active key in SQLite → skip archive, + generate fresh key only. + - Verification with PgStore: v0.4 VC (active key) verifies ✓; v0.3 VC + (superseded key) verifies ✓ (R-VC-MIG-01 — the critical test). + - get_public_key_row finds superseded key by id (verification fallback). +""" + +from __future__ import annotations + +import base64 +import json +import uuid +from unittest.mock import AsyncMock, MagicMock + +import nacl.signing +import pytest + +from server.vc.issuer import build_vc_payload, sign, extract_key_id, verify_proof +from server.vc.issuer_keys import _load_root_key +from server.vc.migrate_keys import migrate_issuer_keys +from server.vc.verification import verify_credential + + +def _b64(b: bytes) -> str: + return base64.b64encode(b).decode("ascii") + + +# ── Migration script ──────────────────────────────────────────────────────── + + +def _make_sqlite_store_with_v03_key(key_id="v03-key-aaa", public_key="pub-v03-b64"): + store = MagicMock() + store.get_active_signing_key_row = AsyncMock( + return_value={"id": key_id, "public_key": public_key, "private_key_enc": b"\x01"} + ) + return store + + +def _make_pg_store(): + store = MagicMock() + store._rows = {} # key_id -> row dict + store._active = None + + async def init_issuer_key(key_id, public_key, private_key_enc): + status = "active" + if key_id in store._rows: + # ON CONFLICT DO NOTHING — don't overwrite + return + store._rows[key_id] = { + "id": key_id, "public_key": public_key, + "private_key_enc": private_key_enc, "status": status, + } + store._active = key_id + + async def get_active_signing_key_row(): + if store._active is None: + return None + return dict(store._rows[store._active]) + + async def get_public_key_row(key_id): + r = store._rows.get(key_id) + return dict(r) if r else None + + async def set_issuer_key_superseded(key_id): + if key_id in store._rows: + store._rows[key_id]["status"] = "superseded" + if store._active == key_id: + store._active = None + + store.init_issuer_key = init_issuer_key + store.get_active_signing_key_row = get_active_signing_key_row + store.get_public_key_row = get_public_key_row + store.set_issuer_key_superseded = set_issuer_key_superseded + return store + + +@pytest.mark.asyncio +async def test_migration_archives_v03_and_generates_fresh(): + sqlite = _make_sqlite_store_with_v03_key() + pg = _make_pg_store() + root = _load_root_key() + result = await migrate_issuer_keys(sqlite, pg, root) + assert result["archived_key_id"] == "v03-key-aaa" + assert result["new_key_id"] is not None + # v0.3 key is superseded in Postgres + archived = await pg.get_public_key_row("v03-key-aaa") + assert archived["status"] == "superseded" + assert archived["public_key"] == "pub-v03-b64" + # fresh key is active + active = await pg.get_active_signing_key_row() + assert active is not None + assert active["id"] == result["new_key_id"] + assert active["status"] == "active" + + +@pytest.mark.asyncio +async def test_migration_idempotent_when_active_key_exists(): + sqlite = _make_sqlite_store_with_v03_key() + pg = _make_pg_store() + root = _load_root_key() + await migrate_issuer_keys(sqlite, pg, root) + # second run — no-op + result = await migrate_issuer_keys(sqlite, pg, root) + assert result["archived_key_id"] is None + assert result["new_key_id"] is None + + +@pytest.mark.asyncio +async def test_migration_g027_first_boot_no_v03_key(): + # G-027: no v0.3 active key in SQLite → skip archive, fresh key only. + sqlite = MagicMock() + sqlite.get_active_signing_key_row = AsyncMock(return_value=None) + pg = _make_pg_store() + root = _load_root_key() + result = await migrate_issuer_keys(sqlite, pg, root) + assert result["archived_key_id"] is None # nothing archived + assert result["new_key_id"] is not None # fresh key generated + active = await pg.get_active_signing_key_row() + assert active is not None + assert active["id"] == result["new_key_id"] + + +@pytest.mark.asyncio +async def test_migration_archives_before_activating_r_vc_mig_01(): + # R-VC-MIG-01: the v0.3 public key MUST be archived BEFORE the fresh + # key is activated. We verify the ordering by checking that at no point + # is there an active v0.4 key without the v0.3 key being present (as + # superseded) in Postgres. + sqlite = _make_sqlite_store_with_v03_key() + pg = _make_pg_store() + # Instrument init_issuer_key to assert the archive happened first. + order = [] + orig_init = pg.init_issuer_key + + async def tracing_init(key_id, public_key, private_key_enc): + order.append(key_id) + await orig_init(key_id, public_key, private_key_enc) + + pg.init_issuer_key = tracing_init + orig_super = pg.set_issuer_key_superseded + + async def tracing_super(key_id): + order.append(f"supersede:{key_id}") + await orig_super(key_id) + + pg.set_issuer_key_superseded = tracing_super + root = _load_root_key() + await migrate_issuer_keys(sqlite, pg, root) + # The v0.3 key (v03-key-aaa) is init'd then superseded BEFORE the fresh + # key is init'd (active). + v03_idx = order.index("v03-key-aaa") + sup_idx = order.index("supersede:v03-key-aaa") + fresh_idx = [i for i, k in enumerate(order) if k not in ("v03-key-aaa",) and not k.startswith("supersede:")][0] + assert v03_idx < sup_idx < fresh_idx + + +# ── Verification with PgStore (R-VC-MIG-01 critical test) ─────────────────── + + +def _make_credential_store(rows: dict[str, dict]): + store = MagicMock() + async def get_credential(cid): + return rows.get(cid) + store.get_credential = get_credential + # status list store (SQLite) — empty + sl_store = MagicMock() + sl_store.get_status_list = AsyncMock(return_value=None) + sl_store.upsert_status_list = AsyncMock() + return store, sl_store + + +@pytest.mark.asyncio +async def test_v03_vc_verifies_against_superseded_key_in_pg(): + """R-VC-MIG-01 critical: a v0.3 VC verifies against a Postgres store + with the v0.3 public key archived as superseded.""" + # Generate a v0.3 keypair + credential. + sk_v03 = nacl.signing.SigningKey.generate() + vk_v03 = sk_v03.verify_key + pub_v03_b64 = _b64(bytes(vk_v03)) + v03_key_id = "v03-key-real" + payload = build_vc_payload( + learner_ref="learner-1", path="cs-refund", + scenarios_passed=["sc-1"], rubric_score=4.0, completed_weeks=6, + evidence=[], credential_id="vc-v03-real", status_list_index=None, + ) + secured, sig_b64 = sign(payload, sk_v03, v03_key_id) + cred_row = { + "id": "vc-v03-real", "learner_ref": "learner-1", + "vc_payload_json": json.dumps(secured, sort_keys=True, separators=(",", ":")), + "signature_b64": sig_b64, "status": "active", + } + # Postgres store has the v0.3 key as superseded + the credential. + pg = _make_pg_store() + await pg.init_issuer_key(v03_key_id, pub_v03_b64, b"") + await pg.set_issuer_key_superseded(v03_key_id) + pg._rows[v03_key_id]["public_key"] = pub_v03_b64 + # add credential to pg via a separate mock get_credential + async def get_cred(cid): + if cid == "vc-v03-real": + return cred_row + return None + pg.get_credential = get_cred + # SQLite status-list store (empty → not revoked) + sqlite_sl = MagicMock() + sqlite_sl.get_status_list = AsyncMock(return_value=None) + sqlite_sl.upsert_status_list = AsyncMock() + result = await verify_credential( + pg, "vc-v03-real", pg_store=pg, sqlite_store=sqlite_sl + ) + assert result is not None + assert result["valid"] is True, "v0.3 VC must verify against archived superseded key (R-VC-MIG-01)" + + +@pytest.mark.asyncio +async def test_v04_vc_verifies_against_active_key_in_pg(): + sk_v04 = nacl.signing.SigningKey.generate() + vk_v04 = sk_v04.verify_key + pub_v04_b64 = _b64(bytes(vk_v04)) + v04_key_id = "v04-key-fresh" + payload = build_vc_payload( + learner_ref="learner-2", path="cs-refund", + scenarios_passed=["sc-1", "sc-2"], rubric_score=4.5, completed_weeks=6, + evidence=[], credential_id="vc-v04-fresh", status_list_index=None, + ) + secured, sig_b64 = sign(payload, sk_v04, v04_key_id) + cred_row = { + "id": "vc-v04-fresh", "learner_ref": "learner-2", + "vc_payload_json": json.dumps(secured, sort_keys=True, separators=(",", ":")), + "signature_b64": sig_b64, "status": "active", + } + pg = _make_pg_store() + await pg.init_issuer_key(v04_key_id, pub_v04_b64, b"\x09") + pg._rows[v04_key_id]["public_key"] = pub_v04_b64 + async def get_cred(cid): + return cred_row if cid == "vc-v04-fresh" else None + pg.get_credential = get_cred + sqlite_sl = MagicMock() + sqlite_sl.get_status_list = AsyncMock(return_value=None) + result = await verify_credential( + pg, "vc-v04-fresh", pg_store=pg, sqlite_store=sqlite_sl + ) + assert result is not None + assert result["valid"] is True + + +@pytest.mark.asyncio +async def test_tampered_v03_vc_fails_verification(): + sk = nacl.signing.SigningKey.generate() + vk = sk.verify_key + pub_b64 = _b64(bytes(vk)) + key_id = "key-tamper" + payload = build_vc_payload( + learner_ref="learner-t", path="cs-refund", + scenarios_passed=["sc-1"], rubric_score=4.0, completed_weeks=6, + evidence=[], credential_id="vc-tamper", status_list_index=None, + ) + secured, sig_b64 = sign(payload, sk, key_id) + # Tamper: change the rubricScore after signing. + secured["credentialSubject"]["rubricScore"] = 1.0 + cred_row = { + "id": "vc-tamper", "learner_ref": "learner-t", + "vc_payload_json": json.dumps(secured, sort_keys=True, separators=(",", ":")), + "signature_b64": sig_b64, "status": "active", + } + pg = _make_pg_store() + await pg.init_issuer_key(key_id, pub_b64, b"") + pg._rows[key_id]["public_key"] = pub_b64 + async def get_cred(cid): + return cred_row if cid == "vc-tamper" else None + pg.get_credential = get_cred + sqlite_sl = MagicMock() + sqlite_sl.get_status_list = AsyncMock(return_value=None) + result = await verify_credential( + pg, "vc-tamper", pg_store=pg, sqlite_store=sqlite_sl + ) + assert result is not None + assert result["valid"] is False + + +@pytest.mark.asyncio +async def test_verification_fallback_sqlite_when_pg_missing_credential(): + """G-011(b): credential not in Postgres → fall back to SQLite.""" + sk = nacl.signing.SigningKey.generate() + vk = sk.verify_key + pub_b64 = _b64(bytes(vk)) + key_id = "key-fallback" + payload = build_vc_payload( + learner_ref="learner-fb", path="cs-refund", + scenarios_passed=["sc-1"], rubric_score=4.0, completed_weeks=6, + evidence=[], credential_id="vc-fallback", status_list_index=None, + ) + secured, sig_b64 = sign(payload, sk, key_id) + sqlite_cred_row = { + "id": "vc-fallback", "learner_ref": "learner-fb", + "vc_payload_json": json.dumps(secured, sort_keys=True, separators=(",", ":")), + "signature_b64": sig_b64, "status": "active", + } + # Postgres has the key but NOT the credential. + pg = _make_pg_store() + await pg.init_issuer_key(key_id, pub_b64, b"") + pg._rows[key_id]["public_key"] = pub_b64 + async def pg_get_cred(cid): + return None # not in Postgres + pg.get_credential = pg_get_cred + # SQLite has the credential + the key (v0.3 path). + sqlite = MagicMock() + async def sqlite_get_cred(cid): + return sqlite_cred_row if cid == "vc-fallback" else None + sqlite.get_credential = sqlite_get_cred + sqlite.get_public_key_row = AsyncMock(return_value={ + "id": key_id, "public_key": pub_b64, "status": "active" + }) + sqlite.get_status_list = AsyncMock(return_value=None) + result = await verify_credential( + sqlite, "vc-fallback", pg_store=pg, sqlite_store=sqlite + ) + assert result is not None + assert result["valid"] is True + + +@pytest.mark.asyncio +async def test_verification_sqlite_only_when_no_pg(): + """G-011(c): no Postgres → full v0.3 SQLite path.""" + sk = nacl.signing.SigningKey.generate() + vk = sk.verify_key + pub_b64 = _b64(bytes(vk)) + key_id = "key-sqlite-only" + payload = build_vc_payload( + learner_ref="learner-so", path="cs-refund", + scenarios_passed=["sc-1"], rubric_score=4.0, completed_weeks=6, + evidence=[], credential_id="vc-so", status_list_index=None, + ) + secured, sig_b64 = sign(payload, sk, key_id) + cred_row = { + "id": "vc-so", "learner_ref": "learner-so", + "vc_payload_json": json.dumps(secured, sort_keys=True, separators=(",", ":")), + "signature_b64": sig_b64, "status": "active", + } + sqlite = MagicMock() + async def get_cred(cid): + return cred_row if cid == "vc-so" else None + sqlite.get_credential = get_cred + sqlite.get_public_key_row = AsyncMock(return_value={ + "id": key_id, "public_key": pub_b64, "status": "active" + }) + sqlite.get_status_list = AsyncMock(return_value=None) + result = await verify_credential(sqlite, "vc-so", pg_store=None, sqlite_store=sqlite) + assert result is not None + assert result["valid"] is True \ No newline at end of file