Skip to content

Commit b0450d7

Browse files
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

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,14 @@ Read-only — no auth, no mutations, no server roundtrip beyond the GETs.
2424

2525
**2. Nostr verification-method generator** — reads your Nostr pubkey from a [NIP-07](https://github.com/nostr-protocol/nips/blob/master/07.md) signer (e.g. [xlogin](https://xlogin.solid.social/)), encodes it per [did:nostr](https://nostrcg.github.io/did-nostr/)'s Multikey recipe, and emits a copyable JSON snippet to add to your profile. No keys leave your browser.
2626

27+
**3. Strict [LWS10-CID](https://www.w3.org/TR/2026/WD-lws10-authn-ssi-cid-20260423/) auth client** — sign in to your pod via Solid-OIDC (using the [`solid-oidc`](https://www.npmjs.com/package/solid-oidc) package), paste a secp256k1 private key as 64 hex chars (the raw 32-byte key behind your Nostr `nsec1…` bech32 — same key, different signature scheme), and the doctor adds a `JsonWebKey` VM to your profile (read-modify-write via authenticated GET + PUT) and signs an LWS10-CID JWT with `alg: ES256K` to authenticate end-to-end. Pairs with the [JSS server-side verifier](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/pull/398). Privkey is held in memory for the tab only.
28+
2729
## Roadmap (rough)
2830

2931
- ~~**B.0**~~ — Read-only LWS-CID profile validator ✅
3032
- ~~**B.2**~~ — Read pubkey from NIP-07 signer; emit Multikey verificationMethod snippet ✅
33+
- ~~**B.3**~~ — Strict LWS10-CID auth: Solid-OIDC sign-in, ES256K `JsonWebKey` VM written into profile (GET → merge → PUT with `If-Match`), sign real JWTs to authenticate ✅
3134
- **B.1** — Bidirectional `alsoKnownAs` ↔ DID-doc check (resolve `did:nostr:…` and verify the DID points back at this WebID)
32-
- **B.3** — In-app PATCH of the snippet via Solid-OIDC sign-in (closes the loop end-to-end)
3335
- **B.4** — did:key + WebAuthn passkey verification methods
3436
- **B.5** — More diagnostics: ACL inheritance, type-index integrity, OIDC discovery, ActivityPub actor doc, …
3537

doctor.css

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,3 +309,133 @@ a { color: var(--accent); }
309309
.add-key pre {
310310
margin: 0;
311311
}
312+
313+
/* --- B.3: strict LWS-CID auth section ----------------------------- */
314+
315+
.lws-auth {
316+
background: var(--panel);
317+
border: 1px solid var(--border);
318+
border-radius: 12px;
319+
padding: 24px;
320+
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04);
321+
margin-top: 20px;
322+
}
323+
.lws-auth h2 {
324+
margin: 0 0 6px;
325+
font-size: 18px;
326+
}
327+
.lws-auth h3 {
328+
margin: 18px 0 8px;
329+
font-size: 14px;
330+
}
331+
.lws-auth > p {
332+
margin: 0 0 18px;
333+
color: var(--muted);
334+
font-size: 13px;
335+
}
336+
.lws-auth p.hint {
337+
font-size: 12px;
338+
color: var(--muted);
339+
margin: 0 0 8px;
340+
}
341+
.lws-auth code {
342+
background: #eef2f7;
343+
padding: 1px 5px;
344+
border-radius: 4px;
345+
font-size: 12px;
346+
}
347+
348+
.oidc-status {
349+
display: flex;
350+
align-items: center;
351+
gap: 8px;
352+
font-size: 13px;
353+
color: var(--muted);
354+
margin-bottom: 12px;
355+
}
356+
.oidc-status .dot {
357+
width: 10px;
358+
height: 10px;
359+
border-radius: 50%;
360+
background: var(--skip);
361+
flex-shrink: 0;
362+
}
363+
.oidc-status.signed-in .dot { background: var(--pass); }
364+
.oidc-status.error .dot { background: var(--fail); }
365+
366+
.lws-auth button {
367+
background: var(--accent);
368+
color: #fff;
369+
border: 0;
370+
padding: 9px 16px;
371+
border-radius: 8px;
372+
font: inherit;
373+
font-weight: 600;
374+
cursor: pointer;
375+
}
376+
.lws-auth button:hover:not(:disabled) { background: #1d4ed8; }
377+
.lws-auth button:focus-visible {
378+
outline: 2px solid var(--accent);
379+
outline-offset: 2px;
380+
}
381+
.lws-auth button:disabled {
382+
opacity: 0.5;
383+
cursor: not-allowed;
384+
}
385+
.lws-auth #oidc-signout {
386+
background: #475569;
387+
margin-left: 6px;
388+
}
389+
.lws-auth #oidc-signout:hover:not(:disabled) { background: #334155; }
390+
391+
.lws-auth label {
392+
display: block;
393+
font-size: 12px;
394+
font-weight: 600;
395+
margin-bottom: 4px;
396+
}
397+
.lws-auth input[type="password"] {
398+
width: 100%;
399+
padding: 9px 12px;
400+
border: 1px solid var(--border);
401+
border-radius: 8px;
402+
font: inherit;
403+
font-size: 13px;
404+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, monospace;
405+
margin-bottom: 12px;
406+
background: #fff;
407+
}
408+
.lws-auth input[type="password"]:focus {
409+
outline: none;
410+
border-color: var(--accent);
411+
}
412+
413+
.patch-result, .test-result {
414+
margin-top: 10px;
415+
font-size: 12px;
416+
padding: 8px 10px;
417+
border-radius: 6px;
418+
white-space: pre-wrap;
419+
word-break: break-word;
420+
}
421+
.patch-result:empty, .test-result:empty {
422+
display: none;
423+
}
424+
.patch-result.ok, .test-result.ok {
425+
background: #ecfdf5;
426+
color: #065f46;
427+
border: 1px solid #a7f3d0;
428+
}
429+
.patch-result.error, .test-result.error {
430+
background: #fef2f2;
431+
color: #991b1b;
432+
border: 1px solid #fecaca;
433+
}
434+
.patch-result.info, .test-result.info {
435+
background: #eff6ff;
436+
color: #1e3a8a;
437+
border: 1px solid #bfdbfe;
438+
}
439+
pre.test-result {
440+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, monospace;
441+
}

0 commit comments

Comments
 (0)