B.3: end-to-end LWS10-CID auth — sign-in, ES256K JWK VM, JWT signing#4
Merged
Conversation
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.
There was a problem hiding this comment.
Pull request overview
This PR adds an end-to-end strict LWS10-CID authentication client flow to the doctor web app (browser-only, no build step), enabling Solid-OIDC sign-in, adding an ES256K JsonWebKey verification method to a WebID profile, and signing LWS10-CID JWTs for a real round-trip auth test against compatible pods (paired with JSS verifier PR #398).
Changes:
- Introduces
lib/lws-cid-client.jsto derive an ES256K JWK VM from a secp256k1 private key and sign ES256K JWTs. - Adds a new UI section for Solid-OIDC sign-in, profile update (GET→merge→PUT), and “Test auth” JWT call.
- Updates docs/roadmap and adds styling for the new auth UI.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| README.md | Documents the new B.3 strict LWS10-CID auth client capability and updates roadmap status. |
| lib/lws-cid-client.js | New client crypto helpers: validate privkey, derive JWK VM (ES256K), sign compact JWS. |
| index.html | Adds the new “Strict LWS10-CID auth setup” UI (sign-in, add VM, test auth). |
| doctor.js | Wires Solid-OIDC session handling, profile update flow, and JWT auth test into the app. |
| doctor.css | Styles the new auth section and status/result UI. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+27
to
+33
| **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 (your Nostr nsec hex works — same key, different signature scheme), and the doctor PATCHes a `JsonWebKey` VM into your profile 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. | ||
|
|
||
| ## Roadmap (rough) | ||
|
|
||
| - ~~**B.0**~~ — Read-only LWS-CID profile validator ✅ | ||
| - ~~**B.2**~~ — Read pubkey from NIP-07 signer; emit Multikey verificationMethod snippet ✅ | ||
| - ~~**B.3**~~ — Strict LWS10-CID auth: Solid-OIDC sign-in, ES256K `JsonWebKey` VM PATCHed into profile, sign real JWTs to authenticate ✅ |
Comment on lines
+79
to
+83
| <p>Sign in to your pod, paste a secp256k1 private key (your Nostr nsec hex works | ||
| — same key, different signature scheme), and the doctor PATCHes a | ||
| <code>JsonWebKey</code> verificationMethod into your profile and signs a real | ||
| <a href="https://www.w3.org/TR/2026/WD-lws10-authn-ssi-cid-20260423/" target="_blank" rel="noopener">LWS10-CID</a> | ||
| JWT to authenticate. <strong>The private key never leaves your browser.</strong></p> |
| <span class="copy-status" id="copy-status" aria-live="polite"></span> | ||
|
|
||
| <p class="hint future">B.3 will close the loop with an in-app PATCH so you don't need to copy/paste.</p> | ||
| <p class="hint future">B.3 (below) closes the loop with an in-app PATCH so you don't have to copy/paste.</p> |
Comment on lines
+37
to
+40
| let lastIssuer = null; | ||
| let lastProfile = null; | ||
| let lastVmKid = null; | ||
| let memPrivKey = null; // 32-byte secp256k1 privkey, in-memory only |
Comment on lines
+359
to
+363
| if (!isActive) { | ||
| testSection.hidden = true; | ||
| patchResult.textContent = ''; | ||
| patchResult.className = 'patch-result'; | ||
| testResult.textContent = ''; |
Comment on lines
+93
to
+97
| const vm = { | ||
| id: kid, | ||
| type: 'JsonWebKey', | ||
| controller: webId, | ||
| publicKeyJwk: jwk, |
Comment on lines
+68
to
+73
| * @param {string} args.webId - WebID URI used as VM controller | ||
| * @param {string} [args.fragment='lws-key-1'] - VM fragment id | ||
| * @returns {{ vm: object, jwk: object, kid: string }} | ||
| */ | ||
| export function buildEs256kVerificationMethod({ privKey, webId, fragment = 'lws-key-1' }) { | ||
| const priv = privKey instanceof Uint8Array ? privKey : validatePrivKey(privKey); |
|
|
||
| const { vm, kid } = buildEs256kVerificationMethod({ | ||
| privKey: priv, | ||
| webId: lastWebId, |
Comment on lines
+448
to
+455
| const current = await getRes.json(); | ||
|
|
||
| const merged = mergeVerificationMethod(current, vm); | ||
| const putRes = await session.authFetch(lastDocUrl, { | ||
| method: 'PUT', | ||
| headers: { 'Content-Type': 'application/ld+json' }, | ||
| body: JSON.stringify(merged, null, 2), | ||
| }); |
| const vms = Array.isArray(out.verificationMethod) ? [...out.verificationMethod] | ||
| : out.verificationMethod ? [out.verificationMethod] | ||
| : []; | ||
| const idx = vms.findIndex((v) => (v?.id || v?.['@id']) === vm.id); |
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).
Comment on lines
+609
to
+617
| function extractIssuer(profile) { | ||
| // JSS emits oidcIssuer in compact form via the profile @context. Some | ||
| // clients use the full predicate URI or the prefixed form; support all. | ||
| const raw = profile?.oidcIssuer | ||
| ?? profile?.['solid:oidcIssuer'] | ||
| ?? profile?.['http://www.w3.org/ns/solid/terms#oidcIssuer']; | ||
| if (!raw) return null; | ||
| if (typeof raw === 'string') return raw; | ||
| if (typeof raw === 'object') return raw['@id'] || raw.id || null; |
Comment on lines
+571
to
+574
| throw new Error( | ||
| `verificationMethod ${vm.id} already exists with a different public key — ` + | ||
| `pick a new fragment (e.g. lws-key-2) or remove the existing entry first`, | ||
| ); |
|
|
||
| **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. | ||
|
|
||
| **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 (your Nostr nsec hex works — 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. |
Comment on lines
+79
to
+84
| <p>Sign in to your pod, paste a secp256k1 private key (your Nostr nsec hex works | ||
| — same key, different signature scheme), and the doctor adds a | ||
| <code>JsonWebKey</code> verificationMethod to your profile (read-modify-write over | ||
| authenticated GET + PUT) and signs a real | ||
| <a href="https://www.w3.org/TR/2026/WD-lws10-authn-ssi-cid-20260423/" target="_blank" rel="noopener">LWS10-CID</a> | ||
| JWT to authenticate. <strong>The private key never leaves your browser.</strong></p> |
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.
| // public-key material (idempotent re-run). | ||
| if (typeof existing === 'object' && existing !== null) { | ||
| const existingJwk = existing.publicKeyJwk; | ||
| if (existingJwk && sameJwk(existingJwk, probe.publicKeyJwk)) { |
Comment on lines
+387
to
+389
| // Only enable sign-in if we have an issuer to point at. | ||
| oidcSignInBtn.disabled = !lastIssuer; | ||
| if (!lastIssuer) { |
|
|
||
| - ~~**B.0**~~ — Read-only LWS-CID profile validator ✅ | ||
| - ~~**B.2**~~ — Read pubkey from NIP-07 signer; emit Multikey verificationMethod snippet ✅ | ||
| - ~~**B.3**~~ — Strict LWS10-CID auth: Solid-OIDC sign-in, ES256K `JsonWebKey` VM PATCHed into profile, sign real JWTs to authenticate ✅ |
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").
| patchResult.className = 'patch-result'; | ||
| testResult.textContent = ''; | ||
| testResult.className = 'test-result'; | ||
| testSection.hidden = true; |
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.)
Comment on lines
+112
to
+116
| * Per the FPWD §4: sub === iss === client_id (all the WebID URI), aud | ||
| * is the target server origin, exp/iat are required. Lifetime capped | ||
| * at 5 minutes — the verifier rejects > 1h, but short tokens limit | ||
| * the replay window if one leaks anyway. | ||
| * |
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).
Comment on lines
+636
to
+639
| function entryMatchesId(entry, id) { | ||
| if (typeof entry === 'string') return entry === id; | ||
| if (entry && typeof entry === 'object') return (entry.id || entry['@id']) === id; | ||
| return false; |
Comment on lines
+626
to
+631
| const auth = Array.isArray(out.authentication) ? [...out.authentication] | ||
| : out.authentication ? [out.authentication] | ||
| : []; | ||
| if (!auth.some((a) => (typeof a === 'string' ? a : a?.['@id'] || a?.id) === vm.id)) { | ||
| auth.push(vm.id); | ||
| } |
| } catch (err) { | ||
| patchResult.className = 'patch-result error'; | ||
| patchResult.textContent = `Failed: ${err.message || err}`; | ||
| memPrivKey = null; |
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.
Comment on lines
+356
to
+364
| if (!isActive) { | ||
| // Drop any pasted privkey + cached VM kid the moment the session | ||
| // ends. The UI promises sign-out clears state, and a privkey | ||
| // sitting in a tab that's no longer authenticated is just | ||
| // exposure with no purpose. | ||
| memPrivKey = null; | ||
| lastVmKid = null; | ||
| privkeyInput.value = ''; | ||
| testSection.hidden = true; |
| }); | ||
| if (!getRes.ok) throw new Error(`GET profile: HTTP ${getRes.status}`); | ||
| const etag = getRes.headers.get('etag'); | ||
| const current = await getRes.json(); |
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.
Comment on lines
+36
to
+38
| let s = ''; | ||
| for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]); | ||
| return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); |
Comment on lines
+658
to
+664
| const vms = Array.isArray(out.verificationMethod) ? [...out.verificationMethod] | ||
| : out.verificationMethod ? [out.verificationMethod] | ||
| : []; | ||
| const idx = vms.findIndex((v) => entryMatchesId(v, vm.id, baseUrl)); | ||
| if (idx >= 0) vms[idx] = vm; | ||
| else vms.push(vm); | ||
| out.verificationMethod = vms; |
Comment on lines
+666
to
+678
| const auth = Array.isArray(out.authentication) ? [...out.authentication] | ||
| : out.authentication ? [out.authentication] | ||
| : []; | ||
| // De-dupe against the absolutized form so a pre-existing relative | ||
| // entry like "#lws-key-1" is recognized as the same as the absolute | ||
| // URI we're about to push. | ||
| const exists = auth.some((a) => { | ||
| const raw = typeof a === 'string' ? a : (a?.['@id'] || a?.id); | ||
| if (typeof raw !== 'string') return false; | ||
| return absolutizeLocal(raw, baseUrl) === vm.id; | ||
| }); | ||
| if (!exists) auth.push(vm.id); | ||
| out.authentication = auth; |
This was referenced May 9, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #3. Pairs with the now-merged JSS#398 (server-side LWS10-CID JWT verifier).
After this merges, the full strict-LWS10-CID round-trip works on JSS pods: doctor signs a JWT, the pod's verifier accepts it, the user is authenticated.
Strategy
Same secp256k1 key, two signature schemes. Nostr signs Schnorr/BIP-340; the same private key trivially signs ECDSA. ES256K (RFC8812) is a registered JWS algorithm, so signing an LWS10-CID JWT with it is fully spec-conformant — no Schnorr-as-JWS gymnastics, no ECDSA tooling install, no new key material. A user's existing nsec hex doubles as their LWS-CID auth key.
What ships
lib/lws-cid-client.js— derives aJsonWebKeyVM (kty:EC, crv:secp256k1, alg:ES256K, x, y, kid) from a secp256k1 privkey; signs LWS10-CID JWTs locally via@noble/curves.solid-oidcpackage via esm.sh — zero deps on the doctor side, the package handles PKCE + DPoP + IndexedDB session persistence), privkey input (in-memory only, never persisted), "Add VM to profile" button (GET → merge → PUT), "Test auth" button (sign JWT → GET WebID withAuthorization: Bearer <jwt>→ show result).id, otherwise appends.End-to-end verified
A node round-trip script signs a JWT using the exact recipe in
lib/lws-cid-client.jsand feeds it through the JSS verifier (src/auth/lws-cid.js). Result: accepted, returns the canonical WebID. Same@noble/curvesprimitives used on both sides.Privkey UX
For this first cut the user pastes a hex privkey directly, held in memory for the tab. Encrypted-at-rest persistence (passkey-wrapped or password-derived) is deferred. NIP-07 doesn't expose ECDSA signing, so xlogin-style signers can't drive this path without a privkey export — that's a known limitation of the strict-LWS10-CID-with-Schnorr-keys story.
Test plan
Refs