Blame
|
1 | # `did:web` Identity for robot.wtf |
||||||
| 2 | ||||||||
| 3 | ## Context |
|||||||
| 4 | ||||||||
| 5 | robot.wtf authenticates users exclusively via ATProto OAuth, which requires a Bluesky account. This creates a chicken-and-egg problem for testing permission sharing: you need an external account to log in, create a wiki, and grant permissions. |
|||||||
| 6 | ||||||||
| 7 | `did:web` provides a self-sovereign identity mechanism. Each wiki gets a DID (`did:web:{slug}.robot.wtf`) that resolves to a DID document served by the platform. A CLI tool bootstraps a user record for that DID and mints a login token, bypassing ATProto OAuth entirely. The wiki's DID document links back to its owner via `alsoKnownAs`. |
|||||||
| 8 | ||||||||
| 9 | This enables: |
|||||||
| 10 | - Logging into a wiki without a Bluesky account |
|||||||
| 11 | - Testing permission sharing between wiki identities |
|||||||
| 12 | - Platform identity assertion (`did:web:robot.wtf`) |
|||||||
| 13 | ||||||||
| 14 | ## Components |
|||||||
| 15 | ||||||||
| 16 | ### 1. Platform DID document (`did:web:robot.wtf`) |
|||||||
| 17 | ||||||||
| 18 | **File:** `app/platform_server.py` |
|||||||
| 19 | **Where:** After existing `/.well-known/jwks.json` route (line 722) |
|||||||
| 20 | ||||||||
| 21 | New route: `GET /.well-known/did.json` |
|||||||
| 22 | ||||||||
| 23 | Returns a static DID document referencing the existing `rs256_jwk`: |
|||||||
| 24 | ||||||||
| 25 | ```json |
|||||||
| 26 | { |
|||||||
| 27 | "@context": ["https://www.w3.org/ns/did/v1", "https://w3id.org/security/jwk/v1"], |
|||||||
| 28 | "id": "did:web:robot.wtf", |
|||||||
| 29 | "verificationMethod": [{ |
|||||||
| 30 | "id": "did:web:robot.wtf#platform-key", |
|||||||
| 31 | "type": "JsonWebKey", |
|||||||
| 32 | "controller": "did:web:robot.wtf", |
|||||||
| 33 | "publicKeyJwk": "<rs256_jwk>" |
|||||||
| 34 | }], |
|||||||
| 35 | "authentication": ["did:web:robot.wtf#platform-key"], |
|||||||
| 36 | "assertionMethod": ["did:web:robot.wtf#platform-key"], |
|||||||
| 37 | "service": [{ |
|||||||
| 38 | "id": "did:web:robot.wtf#oauth-as", |
|||||||
| 39 | "type": "OAuthAuthorizationServer", |
|||||||
| 40 | "serviceEndpoint": "https://robot.wtf/.well-known/oauth-authorization-server" |
|||||||
| 41 | }] |
|||||||
| 42 | } |
|||||||
| 43 | ``` |
|||||||
| 44 | ||||||||
| 45 | Uses `PLATFORM_DOMAIN` and `_SCHEME` variables already in scope. |
|||||||
| 46 | ||||||||
| 47 | ### 2. Per-wiki DID documents (`did:web:{slug}.robot.wtf`) |
|||||||
| 48 | ||||||||
| 49 | **File:** `app/resolver.py` |
|||||||
| 50 | **Where:** Early intercept in `TenantResolver.__call__()`, after slug extraction (line ~651) but before auth resolution (line ~702). Pattern: similar to `_serve_platform_static()` (line 528). |
|||||||
| 51 | ||||||||
| 52 | When `PATH_INFO == "/.well-known/did.json"`: |
|||||||
| 53 | 1. Look up wiki by slug via `WikiModel.get(slug)` |
|||||||
| 54 | 2. Return 404 if wiki not found |
|||||||
| 55 | 3. Build DID document dynamically: |
|||||||
| 56 | ||||||||
| 57 | ```json |
|||||||
| 58 | { |
|||||||
| 59 | "@context": ["https://www.w3.org/ns/did/v1", "https://w3id.org/security/jwk/v1"], |
|||||||
| 60 | "id": "did:web:dev.robot.wtf", |
|||||||
| 61 | "alsoKnownAs": ["did:plc:<owner>"], |
|||||||
| 62 | "controller": "did:web:robot.wtf", |
|||||||
| 63 | "verificationMethod": [{ |
|||||||
| 64 | "id": "did:web:dev.robot.wtf#platform-key", |
|||||||
| 65 | "type": "JsonWebKey", |
|||||||
| 66 | "controller": "did:web:robot.wtf", |
|||||||
| 67 | "publicKeyJwk": "<platform rs256_jwk>" |
|||||||
| 68 | }], |
|||||||
| 69 | "authentication": ["did:web:dev.robot.wtf#platform-key"], |
|||||||
| 70 | "assertionMethod": ["did:web:dev.robot.wtf#platform-key"] |
|||||||
| 71 | } |
|||||||
| 72 | ``` |
|||||||
| 73 | ||||||||
| 74 | **Key access:** The resolver needs the platform's public JWK. Currently `rs256_jwk` is constructed inside `_register_auth_routes()` in `platform_server.py`. Extract JWK construction into a shared utility (`app/auth/jwk.py`) that both `platform_server.py` and `resolver.py` can import. Avoids duplicating key loading logic. |
|||||||
| 75 | ||||||||
| 76 | ### 3. One-time login route |
|||||||
| 77 | ||||||||
| 78 | **File:** `app/platform_server.py` |
|||||||
| 79 | **Where:** Inside `_register_auth_routes()`, after the existing routes. |
|||||||
| 80 | ||||||||
| 81 | New route: `GET /auth/token-login` |
|||||||
| 82 | ||||||||
| 83 | Flow: |
|||||||
| 84 | 1. Extract `token` query parameter |
|||||||
| 85 | 2. Validate as platform JWT (same `PlatformJWT.validate_token()`) |
|||||||
| 86 | 3. Look up user by `sub` claim — 401 if not found |
|||||||
| 87 | 4. Mint a fresh 24-hour session JWT |
|||||||
| 88 | 5. Set as HTTP-only cookie (same pattern as OAuth callback, lines 526-545) |
|||||||
| 89 | 6. Redirect to `/app/` (or to a `next` query parameter if provided) |
|||||||
| 90 | ||||||||
| 91 | No nonce table needed — the token is a short-lived JWT signed with the platform key. |
|||||||
| 92 | ||||||||
| 93 | ### 4. CLI bootstrap tool |
|||||||
| 94 | ||||||||
| 95 | **File:** `app/cli.py` (new) |
|||||||
| 96 | **No existing CLI module.** Standalone script using argparse. |
|||||||
| 97 | ||||||||
| 98 | Usage: `python -m app.cli did-web-login --slug dev` |
|||||||
| 99 | ||||||||
| 100 | Flow: |
|||||||
| 101 | 1. Load platform DB connection (`get_connection()` from `app/db.py`) |
|||||||
| 102 | 2. Load signing keys (`_load_keys()` from `app/auth/jwt.py`) |
|||||||
| 103 | 3. Look up wiki by slug → get `owner_did`, validate wiki exists |
|||||||
| 104 | 4. Construct DID: `did:web:{slug}.{PLATFORM_DOMAIN}` |
|||||||
| 105 | 5. Construct handle: `{slug}.{PLATFORM_DOMAIN}` |
|||||||
| 106 | 6. Upsert user record: `INSERT OR REPLACE INTO users (did, handle, display_name, created_at) VALUES (?, ?, ?, ?)` |
|||||||
| 107 | 7. Mint short-lived JWT (e.g., 5 minutes): `PlatformJWT.create_token(user_did=did, handle=handle, ...)` |
|||||||
| 108 | 8. Print login URL: `https://{PLATFORM_DOMAIN}/auth/token-login?token={jwt}` |
|||||||
| 109 | ||||||||
| 110 | Environment variables needed (same as platform server): |
|||||||
| 111 | - `ROBOT_DB_PATH` — platform database |
|||||||
| 112 | - `SIGNING_KEY_PATH` — RSA private key |
|||||||
| 113 | - `PLATFORM_DOMAIN` — defaults to `robot.wtf` |
|||||||
| 114 | ||||||||
| 115 | ### 5. Shared JWK utility |
|||||||
| 116 | ||||||||
| 117 | **File:** `app/auth/jwk.py` (new) |
|||||||
| 118 | ||||||||
| 119 | Extract from `platform_server.py` lines 225-249: |
|||||||
| 120 | - `load_public_jwk(signing_key_path: str) -> dict` — loads PEM, extracts RSA public numbers, returns JWK dict |
|||||||
| 121 | ||||||||
| 122 | Both `platform_server.py` and `resolver.py` import from here. |
|||||||
| 123 | ||||||||
| 124 | ## Files changed |
|||||||
| 125 | ||||||||
| 126 | | File | Change | |
|||||||
| 127 | |------|--------| |
|||||||
| 128 | | `app/auth/jwk.py` | **New.** Shared JWK construction utility. | |
|||||||
| 129 | | `app/platform_server.py` | Add `/.well-known/did.json` route. Add `/auth/token-login` route. Refactor `rs256_jwk` construction to use shared utility. | |
|||||||
| 130 | | `app/resolver.py` | Add `/.well-known/did.json` intercept in `TenantResolver.__call__()`. | |
|||||||
| 131 | | `app/cli.py` | **New.** `did-web-login` command. | |
|||||||
| 132 | | `tests/test_did_web.py` | **New.** Tests for DID document serving and token login. | |
|||||||
| 133 | ||||||||
| 134 | ## Tests |
|||||||
| 135 | ||||||||
| 136 | **File:** `tests/test_did_web.py` |
|||||||
| 137 | ||||||||
| 138 | Uses existing fixtures from `conftest.py` (db, user_model, wiki_model, sample_user, sample_wiki). |
|||||||
| 139 | ||||||||
| 140 | 1. **Platform DID document:** `GET /.well-known/did.json` on platform server → 200, valid DID document, correct key |
|||||||
| 141 | 2. **Per-wiki DID document:** Call `TenantResolver` with `Host: test-wiki.robot.wtf` and `PATH_INFO: /.well-known/did.json` → 200, valid DID document, `alsoKnownAs` contains owner's DID |
|||||||
| 142 | 3. **Per-wiki DID 404:** Request DID for nonexistent wiki → 404 |
|||||||
| 143 | 4. **Token login:** `GET /auth/token-login?token=<valid-jwt>` → 302 redirect, cookie set |
|||||||
| 144 | 5. **Token login invalid:** `GET /auth/token-login?token=garbage` → 401 |
|||||||
| 145 | 6. **Token login unknown user:** Valid JWT but user not in DB → 401 |
|||||||
| 146 | ||||||||
| 147 | ## Verification |
|||||||
| 148 | ||||||||
| 149 | 1. Run `pytest tests/test_did_web.py` — all pass |
|||||||
| 150 | 2. Run full test suite `pytest` — no regressions |
|||||||
| 151 | 3. Manual smoke test (on VPS after deploy): |
|||||||
| 152 | - `curl https://robot.wtf/.well-known/did.json` → valid platform DID document |
|||||||
| 153 | - `curl https://dev.robot.wtf/.well-known/did.json` → valid wiki DID document with correct `alsoKnownAs` |
|||||||
| 154 | - Run CLI: `python -m app.cli did-web-login --slug dev` → prints login URL |
|||||||
| 155 | - Open login URL in browser → redirected to `/app/`, logged in as `did:web:dev.robot.wtf` |
|||||||
| 156 | - Navigate to wiki → has owner permissions |
|||||||
| 157 | ||||||||
| 158 | ## Future: per-wiki keys |
|||||||
| 159 | ||||||||
| 160 | Upgrade path when needed: |
|||||||
| 161 | 1. Add `signing_key` column to `wikis` table (or store Ed25519 keypair as file in wiki repo) |
|||||||
| 162 | 2. Generate keypair at wiki creation |
|||||||
| 163 | 3. DID document adds `#wiki-key` verification method alongside `#platform-key` |
|||||||
| 164 | 4. `controller` field removed once wiki self-signs |
|||||||
| 165 | 5. Old signatures against `#platform-key` remain verifiable during transition |
|||||||
