| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
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.
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:
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.
Sorry, something went wrong.
| **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 ✅ |
| <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> |
| let lastIssuer = null; | ||
| let lastProfile = null; | ||
| let lastVmKid = null; | ||
| let memPrivKey = null; // 32-byte secp256k1 privkey, in-memory only |
| if (!isActive) { | ||
| testSection.hidden = true; | ||
| patchResult.textContent = ''; | ||
| patchResult.className = 'patch-result'; | ||
| testResult.textContent = ''; |
| const vm = { | ||
| id: kid, | ||
| type: 'JsonWebKey', | ||
| controller: webId, | ||
| publicKeyJwk: jwk, |
| * @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, |
| 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).
| 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; |
| 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. |
| <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)) { |
| // 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.)
| * 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).
| function entryMatchesId(entry, id) { | ||
| if (typeof entry === 'string') return entry === id; | ||
| if (entry && typeof entry === 'object') return (entry.id || entry['@id']) === id; | ||
| return false; |
| 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.
| 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.
| let s = ''; | ||
| for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]); | ||
| return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); |
| 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; |
| 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; |
| Back | FazBrowse Home | New Git URL |
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
End-to-end verified
A node round-trip script signs a JWT using the exact recipe in lib/lws-cid-client.js and feeds it through the JSS verifier (src/auth/lws-cid.js). Result: accepted, returns the canonical WebID. Same @noble/curves primitives 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