Commit b0450d7
authored
B.3: end-to-end LWS10-CID auth — sign-in, ES256K JWK VM, JWT signing (#4)
* B.3: end-to-end LWS10-CID auth — sign-in, ES256K JWK VM, JWT signing
Closes the loop with the strict LWS10-CID path: same secp256k1 key
Nostr already uses, but signed with ECDSA (RFC8812 ES256K) so the
JWT is fully spec-conformant. Pairs with the JSS verifier in
JavaScriptSolidServer/JavaScriptSolidServer#398 (now merged).
What it does:
1. Sign in to the user's pod via Solid-OIDC. Uses the standalone
`solid-oidc` package via esm.sh — zero deps on the doctor side,
the package handles PKCE + DPoP + IndexedDB session persistence.
2. User pastes their secp256k1 private key (32 bytes hex; nsec hex
works since it's the same key Nostr uses). Held in memory only;
never persisted.
3. Doctor derives a JsonWebKey VM (kty:EC, crv:secp256k1, alg:ES256K,
x/y coords), GETs the WebID profile via authFetch, merges the VM
into verificationMethod + authentication, PUTs back. Idempotent
merge — replaces an existing VM with the same id, otherwise
appends.
4. "Test auth" button signs a fresh LWS10-CID JWT (sub === iss ===
client_id === WebID, aud = pod origin, exp = iat + 5 min),
GETs the WebID URL with `Authorization: Bearer <jwt>` (NOT
authFetch — the JWT must be the only auth on the wire), shows
the response status + WAC-Allow header.
Verified end-to-end via a node round-trip: a JWT built with the
exact recipe in lib/lws-cid-client.js is accepted by the JSS
verifier (src/auth/lws-cid.js). Same `@noble/curves` primitives
used on both sides.
Closes #3.
* Address copilot pass 1 on #4
Ten findings, all real. Five for behavior, four for wording, one
cleanup.
Behavior:
1. VM controller hard-coded to webId. The builder now accepts an
explicit `controller` (defaulting to webId for the common
self-controlled case); the doctor passes `lastController` from
diagnostics so delegated-control profiles produce VMs that match
the profile's outer controller predicate. Verified end-to-end
against the JSS verifier in self-controlled, delegated-controlled,
and mismatched scenarios.
2. memPrivKey + lastVmKid weren't cleared on sign-out. The UI
promised sign-out clears state, and a privkey sitting in a tab
that's no longer authenticated is just exposure with no purpose.
Now nulled (and the input field cleared) when the session goes
inactive.
3. Read-modify-write PUT had no concurrency control. Now captures
ETag from the GET and sends it via If-Match on the PUT, with a
clear "profile changed since GET" error on 412/409.
4. mergeVerificationMethod only matched object entries, missing
string-IRI entries. JSON-LD permits VMs to be referenced by IRI
string, so an existing string entry could leave a duplicate when
merged. Now matches both forms via entryMatchesId.
5. Idempotent merge could silently clobber a different key sitting
at the same fragment. Now compares publicKeyJwk material (kty,
crv, x, y) and refuses to overwrite if the existing key differs,
pointing the user to a fresh fragment.
Wording (README/UI said "PATCH" but code does GET+PUT):
6. README's B.3 description.
7. lws-auth section's intro.
8. The "future" hint in the B.2 section is now stale — replaced
with a pointer to B.3.
Cleanup:
9. lastProfile was assigned but never read — dropped.
10. New patch-section hint clarifies why we PUT instead of PATCH
(JSS conneg-layer edge cases on patch round-trips).
* Address copilot pass 2 on #4
Four findings, all real:
1. extractIssuer didn't handle JSON-LD array shape (line 617).
Profiles emitting `oidcIssuer: [{"@id":"..."}]` were treated as
having no issuer. Now normalizes through asArray-like logic and
takes the first usable entry.
2. The "pick a new fragment" suggestion in the merge error was
useless because the fragment was hard-coded. Replaced
refuse-to-clobber with auto-pick: walk lws-key-1..99 and choose
the first slot that's either unused or already holds the SAME
public key (idempotent re-run). Different key on lws-key-1 →
automatically lands at lws-key-2; same key → lands on lws-key-1
for idempotence. Verified in both scenarios.
3. README said "Nostr nsec hex" — but nsec is bech32 (`nsec1…`),
not hex. Reworded to "64 hex chars (the raw 32-byte key behind
your nsec1…)" so users don't paste bech32 and hit a confusing
"not a hex string" error.
4. Same wording in index.html, plus the input label/hint clarified.
* Address copilot pass 3 on #4
Three findings:
1. chooseFragmentAndBuildVm bug introduced in pass 2:
`buildEs256kVerificationMethod` returns `{ vm, jwk, kid }` but
I read `probe.publicKeyJwk` (always undefined). sameJwk() never
matched, so idempotent re-runs would walk to a fresh fragment
instead of reusing the existing same-key VM at lws-key-1. Fixed
to read `probe.jwk`.
(My pass-2 smoke test "passed" because it inlined the logic with
a hand-built `{ publicKeyJwk: jwk }` probe — wasn't exercising
the actual code. Real bug.)
2. revealLwsAuthSection() unconditionally set "no oidcIssuer" error
status when lastIssuer was missing. If a session was already
restored from IndexedDB the user's signed-in status got
clobbered with a pre-login warning. Now gated on `!session.isActive`.
3. README roadmap line still said "PATCHed" for the B.3 entry —
updated to match the implementation ("written into profile via
GET → merge → PUT with If-Match").
* Address copilot pass 4 on #4
hideLwsAuthSection() reset memPrivKey/lastVmKid but left the
pasted value in the privkey <input> DOM element. If a user
re-ran diagnostics or switched to a different WebID, the
privkey could be silently reused on the wrong identity. Clear
privkeyInput.value too. (sign-out already does this; this
catches the diagnostic-re-run path.)
* Address copilot pass 5 on #4
The comment said the JWT lifetime is "capped at 5 minutes" but
the code only used 5 minutes as a default — callers could pass
arbitrarily large lifetimeSec and produce tokens the JSS verifier
would later reject for exceeding MAX_LIFETIME (3600s).
Now enforces the same 3600s cap at sign time so we don't mint
tokens the server will refuse, and rejects non-numeric / non-positive
input. Comment updated to describe the actual behavior (default
300s, enforced cap 3600s, matches server).
* Address copilot pass 6 on #4
Three real findings:
1. entryMatchesId only did exact string equality, missing the
relative-IRI case (line 639). JSON-LD profiles often write VM
ids as `"#lws-key-1"` which resolve against the document URL.
Without absolutization, fragment-collision detection would walk
PAST an existing relative entry as if free, then merge would
create a duplicate. Now resolves both sides against baseUrl
before comparing. Verified across absolute/relative/object-
wrapped forms.
2. authentication de-dupe had the same blind spot (line 631) —
could push an absolute IRI even when an equivalent "#fragment"
already existed. Now absolutizes both sides via the same helper.
3. PATCH-failure path left the test UI in a stale "ready to test"
state if a prior run had succeeded (line 500). Could mislead a
user into hitting Test with a kid the server may not have. Now
clears lastVmKid, hides testSection, resets testResult in the
catch block.
* Address copilot pass 7 on #4
Two findings:
1. Privkey memory residue (line 364). Setting memPrivKey = null
drops our reference but leaves the 32 bytes in the heap until
GC. JS gives no real memory clearing, but for a Uint8Array we
own, fill(0) overwrites the bytes in place — meaningful for a
multi-step UI where the key sits between PATCH and Test. Added
clearMemPrivKey() and routed the three call sites through it.
2. Profile GET assumed JSON Content-Type (line 456). If the pod
returned Turtle / an HTML error page despite our Accept,
getRes.json() would throw a generic "Unexpected token in JSON"
error. Now reads as text first, validates content-type contains
"json", surfaces the actual content-type and a body prefix on
mismatch, and wraps JSON.parse with a clearer error message.1 parent 768d250 commit b0450d7
5 files changed
Lines changed: 771 additions & 3 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
24 | 24 | | |
25 | 25 | | |
26 | 26 | | |
| 27 | + | |
| 28 | + | |
27 | 29 | | |
28 | 30 | | |
29 | 31 | | |
30 | 32 | | |
| 33 | + | |
31 | 34 | | |
32 | | - | |
33 | 35 | | |
34 | 36 | | |
35 | 37 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
309 | 309 | | |
310 | 310 | | |
311 | 311 | | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
| 338 | + | |
| 339 | + | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
| 351 | + | |
| 352 | + | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
| 358 | + | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
| 376 | + | |
| 377 | + | |
| 378 | + | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
| 382 | + | |
| 383 | + | |
| 384 | + | |
| 385 | + | |
| 386 | + | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
| 407 | + | |
| 408 | + | |
| 409 | + | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
| 417 | + | |
| 418 | + | |
| 419 | + | |
| 420 | + | |
| 421 | + | |
| 422 | + | |
| 423 | + | |
| 424 | + | |
| 425 | + | |
| 426 | + | |
| 427 | + | |
| 428 | + | |
| 429 | + | |
| 430 | + | |
| 431 | + | |
| 432 | + | |
| 433 | + | |
| 434 | + | |
| 435 | + | |
| 436 | + | |
| 437 | + | |
| 438 | + | |
| 439 | + | |
| 440 | + | |
| 441 | + | |
0 commit comments