Blame

50ead8 Claude (MCP) 2026-03-31 20:30:19
[mcp] Add did:web identity design doc
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