personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

pairing: add design doc for Wave 5 pairing server

+459
+459
docs/design/pairing.md
··· 1 + # Wave 5 pairing server 2 + 3 + ## 1. Summary 4 + 5 + Wave 5 adds a self-hosted iOS pairing flow to the existing Convey process: one JSON API blueprint at `/api/pairing/*` plus one owner-facing HTML blueprint at `/app/pairing/*`, both defined in `convey/pairing.py`. This follows the Wave 2 / Wave 3 root-blueprint pattern rather than adding an `apps/pairing/` package (`convey/voice.py:27-197`, `convey/push.py:24-127`, `convey/__init__.py:110-169`, `/home/jer/projects/extro/vpe/workspace/hop-solstone-pairing-wave-5-server.md:20-78`). 6 + 7 + The server mints one-time `ptk_...` pairing tokens in memory, accepts ssh-ed25519 public keys from the iOS client, stores paired devices in `journal/config/paired_devices.json`, and returns a one-time `dsk_...` bearer session key whose hash is the only value persisted at rest. The companion iOS contract is fixed: `solstone://pair?token=...&host=...`, `POST {host}/api/pairing/confirm`, then `Authorization: Bearer <session_key>` on subsequent API calls (`/home/jer/projects/extro/vpe/workspace/hop-solstone-pairing-wave-5-server.md:26-56`, `/home/jer/projects/extro/vpe/workspace/hop-solstone-swift-wave-5-client.md:115-137`). 8 + 9 + The design keeps config defaults compatible with existing journals by using both a new `pairing` block in `think/journal_default.json` and in-code defaults in `think/pairing/config.py`, because `get_config()` does not merge defaults once `config/journal.json` exists (`think/journal_default.json:1-53`, `think/utils.py:557-588`, `tests/conftest.py:77-84`). It also promotes `cryptography` to a direct dependency: the repo already imports it directly in the link pairing surface, but `pyproject.toml` does not currently declare it (`apps/link/routes.py:33-35`, `pyproject.toml:32-93`). 10 + 11 + ## 2. Module layout 12 + 13 + | Path | Role | 14 + |---|---| 15 + | `pyproject.toml` | Add direct dependency `cryptography>=42` because pairing will import `cryptography` directly, and the repo already does so in `apps/link/routes.py` (`apps/link/routes.py:33-35`, `pyproject.toml:55-61`). | 16 + | `think/journal_default.json` | Add a flat `pairing` config block beside `voice` and `push`. `identity.name` and `identity.preferred` already exist here and are the source for `owner_identity` resolution (`think/journal_default.json:2-15`, `35-53`). | 17 + | `think/pairing/__init__.py` | Narrow re-export surface so callers do not reach into module-private helpers, matching the small package-surface pattern used by `think/push` and `think/voice` (`docs/design/push.md:13-24`, `docs/design/voice-server.md:11-26`). | 18 + | `think/pairing/config.py` | Config readers for `pairing.host_url`, `pairing.token_ttl_seconds`, and owner identity fallback. Mirrors the small-reader style in `think/push/config.py` and must supply defaults in code because fixture journals already have a `journal.json` (`think/push/config.py:17-81`, `think/utils.py:557-588`). | 19 + | `think/pairing/tokens.py` | In-memory token store with a module-level singleton and `threading.Lock`, modeled after `think/link/nonces.py`’s single-use TTL store but intentionally kept process-local instead of journal-backed (`think/link/nonces.py:25-103`). | 20 + | `think/pairing/keys.py` | Public-key validation, bearer-session-key generation, SHA-256 hashing, and log masking. This is the only module that knows the `ptk_...` / `dsk_...` wire formats and the ssh-ed25519-only rule. | 21 + | `think/pairing/devices.py` | Sole writer for `journal/config/paired_devices.json`, mirroring the whole-file atomic rewrite pattern from `think/push/devices.py` (`think/push/devices.py:21-153`). | 22 + | `convey/auth.py` | Shared bearer/owner auth helpers. Factors the Bearer extraction pattern out of `apps/observer/routes.py` / `apps/import/journal_sources.py` and adds paired-device resolution and owner-auth inspection without redirects (`apps/observer/routes.py:63-70`, `503-538`, `apps/import/journal_sources.py:108-128`, `convey/root.py:49-57`, `81-139`). | 23 + | `convey/pairing.py` | Defines `pairing_bp = Blueprint("pairing", ..., url_prefix="/api/pairing")` and `pairing_ui_bp = Blueprint("pairing_ui", ..., url_prefix="/app/pairing")`, using the same local `_error`, `_required_json_object`, and `_optional_json_object` request-validation pattern as `convey/voice.py` / `convey/push.py` (`convey/voice.py:27-53`, `convey/push.py:24-50`). | 24 + | `convey/__init__.py` | Import and register both pairing blueprints in the same root-blueprint block as `voice_bp` / `push_bp`, before app discovery (`convey/__init__.py:112-161`). | 25 + | `convey/root.py` | Extend the exact-name allowlist in `require_login()` with `pairing.confirm_pairing`, `pairing.heartbeat`, `pairing.list_devices`, and `pairing.unpair_device`. Leave `pairing.create_token` and `pairing_ui.index` owner-authed (`convey/root.py:81-139`). | 26 + | `convey/templates/pairing.html` | Flat owner-facing desktop page. Use a heading of “Pair a phone” to distinguish this flow from tunnel pairing in `apps/link/routes.py` (`apps/link/routes.py:4-24`). | 27 + | `convey/static/pairing-qr.js` | Vendored `qrcode-generator` browser build, loaded directly because package data currently only includes flat `static/*` assets (`pyproject.toml:110-118`, `convey/__init__.py:118-133`). | 28 + | `convey/static/pairing.js` | Page logic: mint token, render QR, countdown, 5-second polling against `GET /api/pairing/devices`, copy-paste fallback, success/error state. | 29 + | `convey/static/pairing.css` | Minimal page styling only if `app.css` reuse is insufficient. | 30 + 31 + ### Public API surface 32 + 33 + The public function surface is intentionally small and explicit. 34 + 35 + #### `think/pairing/config.py` 36 + 37 + - `def get_host_url() -> str:` 38 + Return the configured pairing host URL, or synthesize `http://localhost:<convey-port>` when `pairing.host_url` is null by reading the recorded Convey port and falling back to the installed default port `5015` (`think/utils.py:922-935`, `think/service.py:32-34`). 39 + - `def get_token_ttl_seconds() -> int:` 40 + Return the configured token TTL, clamped to `60..3600`, defaulting to `600`. 41 + - `def get_owner_identity() -> str:` 42 + Return `config.identity.preferred`, else `config.identity.name`, else `""` (`think/journal_default.json:2-15`). 43 + 44 + #### `think/pairing/tokens.py` 45 + 46 + - `def create_token(*, ttl_seconds: int | None = None, now: int | None = None) -> PairingToken:` 47 + Mint a `ptk_...` token, insert it into the in-memory store, and return its issued/expires metadata. 48 + - `def consume_token(token: str, *, now: int | None = None) -> PairingToken | None:` 49 + Atomically validate and consume a token; return `None` for missing, expired, or already-consumed tokens. 50 + - `def peek_token(token: str, *, now: int | None = None) -> PairingToken | None:` 51 + Return current token metadata without consuming it while still pruning expired entries. 52 + - `def purge_expired_tokens(*, now: int | None = None) -> int:` 53 + Remove expired tokens and return the number purged. 54 + 55 + #### `think/pairing/keys.py` 56 + 57 + - `def validate_public_key(public_key: str) -> str:` 58 + Parse and validate an ssh-ed25519 public key, reject any non-Ed25519 algorithm, and return the normalized string. 59 + - `def generate_session_key() -> str:` 60 + Mint a one-time `dsk_...` bearer session key for the paired device. 61 + - `def hash_session_key(session_key: str) -> str:` 62 + Return `sha256:<hex>` for storage and lookup. 63 + - `def mask_session_key(session_key: str) -> str:` 64 + Return a log-safe mask showing only the last four characters and the total length. 65 + 66 + #### `think/pairing/devices.py` 67 + 68 + - `def load_devices() -> list[Device]:` 69 + Load and validate `paired_devices.json`, returning `[]` on missing or malformed stores with a warning. 70 + - `def find_device_by_id(device_id: str) -> Device | None:` 71 + Return one paired device by `id`, or `None`. 72 + - `def find_device_by_session_key_hash(session_key_hash: str) -> Device | None:` 73 + Return one paired device matching a stored `session_key_hash`, or `None`. 74 + - `def register_device(*, name: str, platform: str, public_key: str, session_key_hash: str, bundle_id: str, app_version: str, paired_at: str | None = None) -> Device:` 75 + Create or update a device row keyed by `public_key`, generating a stable `dev_...` id on first registration and rotating the stored `session_key_hash` on re-pair. 76 + - `def touch_last_seen(device_id: str, *, last_seen_at: str | None = None) -> bool:` 77 + Update `last_seen_at` for an existing paired device. 78 + - `def remove_device(device_id: str) -> bool:` 79 + Remove one paired device by `id`. 80 + - `def status_view(device: Device) -> dict[str, Any]:` 81 + Return the non-secret JSON view exposed by `GET /api/pairing/devices`. 82 + 83 + #### `convey/auth.py` 84 + 85 + - `def extract_bearer_token() -> str | None:` 86 + Return the trimmed `Authorization: Bearer ...` token if present, else `None`. 87 + - `def resolve_paired_device() -> Device | None:` 88 + Hash the presented bearer token, load the matching paired device, and return it when valid. 89 + - `def is_owner_authed() -> bool:` 90 + Return `True` when the current request already satisfies the owner checks used by `require_login()` without triggering redirects: session cookie, Basic Auth, or the completed-setup localhost bypass (`convey/root.py:49-57`, `81-139`). 91 + - `def require_paired_device(f):` 92 + Decorator that resolves a paired-device bearer, stores it on `g.paired_device`, and returns `401` JSON when no valid paired-device bearer is present. 93 + 94 + ## 3. Flow diagrams 95 + 96 + ### 3.1 Token mint (`POST /api/pairing/create`) 97 + 98 + ```text 99 + owner browser on /app/pairing/ 100 + -> POST /api/pairing/create (owner-auth via require_login) 101 + -> pairing config resolves host URL + TTL 102 + -> think.pairing.tokens.create_token(...) 103 + -> response includes token, expires_at, pairing_url, qr_data 104 + -> pairing.js renders QR client-side and starts countdown 105 + ``` 106 + 107 + This mirrors the local-request-validation shape of `convey/voice.py` and `convey/push.py`, but the write target is an in-memory store rather than journal state (`convey/voice.py:30-53`, `convey/push.py:27-50`, `think/link/nonces.py:45-103`). 108 + 109 + ### 3.2 Confirm (`POST /api/pairing/confirm`) 110 + 111 + ```text 112 + iOS client 113 + -> POST /api/pairing/confirm 114 + -> allowlist bypasses require_login 115 + -> route validates JSON object + token/public_key/device metadata 116 + -> think.pairing.tokens.consume_token(token) 117 + -> think.pairing.keys.validate_public_key(public_key) 118 + -> think.pairing.keys.generate_session_key() + hash_session_key(...) 119 + -> think.pairing.devices.register_device(...) 120 + -> response returns session_key once, plus device_id/journal_root/owner_identity/server_version 121 + ``` 122 + 123 + The confirm flow deliberately follows the existing “token in header/body, then validate against a feature-owned store” pattern from observer ingest and journal-source ingest, but pairing consumes its own in-memory token and persists only the device ledger (`apps/observer/routes.py:524-538`, `apps/import/journal_sources.py:108-128`, `/home/jer/projects/extro/vpe/workspace/hop-solstone-swift-wave-5-client.md:115-124`). 124 + 125 + ### 3.3 List / heartbeat (`GET /api/pairing/devices`, `POST /api/pairing/heartbeat`) 126 + 127 + ```text 128 + paired device 129 + -> Authorization: Bearer dsk_... 130 + -> list: resolve paired device or owner path, return non-secret device rows 131 + -> heartbeat: @require_paired_device resolves bearer and stores g.paired_device 132 + -> think.pairing.devices.touch_last_seen(g.paired_device["id"]) 133 + ``` 134 + 135 + Heartbeat is bearer-only. List is mixed-auth: allowlist bypass at `require_login()`, then explicit handler-level acceptance of either a paired-device bearer or an already-owner-authenticated request. 136 + 137 + ### 3.4 Unpair (`DELETE /api/pairing/devices/<device_id>`) 138 + 139 + ```text 140 + paired device OR owner browser 141 + -> allowlist bypasses require_login 142 + -> route resolves either bearer device or owner auth 143 + -> think.pairing.devices.remove_device(device_id) 144 + -> 200 {"unpaired": true} on success 145 + ``` 146 + 147 + The storage rule is simple: unpair removes the row from `paired_devices.json` rather than soft-deleting it. That keeps the store authoritative for current pairings only, matching the existing push-device store style (`think/push/devices.py:76-124`). 148 + 149 + ### 3.5 Restart semantics 150 + 151 + ```text 152 + process restart 153 + -> in-memory token store is empty 154 + -> pending QR codes immediately stop working 155 + -> paired_devices.json persists 156 + -> existing dsk_... bearer tokens continue to resolve 157 + ``` 158 + 159 + This split is deliberate. The token store is ephemeral by scope; the device ledger is durable config (`/home/jer/projects/extro/vpe/workspace/hop-solstone-pairing-wave-5-server.md:32`, `147-148`). 160 + 161 + ## 4. Endpoint specs 162 + 163 + ### 4.1 Endpoint table 164 + 165 + | Route | Auth gate | Request shape | Success response | Error cases | 166 + |---|---|---|---|---| 167 + | `POST /api/pairing/create` | `require_login` only | Optional JSON object; no required fields in Wave 5 | `{"token","expires_at","pairing_url","qr_data"}` | `400` invalid JSON / non-object, `500` token mint failure | 168 + | `POST /api/pairing/confirm` | allowlist only | Required JSON object with `token`, `public_key`, `device_name`, `platform`, `bundle_id`, `app_version` | `{"session_key","device_id","journal_root","owner_identity","server_version"}` | `400` bad JSON / missing fields / bad key / unsupported platform, `410` token expired or used | 169 + | `POST /api/pairing/heartbeat` | allowlist + `@require_paired_device` | Optional empty body or JSON object ignored in Wave 5 | `{"ok": true}` | `400` invalid JSON / non-object, `401` missing or invalid bearer | 170 + | `GET /api/pairing/devices` | allowlist + chained | No body | `{"devices":[...]}` | `401` no valid paired-device bearer and not owner-authed | 171 + | `DELETE /api/pairing/devices/<device_id>` | allowlist + chained | No body | `{"unpaired": true}` | `401` no valid paired-device bearer and not owner-authed, `404` unknown `device_id` | 172 + | `GET /app/pairing/` | `require_login` only | No body | `200` HTML page | No route-specific auth bypass; unauthenticated requests redirect via `require_login()` | 173 + 174 + ### 4.2 Allowlist additions 175 + 176 + Add these exact endpoint names to `convey/root.py`’s `require_login()` allowlist and no others: 177 + 178 + - `pairing.confirm_pairing` 179 + - `pairing.heartbeat` 180 + - `pairing.list_devices` 181 + - `pairing.unpair_device` 182 + 183 + Do **not** add `pairing.create_token`. Do **not** add `pairing_ui.index` (`convey/root.py:81-139`). 184 + 185 + ### 4.3 Mixed-auth chain for `list_devices` and `unpair_device` 186 + 187 + The mixed-auth routes intentionally do **not** use `@require_paired_device`, because that decorator is bearer-only and would reject a valid owner request before the owner path could run. Their route-level chain is: 188 + 189 + 1. `require_login()` is bypassed by endpoint-name allowlist. 190 + 2. The handler calls `resolve_paired_device()`. 191 + 3. If a device is found, the handler sets `g.paired_device = device` and proceeds. 192 + 4. If no device is found, the handler calls `is_owner_authed()`. 193 + 5. If neither path succeeds, the handler returns `401 {"error": "...", "reason": "auth_required"}`. 194 + 195 + This keeps the bypass explicit, preserves owner access via cookie / Basic Auth / localhost semantics, and avoids redirect responses on API routes (`convey/root.py:49-57`, `81-139`). 196 + 197 + ## 5. Config keys 198 + 199 + Add this block to `think/journal_default.json` after `push` and before `retention`: 200 + 201 + ```json 202 + "pairing": { 203 + "host_url": null, 204 + "token_ttl_seconds": 600 205 + } 206 + ``` 207 + 208 + Key rules: 209 + 210 + - `pairing.host_url` 211 + - Default in `journal_default.json`: `null`. 212 + - Runtime behavior: when null, synthesize `http://localhost:<convey-port>` using `read_service_port("convey")` and fall back to `DEFAULT_SERVICE_PORT = 5015` (`think/utils.py:922-935`, `think/service.py:32-34`). 213 + - Operator behavior: set this explicitly when Convey is exposed through a tunnel, reverse proxy, or non-localhost origin. 214 + - `pairing.token_ttl_seconds` 215 + - Default: `600`. 216 + - Runtime clamp: `60..3600`. 217 + 218 + `owner_identity` does not need its own config key; it resolves from existing identity fields: `identity.preferred`, then `identity.name`, then `""` (`think/journal_default.json:2-15`). 219 + 220 + ## 6. Feature-specific detail 221 + 222 + ### 6.1 Token store 223 + 224 + Decision: use a module-level singleton store backed by `threading.Lock` and a plain dict. Rationale: the scope explicitly accepts restart-invalidated tokens, and the existing link nonce store shows the TTL + single-use semantics we need without implying journal persistence (`think/link/nonces.py:45-103`, `/home/jer/projects/extro/vpe/workspace/hop-solstone-pairing-wave-5-server.md:32`, `147-148`). 225 + 226 + Token rules: 227 + 228 + - Format: `ptk_<urlsafe-base64-32-bytes>`. 229 + - TTL default: `600` seconds. 230 + - TTL clamp: `60..3600`. 231 + - Single-use: `consume_token()` is the only mutating read path. 232 + - Restart invariant: store is empty after process restart; paired devices remain valid. 233 + 234 + ### 6.2 Session-key crypto 235 + 236 + Decision: bearer session keys are `dsk_<urlsafe-base64-32-bytes>` and are stored only as `sha256:<hex>`. Rationale: this matches the scope’s one-time-return contract and keeps the journal ledger non-secret at rest (`/home/jer/projects/extro/vpe/workspace/hop-solstone-pairing-wave-5-server.md:41-54`, `147-148`). 237 + 238 + ### 6.3 Public-key validation 239 + 240 + Decision: accept ssh-ed25519 only, with a `2048`-character cap on the incoming public-key string and a `128`-character cap on `device_name`. Rationale: the client contract already generates Ed25519 keys, and tight bounds keep the parser and logs safe (`/home/jer/projects/extro/vpe/workspace/hop-solstone-swift-wave-5-client.md:116-122`). 241 + 242 + ### 6.4 `paired_devices.json` schema 243 + 244 + The on-disk store is a single JSON object: 245 + 246 + ```json 247 + { 248 + "devices": [ 249 + { 250 + "id": "dev_...", 251 + "name": "jer's iPhone 15 Pro", 252 + "platform": "ios", 253 + "public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...", 254 + "session_key_hash": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", 255 + "bundle_id": "org.solpbc.solstone-swift", 256 + "app_version": "0.1.0", 257 + "paired_at": "2026-04-20T15:31:02Z", 258 + "last_seen_at": null 259 + } 260 + ] 261 + } 262 + ``` 263 + 264 + Store rules: 265 + 266 + - Top-level object only. 267 + - Device identity is unique by `public_key`; re-pairing the same phone updates the existing row in place and rotates `session_key_hash`. 268 + - `last_seen_at` starts `null` and is updated only by `POST /api/pairing/heartbeat`. 269 + - Unpair removes the row entirely. 270 + 271 + ### 6.5 Auth chain 272 + 273 + Decision: ship a pairing-focused `convey/auth.py` now, but do not thread it into existing voice/push/observer routes. Rationale: Wave 5 needs the helper surface immediately, but the scope explicitly defers cross-route enforcement to Wave 5.1 (`/home/jer/projects/extro/vpe/workspace/hop-solstone-pairing-wave-5-server.md:66-72`, `134-148`). 274 + 275 + Helper contract: 276 + 277 + - `extract_bearer_token()` mirrors the existing header parsing in observer and import. 278 + - `resolve_paired_device()` hashes the bearer and resolves `paired_devices.json`. 279 + - `require_paired_device` is used only on bearer-only pairing routes in this lode. 280 + - `is_owner_authed()` mirrors `require_login()`’s owner checks without redirects. 281 + 282 + ### 6.6 Error model 283 + 284 + Decision: every API error is JSON shaped as `{"error": "<human message>", "reason": "<stable_reason>"}`. Rationale: this preserves the concise error-helper style from `convey/voice.py` / `convey/push.py` while giving the client a stable machine-readable string that does not echo input back (`convey/voice.py:30-53`, `convey/push.py:27-50`). 285 + 286 + ### 6.7 QR generation 287 + 288 + Decision: vendor `qrcode-generator` version `1.4.4` under `convey/static/pairing-qr.js` and generate the SVG client-side from the `qr_data` field returned by `POST /api/pairing/create`. Rationale: the repo packaging only includes flat `static/*` assets, and this keeps the QR dependency browser-only, MIT-licensed, and separate from the page logic in `convey/static/pairing.js` (`pyproject.toml:110-118`, `convey/__init__.py:118-133`, `/home/jer/projects/extro/vpe/workspace/hop-solstone-pairing-wave-5-server.md:60-64`). 289 + 290 + ### 6.8 Logging 291 + 292 + Decision: use `logging` only; never log raw `session_key`; log masked session keys as last four chars + total length; log public keys only at `DEBUG` with truncation. Rationale: the scope forbids raw secret leakage and pairing is explicitly bearer-token based (`/home/jer/projects/extro/vpe/workspace/hop-solstone-pairing-wave-5-server.md:147-148`). 293 + 294 + ## 7. Domain write-ownership (L1-L9 declarations) 295 + 296 + This lode stays within the repo’s layer-hygiene invariants (`scripts/check_layer_hygiene.py:38-110`, `183-240`). 297 + 298 + - **L1 Layer boundaries**: `think/pairing/devices.py` owns the device ledger. `convey/pairing.py` only validates requests, coordinates feature calls, and returns HTTP responses, matching the root-blueprint split in voice/push (`convey/voice.py:30-197`, `convey/push.py:27-127`). 299 + - **L2 Domain write ownership**: `journal/config/paired_devices.json` is owned exclusively by `think/pairing/devices.py`. No other module writes it. The token store is in memory only, so no extra journal domain is created. 300 + - **L3 Naming contract**: read helpers use read verbs (`load_devices`, `find_device_by_id`, `find_device_by_session_key_hash`); write helpers use write verbs (`register_device`, `touch_last_seen`, `remove_device`). 301 + - **L4 CLI read verbs are read-only**: no new CLI surface is introduced in this lode. 302 + - **L5 Write-verb defaults**: not applicable to CLI; the only mutating surfaces are explicit HTTP write routes. 303 + - **L6 Indexers never mutate source data**: not applicable; no indexer changes. 304 + - **L7 Importers only write to imports/**: not applicable; no importer changes. 305 + - **L8 Hooks have declared outputs**: not applicable; no hook changes. 306 + - **L9 Event handlers are idempotent**: `heartbeat` is idempotent by row update; `confirm` is single-use because the token store enforces consume-once; `create` is side-effectful by construction but scoped to ephemeral memory. 307 + 308 + ## 8. Tests 309 + 310 + ### 8.1 `tests/test_pairing_config.py` 311 + 312 + Verify config defaults, null-host synthesis, TTL clamp behavior, and owner-identity fallback against fixture journals that already contain `config/journal.json` (`think/utils.py:557-588`, `tests/conftest.py:77-84`). 313 + 314 + ### 8.2 `tests/test_pairing_tokens.py` 315 + 316 + Verify token shape, TTL metadata, single-use semantics, expiry purge, and restart-local assumptions of the module singleton (`think/link/nonces.py:45-103`). 317 + 318 + ### 8.3 `tests/test_pairing_keys.py` 319 + 320 + Verify ssh-ed25519 acceptance, rejection of ssh-rsa / ecdsa / malformed keys, session-key shape, SHA-256 hash format, and masking helpers. 321 + 322 + ### 8.4 `tests/test_pairing_devices.py` 323 + 324 + Verify malformed-store recovery, atomic whole-file rewrites, public-key-keyed upsert behavior, `last_seen_at` updates, removal by id, and `status_view()` redaction pattern matching the push-device precedent (`think/push/devices.py:64-153`). 325 + 326 + ### 8.5 `tests/test_pairing_auth.py` 327 + 328 + Verify `extract_bearer_token()`, `resolve_paired_device()`, `require_paired_device`, and `is_owner_authed()` against cookie, Basic Auth, and `trust_localhost` cases from `convey/root.py` (`convey/root.py:49-57`, `81-139`). 329 + 330 + ### 8.6 `tests/test_pairing_routes.py` 331 + 332 + Verify the 5 JSON routes plus the UI route: JSON validation, allowlist expectations, mixed-auth acceptance on list/unpair, confirm success/error shapes, and polling-facing device list output. 333 + 334 + ### 8.7 `tests/test_pairing_integration.py` 335 + 336 + Exercise the full owner-create -> confirm -> bearer-list -> heartbeat -> unpair round trip using `journal_copy`, with no fixture `pairing` stanza added (`tests/conftest.py:77-84`). 337 + 338 + ## 9. Security considerations 339 + 340 + ### Token reuse and replay 341 + 342 + `consume_token()` is the only mutating read path and it enforces single-use + TTL. Tokens are not written to disk, so process restart invalidates all outstanding QR codes, which the scope explicitly accepts (`/home/jer/projects/extro/vpe/workspace/hop-solstone-pairing-wave-5-server.md:32`, `147-148`, `think/link/nonces.py:66-85`). 343 + 344 + ### Key validation and bounded input 345 + 346 + Only ssh-ed25519 public keys are accepted. Rejecting all other SSH algorithms aligns the server with the iOS client contract and keeps the parser surface narrow (`/home/jer/projects/extro/vpe/workspace/hop-solstone-swift-wave-5-client.md:116-122`). 347 + 348 + ### Log masking 349 + 350 + No raw `session_key` appears in logs, error messages, or `paired_devices.json`. Public keys are truncated when logged at `DEBUG`. This is stricter than the existing link pair route, which still echoes CSR parse failures in the JSON error body; pairing should not repeat that pattern (`apps/link/routes.py:230-235`, `/home/jer/projects/extro/vpe/workspace/hop-solstone-pairing-wave-5-server.md:147-148`). 351 + 352 + ### Restart semantics 353 + 354 + Ephemeral pairing tokens disappear on restart; paired-device bearers remain valid because only their hashes are persisted. This split is acceptable at MVP scale and keeps the durable store strictly journal-config scoped (`/home/jer/projects/extro/vpe/workspace/hop-solstone-pairing-wave-5-server.md:32`, `41-54`, `147-148`). 355 + 356 + ### Wave 5.1 enforcement gap 357 + 358 + This lode ships `convey/auth.py` and the pairing-only route protections, but it does not yet enforce paired-device auth on existing voice, push, or observer routes. That follow-up is explicit and documented below. 359 + 360 + ### Naming and UI risk 361 + 362 + There is already a separate “pair” concept in the tunnel subsystem (`apps/link/routes.py:4-24`, `161-256`, `think/link/README.md:1-20`). To reduce operator confusion, the desktop page heading should read **“Pair a phone”** rather than the more ambiguous **“Pair a device.”** This is a UX pitfall to revisit if operators continue to confuse iOS app pairing with tunnel pairing. 363 + 364 + ## 10. Live validation 365 + 366 + Sandbox smoke-test commands: 367 + 368 + ```sh 369 + BASE_URL=${BASE_URL:-http://127.0.0.1:5015} 370 + AUTH=${AUTH:-":$SOL_PASSWORD"} 371 + TMPDIR=$(mktemp -d) 372 + KEY_PREFIX="$TMPDIR/pairing-smoke" 373 + 374 + ssh-keygen -q -t ed25519 -N '' -f "$KEY_PREFIX" >/dev/null 375 + PUBLIC_KEY=$(tr -d '\n' < "$KEY_PREFIX.pub") 376 + 377 + CREATE_JSON=$(curl -u "$AUTH" \ 378 + -H 'Content-Type: application/json' \ 379 + -X POST "$BASE_URL/api/pairing/create" \ 380 + -d '{}') 381 + printf '%s\n' "$CREATE_JSON" 382 + 383 + TOKEN=$(CREATE_JSON="$CREATE_JSON" python - <<'PY' 384 + import json, os 385 + print(json.loads(os.environ["CREATE_JSON"])["token"]) 386 + PY 387 + ) 388 + 389 + CONFIRM_JSON=$(curl \ 390 + -H 'Content-Type: application/json' \ 391 + -X POST "$BASE_URL/api/pairing/confirm" \ 392 + -d '{ 393 + "token": "'"$TOKEN"'", 394 + "public_key": "'"$PUBLIC_KEY"'", 395 + "device_name": "Pairing Smoke iPhone", 396 + "platform": "ios", 397 + "bundle_id": "org.solpbc.solstone-swift", 398 + "app_version": "0.1.0" 399 + }' \ 400 + "$BASE_URL/api/pairing/confirm") 401 + printf '%s\n' "$CONFIRM_JSON" 402 + 403 + SESSION_KEY=$(CONFIRM_JSON="$CONFIRM_JSON" python - <<'PY' 404 + import json, os 405 + print(json.loads(os.environ["CONFIRM_JSON"])["session_key"]) 406 + PY 407 + ) 408 + DEVICE_ID=$(CONFIRM_JSON="$CONFIRM_JSON" python - <<'PY' 409 + import json, os 410 + print(json.loads(os.environ["CONFIRM_JSON"])["device_id"]) 411 + PY 412 + ) 413 + 414 + curl -H "Authorization: Bearer $SESSION_KEY" \ 415 + "$BASE_URL/api/pairing/devices" 416 + 417 + curl -H "Authorization: Bearer $SESSION_KEY" \ 418 + -H 'Content-Type: application/json' \ 419 + -X POST "$BASE_URL/api/pairing/heartbeat" \ 420 + -d '{}' 421 + 422 + curl -u "$AUTH" \ 423 + "$BASE_URL/api/pairing/devices" 424 + 425 + curl -H "Authorization: Bearer $SESSION_KEY" \ 426 + -X DELETE "$BASE_URL/api/pairing/devices/$DEVICE_ID" 427 + ``` 428 + 429 + Basic Auth uses only the password component, so `-u ":$SOL_PASSWORD"` is the portable form for owner-auth pairing routes (`convey/root.py:49-57`, `docs/design/push.md:609-646`). 430 + 431 + ## 11. Wave 5.1 follow-up 432 + 433 + Wave 5.1 will apply `@require_paired_device` to existing iOS-facing routes, but **nothing in those files changes in this lode**. 434 + 435 + Routes queued for Wave 5.1: 436 + 437 + - `POST /api/voice/session`, `POST /api/voice/connect`, `POST /api/voice/refresh-brain`, `GET /api/voice/nav-hints`, `GET /api/voice/observer-actions`, `GET /api/voice/status` in `convey/voice.py` (`convey/voice.py:65-194`). 438 + - `POST /api/push/register`, `DELETE /api/push/register`, `GET /api/push/status`, `POST /api/push/test` in `convey/push.py` (`convey/push.py:53-124`). 439 + - `GET /api/voice/observer-actions` remains the immediate observer-actions surface requiring paired-device auth in addition to the pairing routes themselves (`convey/voice.py:170-176`). 440 + 441 + The follow-up will be a targeted auth-enforcement sweep only; the helper surface is shipped in Wave 5 so that sweep can stay mechanical. 442 + 443 + ## 12. Open questions 444 + 445 + - None — ready to implement. 446 + 447 + ## 13. Sources 448 + 449 + - Wave 5 server scope: `/home/jer/projects/extro/vpe/workspace/hop-solstone-pairing-wave-5-server.md:1-161` 450 + - Wave 5 iOS companion scope: `/home/jer/projects/extro/vpe/workspace/hop-solstone-swift-wave-5-client.md:112-137` 451 + - Root blueprint pattern: `convey/voice.py:27-197`, `convey/push.py:24-127`, `convey/__init__.py:110-169` 452 + - Current auth gate and owner auth semantics: `convey/root.py:30-57`, `81-139` 453 + - Existing Bearer-auth prior art: `apps/observer/routes.py:63-70`, `503-538`, `apps/import/journal_sources.py:108-128` 454 + - Durable config/default behavior: `think/journal_default.json:2-53`, `think/utils.py:557-588`, `tests/conftest.py:77-84` 455 + - Push-device storage prior art: `think/push/devices.py:21-153`, `think/push/config.py:17-81` 456 + - Convey packaging/static constraints: `pyproject.toml:110-118`, `convey/__init__.py:118-133` 457 + - Link pairing naming collision and prior-art nonce store: `apps/link/routes.py:4-24`, `74-89`, `161-256`, `think/link/nonces.py:25-103`, `think/link/README.md:1-20`, `think/link/paths.py:74-95` 458 + - Design-doc structure precedent: `docs/design/push.md:1-654`, `docs/design/voice-server.md:1-465` 459 + - Layer-hygiene scope: `scripts/check_layer_hygiene.py:38-110`, `183-240`