# Wayfinder Auth Spec v1

> **Ticket:** [#9 — Modelo de auth para integradores B2B read-only](https://github.com/mandres9/GeoEvidence/issues/9)
> **Mapa:** [#1 — API Readiness bajo supuesto B2B LegalTech](https://github.com/mandres9/GeoEvidence/issues/1)
> **Estado:** locked-down (resolución de G2)
> **Arquitectura subyacente:** [ADR-0006 — Sub-app `/api/v1/` para Public API](https://github.com/mandres9/GeoEvidence/blob/main/docs/adr/ADR-0006-sub-app-public-api-hybrid.md)
> **Readiness table:** [readiness-table-v1.md](https://github.com/mandres9/GeoEvidence/tree/readiness/canonical/docs/wayfinder/readiness-table-v1.md) — este spec cumple el umbral POC-ready del eje Auth & authz.

## Modelo

Cada integrador es un **partner** (`partners` table). Un partner puede tener **N API keys** (`api_keys` table) — típicamente un par test/live, o keys separadas para distintas apps internas.

Cada API key tiene:
- Un **token** (`geoev_test_<random>` o `geoev_live_<random>`) que el partner envía en `Authorization: Bearer <token>`.
- Un **HMAC secret** (32 bytes hex) que el partner usa para firmar cada request contra el sub-app.

El flujo de autenticación es **doble**:
1. **Edge (sub-app `/api/v1/`)**: el sub-app recibe el Bearer token, valida que la key existe y no está revocada, y extrae el HMAC secret correspondiente.
2. **Verificación interna (handler interno)**: el handler verifica que cada request viene firmado con el HMAC secret esperado (HMAC-SHA256 sobre `t.method.path.sha256(body)`).

Esto da **defense in depth**: un atacante que bypasea el sub-app no llega al handler sin firma válida; un atacante que llega al handler no puede伪造 la firma porque no tiene el HMAC secret.

## Schema (PostgreSQL)

Ver DDL completo en `services/verifier/migrations/0001-public-api-tables.sql`. Tablas creadas automáticamente por `init_db()` en el arranque del verifier.

### `partners`

| Columna | Tipo | Notas |
|---|---|---|
| `id` | `BIGSERIAL PK` | |
| `name` | `VARCHAR(255) NOT NULL` | Razón social o nombre del partner |
| `contact_email` | `VARCHAR(255) NOT NULL` | Para notificaciones de revocación / rotación |
| `status` | `VARCHAR(16)` enum | `pending` / `active` / `suspended` / `churned` |
| `tier` | `VARCHAR(32)` enum | `piloto` / `empresarial` / `gobierno` (alineado con pricing público) |
| `notes` | `TEXT` | KYB lite — el admin anota lo que verificó |
| `created_at`, `updated_at` | `TIMESTAMPTZ` | |

### `api_keys`

| Columna | Tipo | Notas |
|---|---|---|
| `id` | `BIGSERIAL PK` | |
| `partner_id` | `BIGINT FK partners(id) NOT NULL` | |
| `environment` | `VARCHAR(4)` enum | `test` o `live` |
| `key_prefix` | `VARCHAR(64) NOT NULL` | Primeros 16 chars del token (para indexar sin exponer la key) |
| `key_hash` | `CHAR(64) NOT NULL` | SHA-256 hex del token completo |
| `hmac_secret_hash_current` | `CHAR(64) NOT NULL` | SHA-256 hex del HMAC secret activo |
| `hmac_secret_hash_previous` | `CHAR(64)` | SHA-256 del secret anterior durante la ventana de rotación |
| `hmac_secret_previous_expires_at` | `TIMESTAMPTZ` | Cuándo expira el secret anterior |
| `scopes` | `JSONB NOT NULL DEFAULT '["*"]'` | Catálogo forward-compat; sin enforcement en POC-ready |
| `notes` | `TEXT` | |
| `created_at`, `last_used_at` | `TIMESTAMPTZ` | |
| `expires_at` | `TIMESTAMPTZ` | NULL = nunca expira (default). Test keys del endpoint self-serve setean `now() + 7 días`. |
| `revoked_at`, `revoked_reason` | `TIMESTAMPTZ`, `VARCHAR(64)` | Una vez revocado, no se rehabilita |

## Token format

```
geoev_<env>_<random>
```

- `<env>` ∈ {`test`, `live`}
- `<random>` = `secrets.token_urlsafe(24)` (32 chars base64-url)
- Ejemplo: `geoev_live_a3f9k2m7q8x4b1c9d2e5f7g8h0i`

El token **se muestra UNA sola vez** al momento de la emisión. No se puede recuperar después — sólo su SHA-256 está en la DB.

## HMAC interno

**Header**: `X-GeoEvidence-Signature: t=<unix_ts>,v1=<hex_sig>`

**Payload firmado**: `<unix_ts>.<method>.<path>.<sha256(body_bytes_hex>)`

**Algoritmo**: HMAC-SHA256 con el secret del partner (`hmac_secret_hash_current`).

**Ejemplo** (Python):
```python
import hashlib, hmac, time

ts = int(time.time())
body = b'{"foo":"bar"}'
body_hash = hashlib.sha256(body).hexdigest()
signed_payload = f"{ts}.POST./api/v1/evidence/abc.{body_hash}".encode()
sig = hmac.new(hmac_secret_bytes, signed_payload, hashlib.sha256).hexdigest()
header = f"t={ts},v1={sig}"
```

**Tolerance**: 5 minutos (300s) entre `t` y `now()`.

**Replay window**: 5 minutos. La firma + payload hash se cachea en `hmac_replay_cache`. Reuso con mismo payload dentro de la ventana: 200 (idempotente). Reuso con payload distinto: 409 `HMAC_REPLAY`.

Incluir `method` y `path` en el payload firmado **previene replay cross-endpoint** — un atacante que captura una firma de `GET /evidence/A` no puede usarla como `DELETE /evidence/B` cuando se expongan endpoints de escritura.

## Rotación HMAC (manual on-demand, overlap 24h)

El API key queda igual. Sólo se genera un nuevo `hmac_secret_hash_current` y el anterior pasa a `hmac_secret_hash_previous` con `hmac_secret_previous_expires_at = now() + overlap_hours`.

Durante la ventana de overlap (default 24h, configurable vía `PUBLIC_API_HMAC_OVERLAP_HOURS`):
- El sub-app acepta firmas contra **ambos** secrets (current o previous).
- Si la firma valida contra `previous`, se loguea un warning para que el admin sepa que el partner todavía no actualizó.

Pasada la ventana:
- El secret anterior se considera inválido. La próxima request firmada con el secret viejo retorna `HMAC_INVALID` 401.

Operación: `python scripts/rotate_hmac_secret.py --key-id <KEY_ID>`.

**Por qué HMAC-only y no API key rotation**: rotar el HMAC secret no obliga al partner a actualizar el token en sus apps (es transparente a nivel del Bearer). Rotar el API key sí lo obliga. Para el flujo rutinario, HMAC-only es menos disruptivo.

## Revocación

Una API key revocada (`revoked_at` no nulo) **no se puede rehabilitar**. Si el partner la necesita, se emite una nueva (`scripts/issue_api_key.py`) y se le entrega.

Operación: `python scripts/revoke_api_key.py --key-id <KEY_ID> --reason <reason>`.

**Reasons** (enum controlado):
- `compromised`: el partner o nosotros detectamos compromiso.
- `partner_request`: el partner pidió baja.
- `inactivity`: el partner no usa la key hace >90 días (proceso manual, sin auto-revocación en POC-ready).
- `churned`: el partner cerró el contrato.
- `other`: documentado en `notes`.

**Revocación en bulk**: `python scripts/revoke_api_key.py --partner-id <ID> --all --reason churned`.

**Sin suspensión temporal en POC-ready**: la suspensión (`status = suspended`) está en el schema para ship-ready, pero en POC todas las revocaciones son permanentes.

## Scopes (forward-compat, sin enforcement en POC-ready)

**Catálogo definido** (vacío en POC-ready, se usa al pasar a ship-ready):

| Scope | Endpoints que cubre |
|---|---|
| `evidence:read` | `GET /api/v1/evidence/{id}`, `/by-hash/{hash}`, `/by-merkle/{root}` |
| `zk_proof:read` | `GET /api/v1/zk-proof/{anchor_id}` |
| `verify_page:read` | `GET /api/v1/verify/{id}` |
| `kaspa:verify` | `GET /api/v1/kaspa/verify/{tx_id}` |
| `*` | Todos los anteriores (wildcard) |

**POC-ready**: toda key emitida tiene `scopes = ["*"]` por default. **El sub-app NO chequea scopes** — cualquier key válida accede a los 6 endpoints.

**Ship-ready**: cuando llegue el momento, el sub-app añadirá un dependency `enforce_scopes("evidence:read")` en cada router que valide `scope in request.state.partner["scopes"]`. El admin empezará a emitir keys con scopes granulares (ej. un perito forense externo recibe `verify_page:read` + `kaspa:verify` solamente).

## Atribución del partner

Una vez autenticado, `partner_id` se propaga por tres canales:

1. **`access_log.partner_id`** (PK de forensic) — ya implementado en `AccessLogMiddleware` (ADR-0006).
2. **`access_log.api_key_id`** — FK a `api_keys(id)`, permite reconstruir qué key específica se usó (importante si un partner tiene varias keys).
3. **`X-GeoEvidence-Partner-Id` response header** — el sub-app setea este header en cada response. El cliente puede confirmar su identidad (útil para debugging). **NO está firmado por HMAC** (lo setea el sub-app después de validar auth), pero el sub-app ya validó que la request viene del partner correcto.

Para ship-ready, si el handler interno necesita tomar decisiones de autorización basadas en `partner_id`, el partner_id se puede incluir en el payload HMAC firmado y verificar independientemente en el handler (defense in depth).

## Scripts operativos

| Script | Uso | Cuándo |
|---|---|---|
| `scripts/issue_api_key.py` | Crear partner nuevo o emitir key para existente | Onboarding de un nuevo partner o nueva app |
| `scripts/rotate_hmac_secret.py` | Rotar HMAC secret con overlap 24h | Partner pide rotación, o política de seguridad |
| `scripts/revoke_api_key.py` | Marcar key como revocada permanentemente | Compromiso, churn, inactividad prolongada |

**Todos los scripts**:
- Conectan vía `DATABASE_URL` (mismo que el verifier).
- Salida en `KEY=VALUE` para fácil piping a scripts de partner onboarding.
- Exit codes: 0 OK, 1 args inválidos, 2 partner/key no encontrado, 3 DB error.

## Auditoría

Todo acceso queda en `access_log` con:
- `partner_id`, `api_key_id` (quién)
- `method`, `path`, `status`, `latency_ms` (qué y cómo)
- `client_ip`, `user_agent` (desde dónde)
- `request_sha256`, `response_sha256` (integridad del exchange)
- `request_id` (correlación con logs internos y del partner)

El `access_log` cumple el umbral **POC-ready de Custodia** de la readiness table v1.

**Retención**: para POC-ready, sin política de purgado. Para ship-ready, agregar limpieza automática vía cron (`DELETE WHERE ts < now() - interval '90 days'`).

## Migración aplicada

```bash
# Manual (si el verifier no levanta con init_db):
psql $DATABASE_URL -f services/verifier/migrations/0001-public-api-tables.sql

# Auto: services/verifier/database.py:init_db() crea las 5 tablas al arranque.
```

## Onboarding de un partner (paso a paso)

1. Partner firma acuerdo (off-process). Anotar en `partners.notes`.
2. Admin corre: `python scripts/issue_api_key.py --new-partner "<nombre>" --contact-email <email> --environment live --tier <tier> --notes "<kyb lite>"`
3. Output: `PARTNER_ID`, `API_KEY`, `HMAC_SECRET`. **Mostrar UNA sola vez**.
4. Admin entrega `API_KEY` y `HMAC_SECRET` al partner por canal seguro (1Password share, signal, etc.).
5. Partner implementa: enviar `Authorization: Bearer <API_KEY>` y `X-GeoEvidence-Signature: t=<ts>,v1=<sig>` en cada request.
6. Admin verifica primer request del partner en `access_log`.

## Live keys (admin UI)

Para la emisión oficial de keys live, el admin usa `POST /api/admin/partners/{partner_id_or_new}/keys` con cookie `gti_admin_session` válida (sesión obtenida mediante el mismo TOTP del panel admin). Sin cookie → `401`.

El path `new` crea un partner y requiere `partner_name` y `contact_email`; un path numérico usa el partner existente y devuelve `404` si no existe. Body JSON:

```json
{
  "partner_name": "Aseguradora XYZ",
  "contact_email": "ops@xyz.com",
  "tier": "piloto",
  "notes": "Onboarding live Q1 2026"
}
```

La respuesta `200` contiene `partner_id`, `api_key`, `hmac_secret`, `key_id`, `environment`, `scopes`, `expires_at` y `rate_limit_per_hour` (además de `partner_name`). Para live, `environment` es `live`, `expires_at` es `null`, `rate_limit_per_hour` es `1000` y `scopes` es `["*"]`. `api_key` y `hmac_secret` se muestran una sola vez.

Este endpoint es el mecanismo oficial de emisión live desde el panel admin. A diferencia de `/api/admin/partners/{partner_id_or_new}/test-keys` (ticket #15), las keys live no expiran y usan el límite completo de 1000 requests por hora; las test expiran en 7 días y tienen límite de 100/h.


> **Ticket:** [#15 — Self-serve test mode endpoint](https://github.com/mandres9/GeoEvidence/issues/15)

Cuando el admin cierra el ciclo de aprobación (off-process), puede emitir la
key de test desde el panel admin en un solo curl — sin SSH al servidor ni
correr el CLI manualmente. Esto es el camino oficial para los 3-5 pilotos
en paralelo del veredicto v1.

### Endpoint

```
POST /api/admin/partners/{partner_id_or_new}/test-keys
```

- **Auth**: cookie `gti_admin_session` (mismo TOTP que el resto del panel admin).
  Sin cookie → 401.
- **Path param**:
  - `"new"` → crea partner nuevo (requiere `partner_name` + `contact_email` en el body).
  - numérico (ej. `42`) → usa partner existente. Si no existe → 404.
- **Body** (JSON, opcional salvo para `"new"`):
  ```json
  {
    "partner_name":   "Aseguradora XYZ",
    "contact_email":  "ops@xyz.com",
    "tier":           "piloto | empresarial | gobierno",
    "notes":          "Onboarding Q1 2026"
  }
  ```

### Response (200)

```json
{
  "partner_id": 42,
  "partner_name": "Aseguradora XYZ",
  "api_key":     "geoev_test_a3f9k2m7q8x4b1c9d2e5f7g8h0i",
  "hmac_secret": "<32-byte hex>",
  "key_id":      777,
  "environment": "test",
  "scopes":      ["*"],
  "expires_at":  "2026-09-12T22:20:41.627793+00:00",
  "rate_limit_per_hour": 100
}
```

`api_key` y `hmac_secret` se devuelven **UNA sola vez**. El backend guarda
sólo el SHA-256 de ambos — no se pueden recuperar.

### Properties

| Campo | Valor | Notas |
|---|---|---|
| `environment` | `test` | Fijo — no configurable en este endpoint |
| `expires_at` | `now() + 7 días` | Auto-rotación vía DELETE + nueva emisión si el piloto se extiende |
| `rate_limit_per_hour` | `100` | vs `1000/h` del plan default. Suficiente para integración + smoke test |
| `scopes` | `["*"]` | Forward-compat; sin enforcement en POC-ready |
| `idempotent` | NO | Cada llamada emite una key nueva. Para revocar la anterior: `scripts/revoke_api_key.py` |

### Ejemplo de invocación (curl)

```bash
# 1) Login admin (TOTP)
COOKIE=$(curl -s -c - -X POST https://verifier.geoevidence.com/admin/login \
  -H 'Content-Type: application/json' \
  -d '{"code":"123456"}' | grep gti_admin_session | awk '{print $7}')

# 2) Emitir key de test para partner existente (id=42)
curl -s -X POST https://verifier.geoevidence.com/api/admin/partners/42/test-keys \
  -H 'Content-Type: application/json' \
  -H "Cookie: gti_admin_session=$COOKIE" \
  -d '{"notes":"piloto Q1 — onboarding"}'
```

```bash
# Variante: crear partner nuevo + emitir key en una sola llamada
curl -s -X POST https://verifier.geoevidence.com/api/admin/partners/new/test-keys \
  -H 'Content-Type: application/json' \
  -H "Cookie: gti_admin_session=$COOKIE" \
  -d '{
    "partner_name":  "Aseguradora XYZ",
    "contact_email": "ops@xyz.com",
    "tier":          "piloto",
    "notes":         "Firmado 2026-09-05"
  }'
```

### Diferencia con `scripts/issue_api_key.py`

| Aspecto | CLI (`scripts/issue_api_key.py`) | Endpoint (`/api/admin/.../test-keys`) |
|---|---|---|
| Ambiente | `test` o `live` | `test` (sólo) |
| Expira | nunca | 7 días |
| Rate limit | 1000/h (default) | 100/h |
| Output | `KEY=VALUE` para piping | JSON |
| Auth | SSH al servidor | cookie admin (TOTP) |
| Cuándo usarlo | Migración live (producción) | Pilotos y demos (POC) |

Ambos llaman a la misma función `services/verifier/keygen.py:issue_key` —
el output es idéntico salvo `expires_at` (que es `None` en el CLI y un
TIMESTAMPTZ en el endpoint).

### Auditoría

El endpoint loguea a stdout:

```
[ADMIN] issued_test_key partner_id=<id> key_id=<id> env=test expires_at=<iso>
```

NO se escribe a `access_log` — el access_log sólo lo escribe el
`AccessLogMiddleware` del sub-app `/api/v1/`. El logueo queda como pista
operacional para que el admin pueda reconstruir qué key emitió en qué
momento sin tener que cruzar con la DB.

### Implementación

- Endpoint: `services/verifier/routes_admin.py:admin_issue_test_key` (POST).
- Lógica compartida: `services/verifier/keygen.py:issue_test_key`.
- Schema: nueva columna `api_keys.expires_at TIMESTAMPTZ` (ALTER TABLE
  idempotente en `services/verifier/database.py:init_db()`).
- Tests: `services/verifier/tests/test_issue_test_key.py` (8 casos).

## Cómo se referencia desde otros tickets

- **#8 G3 (arquitectura)**: `services/verifier/public_api/auth.py` y `hmac.py` ya implementados con soporte para rotation overlap.
- **#10 P1 (OpenAPI draft)**: la spec pública debe documentar el header `Authorization: Bearer geoev_*` y `X-GeoEvidence-Signature: t=...,v1=...` con un ejemplo de cálculo de firma.
- **#11 D1 (síntesis)**: el veredicto final del mapa compara el estado actual (R1 inventario HTTP) contra esta spec; el eje Auth & authz se evalúa como POC-ready si los 6 endpoints están gateados por `require_partner` + `verify_internal_hmac`.
