From b0f2daad63117aa5232463d31b2c14e71e94f723 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Sat, 9 May 2026 16:03:59 +0200 Subject: [PATCH 1/8] =?UTF-8?q?B.3:=20end-to-end=20LWS10-CID=20auth=20?= =?UTF-8?q?=E2=80=94=20sign-in,=20ES256K=20JWK=20VM,=20JWT=20signing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` (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. --- README.md | 4 +- doctor.css | 130 +++++++++++++++++++++++ doctor.js | 236 +++++++++++++++++++++++++++++++++++++++++- index.html | 42 +++++++- lib/lws-cid-client.js | 151 +++++++++++++++++++++++++++ 5 files changed, 560 insertions(+), 3 deletions(-) create mode 100644 lib/lws-cid-client.js diff --git a/README.md b/README.md index 9c548c4..a79556f 100644 --- a/README.md +++ b/README.md @@ -24,12 +24,14 @@ Read-only — no auth, no mutations, no server roundtrip beyond the GETs. **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 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 ✅ - **B.1** — Bidirectional `alsoKnownAs` ↔ DID-doc check (resolve `did:nostr:…` and verify the DID points back at this WebID) -- **B.3** — In-app PATCH of the snippet via Solid-OIDC sign-in (closes the loop end-to-end) - **B.4** — did:key + WebAuthn passkey verification methods - **B.5** — More diagnostics: ACL inheritance, type-index integrity, OIDC discovery, ActivityPub actor doc, … diff --git a/doctor.css b/doctor.css index ae02fb5..89ade31 100644 --- a/doctor.css +++ b/doctor.css @@ -309,3 +309,133 @@ a { color: var(--accent); } .add-key pre { margin: 0; } + +/* --- B.3: strict LWS-CID auth section ----------------------------- */ + +.lws-auth { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 12px; + padding: 24px; + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04); + margin-top: 20px; +} +.lws-auth h2 { + margin: 0 0 6px; + font-size: 18px; +} +.lws-auth h3 { + margin: 18px 0 8px; + font-size: 14px; +} +.lws-auth > p { + margin: 0 0 18px; + color: var(--muted); + font-size: 13px; +} +.lws-auth p.hint { + font-size: 12px; + color: var(--muted); + margin: 0 0 8px; +} +.lws-auth code { + background: #eef2f7; + padding: 1px 5px; + border-radius: 4px; + font-size: 12px; +} + +.oidc-status { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--muted); + margin-bottom: 12px; +} +.oidc-status .dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--skip); + flex-shrink: 0; +} +.oidc-status.signed-in .dot { background: var(--pass); } +.oidc-status.error .dot { background: var(--fail); } + +.lws-auth button { + background: var(--accent); + color: #fff; + border: 0; + padding: 9px 16px; + border-radius: 8px; + font: inherit; + font-weight: 600; + cursor: pointer; +} +.lws-auth button:hover:not(:disabled) { background: #1d4ed8; } +.lws-auth button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.lws-auth button:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.lws-auth #oidc-signout { + background: #475569; + margin-left: 6px; +} +.lws-auth #oidc-signout:hover:not(:disabled) { background: #334155; } + +.lws-auth label { + display: block; + font-size: 12px; + font-weight: 600; + margin-bottom: 4px; +} +.lws-auth input[type="password"] { + width: 100%; + padding: 9px 12px; + border: 1px solid var(--border); + border-radius: 8px; + font: inherit; + font-size: 13px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, monospace; + margin-bottom: 12px; + background: #fff; +} +.lws-auth input[type="password"]:focus { + outline: none; + border-color: var(--accent); +} + +.patch-result, .test-result { + margin-top: 10px; + font-size: 12px; + padding: 8px 10px; + border-radius: 6px; + white-space: pre-wrap; + word-break: break-word; +} +.patch-result:empty, .test-result:empty { + display: none; +} +.patch-result.ok, .test-result.ok { + background: #ecfdf5; + color: #065f46; + border: 1px solid #a7f3d0; +} +.patch-result.error, .test-result.error { + background: #fef2f2; + color: #991b1b; + border: 1px solid #fecaca; +} +.patch-result.info, .test-result.info { + background: #eff6ff; + color: #1e3a8a; + border: 1px solid #bfdbfe; +} +pre.test-result { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, monospace; +} diff --git a/doctor.js b/doctor.js index 0b0fd01..bf9a316 100644 --- a/doctor.js +++ b/doctor.js @@ -10,6 +10,8 @@ import { runLwsCidChecks, normalizeControllers } from './lib/lws-cid.js'; import { buildNostrVerificationMethod } from './lib/multikey.js'; +import { buildEs256kVerificationMethod, signLwsCidJwt, validatePrivKey } from './lib/lws-cid-client.js'; +import { Session } from 'https://esm.sh/solid-oidc@0.0.8'; const form = document.getElementById('check-form'); const input = document.getElementById('webid'); @@ -32,6 +34,10 @@ const copyStatus = document.getElementById('copy-status'); let lastWebId = null; let lastDocUrl = null; let lastController = null; +let lastIssuer = null; +let lastProfile = null; +let lastVmKid = null; +let memPrivKey = null; // 32-byte secp256k1 privkey, in-memory only // Allow ?webid=… in the URL to pre-fill (handy for sharing / bookmarks). const params = new URLSearchParams(window.location.search); @@ -57,17 +63,22 @@ form.addEventListener('submit', async (e) => { // run can't be copied or have its connect button clicked while the // new diagnostics are in flight. hideAddKeySection(); + hideLwsAuthSection(); try { - const { checks, profileFetched, webId, docUrl, controller } = await runAll(url); + const { checks, profileFetched, webId, docUrl, controller, profile, issuer } = await runAll(url); renderChecks(checks); if (profileFetched && webId) { lastWebId = webId; lastDocUrl = docUrl; lastController = controller; + lastProfile = profile; + lastIssuer = issuer; revealAddKeySection(); + revealLwsAuthSection(); } else { hideAddKeySection(); + hideLwsAuthSection(); } } catch (err) { renderChecks([{ @@ -199,6 +210,8 @@ async function runAll(webIdUrl) { result.docUrl = docUrl.toString(); result.webId = canonicalWebId; result.controller = controllerIri; + result.profile = profile; + result.issuer = extractIssuer(profile); // 4. Run LWS-CID structural checks. for (const c of runLwsCidChecks(profile, { webIdUrl })) { @@ -320,6 +333,227 @@ copyButton.addEventListener('click', async () => { }, 2500); }); +// --- B.3: strict LWS-CID auth (Solid-OIDC sign-in + ES256K JWT) ---- + +const lwsAuthSection = document.getElementById('lws-auth'); +const oidcStatusEl = document.getElementById('oidc-status'); +const oidcSignInBtn = document.getElementById('oidc-signin'); +const oidcSignOutBtn = document.getElementById('oidc-signout'); +const patchSection = document.getElementById('patch-section'); +const privkeyInput = document.getElementById('privkey'); +const patchButton = document.getElementById('patch-button'); +const patchResult = document.getElementById('patch-result'); +const testSection = document.getElementById('test-section'); +const testButton = document.getElementById('test-button'); +const testResult = document.getElementById('test-result'); + +const session = new Session({ + onStateChange: (e) => { + const isActive = e?.detail?.isActive; + const webId = e?.detail?.webId; + setOidcStatus(isActive ? 'signed-in' : null, + isActive ? `Signed in as ${webId}` : 'Not signed in.'); + oidcSignInBtn.hidden = !!isActive; + oidcSignOutBtn.hidden = !isActive; + patchSection.hidden = !isActive; + if (!isActive) { + testSection.hidden = true; + patchResult.textContent = ''; + patchResult.className = 'patch-result'; + testResult.textContent = ''; + testResult.className = 'test-result'; + } + }, +}); + +// Restore any prior session (saved in IndexedDB by solid-oidc) and +// handle the redirect-back from the IdP if we just landed on one. +session.restore().catch(() => { /* no prior session — fine */ }); +session.handleRedirectFromLogin().catch((err) => { + setOidcStatus('error', `Sign-in callback failed: ${err.message || err}`); +}); + +function setOidcStatus(state, text) { + oidcStatusEl.className = `oidc-status${state ? ' ' + state : ''}`; + oidcStatusEl.querySelector('.text').textContent = text; +} + +function revealLwsAuthSection() { + lwsAuthSection.hidden = false; + // Only enable sign-in if we have an issuer to point at. + oidcSignInBtn.disabled = !lastIssuer; + if (!lastIssuer) { + setOidcStatus('error', + 'Profile declares no oidcIssuer — cannot start a Solid-OIDC sign-in.'); + } +} + +function hideLwsAuthSection() { + lwsAuthSection.hidden = true; + patchResult.textContent = ''; + patchResult.className = 'patch-result'; + testResult.textContent = ''; + testResult.className = 'test-result'; + testSection.hidden = true; + memPrivKey = null; + lastVmKid = null; +} + +oidcSignInBtn.addEventListener('click', async () => { + if (!lastIssuer) return; + try { + // Persist current target across the redirect — strip any login + // params on the way back. + const returnUrl = `${window.location.pathname}?webid=${encodeURIComponent(lastWebId)}`; + await session.login(lastIssuer, new URL(returnUrl, window.location.origin).toString()); + } catch (err) { + setOidcStatus('error', `Could not start sign-in: ${err.message || err}`); + } +}); + +oidcSignOutBtn.addEventListener('click', async () => { + try { + await session.logout(); + } catch (err) { + setOidcStatus('error', `Sign-out failed: ${err.message || err}`); + } +}); + +patchButton.addEventListener('click', async () => { + patchResult.className = 'patch-result info'; + patchResult.textContent = 'Working…'; + try { + if (!session.isActive) throw new Error('not signed in'); + if (!session.webId) throw new Error('signed-in session has no webId'); + if (session.webId !== lastWebId) { + throw new Error( + `signed-in WebID (${session.webId}) doesn't match the diagnosed one (${lastWebId})`); + } + const priv = validatePrivKey(privkeyInput.value); + memPrivKey = priv; + + const { vm, kid } = buildEs256kVerificationMethod({ + privKey: priv, + webId: lastWebId, + }); + lastVmKid = kid; + + // Read-modify-write: GET via authFetch (so we see the + // authoritative current state, including any private triples), + // merge our VM into verificationMethod / authentication, PUT back. + const getRes = await session.authFetch(lastDocUrl, { + headers: { Accept: 'application/ld+json' }, + }); + if (!getRes.ok) throw new Error(`GET profile: HTTP ${getRes.status}`); + 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), + }); + if (!putRes.ok) throw new Error(`PUT profile: HTTP ${putRes.status}`); + + patchResult.className = 'patch-result ok'; + patchResult.textContent = + `Added ${kid} to verificationMethod and authentication.\n` + + `Profile updated. You can now test LWS-CID auth below.`; + testSection.hidden = false; + privkeyInput.value = ''; + } catch (err) { + patchResult.className = 'patch-result error'; + patchResult.textContent = `Failed: ${err.message || err}`; + memPrivKey = null; + } +}); + +testButton.addEventListener('click', async () => { + testResult.className = 'test-result info'; + testResult.textContent = 'Signing JWT and calling pod…'; + try { + if (!memPrivKey) throw new Error('no privkey in memory — re-run the PATCH step'); + if (!lastVmKid) throw new Error('no VM id captured — re-run the PATCH step'); + + const audience = new URL(lastDocUrl).origin; + const jwt = await signLwsCidJwt({ + privKey: memPrivKey, + kid: lastVmKid, + webId: lastWebId, + audience, + }); + + // Hit the WebID's own resource. The doctor's plain `fetch` (NOT + // session.authFetch) so the only auth on the wire is the JWT we + // just minted — that's what we want to test. + const res = await fetch(lastDocUrl, { + headers: { + Accept: 'application/ld+json', + Authorization: `Bearer ${jwt}`, + }, + }); + + const wacAllow = res.headers.get('wac-allow') || '(none)'; + const summary = [ + `Status: ${res.status} ${res.statusText}`, + `WAC-Allow: ${wacAllow}`, + '', + `JWT (truncated): ${jwt.slice(0, 80)}…`, + ].join('\n'); + + if (res.ok) { + testResult.className = 'test-result ok'; + testResult.textContent = `LWS10-CID auth round-trip OK!\n\n${summary}`; + } else { + // Even on 4xx the response can carry useful diagnostics in the body. + const body = await res.text().catch(() => ''); + testResult.className = 'test-result error'; + testResult.textContent = + `Pod rejected the JWT.\n\n${summary}\n\nResponse body:\n${body.slice(0, 500)}`; + } + } catch (err) { + testResult.className = 'test-result error'; + testResult.textContent = `Failed: ${err.message || err}`; + } +}); + +/** + * Merge a verificationMethod entry into a profile, idempotently. + * Replaces an existing entry with the same `id`, otherwise appends. + * Also adds the entry's id to `authentication` if not already there. + */ +function mergeVerificationMethod(profile, vm) { + const out = { ...profile }; + const vms = Array.isArray(out.verificationMethod) ? [...out.verificationMethod] + : out.verificationMethod ? [out.verificationMethod] + : []; + const idx = vms.findIndex((v) => (v?.id || v?.['@id']) === vm.id); + 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] + : []; + if (!auth.some((a) => (typeof a === 'string' ? a : a?.['@id'] || a?.id) === vm.id)) { + auth.push(vm.id); + } + out.authentication = auth; + return out; +} + +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; + return null; +} + function renderChecks(checks) { checksEl.innerHTML = ''; for (const c of checks) { diff --git a/index.html b/index.html index 2e9c75f..439a9df 100644 --- a/index.html +++ b/index.html @@ -70,7 +70,47 @@

Patch to apply

-

B.3 will close the loop with an in-app PATCH so you don't need to copy/paste.

+

B.3 (below) closes the loop with an in-app PATCH so you don't have to copy/paste.

+ + + + diff --git a/lib/lws-cid-client.js b/lib/lws-cid-client.js new file mode 100644 index 0000000..ae08995 --- /dev/null +++ b/lib/lws-cid-client.js @@ -0,0 +1,151 @@ +/** + * Client-side helpers for LWS10-CID authentication with secp256k1. + * + * Two responsibilities: + * + * 1. Derive a `JsonWebKey` verificationMethod from a secp256k1 private + * key (the same curve Nostr uses) so it can be PATCHed into the + * user's WebID profile. + * + * 2. Sign LWS10-CID JWTs locally — `alg: ES256K` (RFC8812). The + * signed JWT is sent as `Authorization: Bearer ` and the + * pod's verifier looks up the VM by `kid`. + * + * Same private key, two signature schemes: Schnorr/BIP-340 for Nostr + * (NIP-98 etc.), ECDSA/secp256k1 for LWS-CID. We use noble's secp256k1 + * primitives via esm.sh. + */ + +import { secp256k1 } from 'https://esm.sh/@noble/curves@1.6.0/secp256k1'; +import { sha256 } from 'https://esm.sh/@noble/hashes@1.5.0/sha2'; + +// --- helpers --------------------------------------------------------- + +function hexToBytes(hex) { + const clean = hex.trim().toLowerCase().replace(/^0x/, ''); + if (!/^[0-9a-f]+$/.test(clean) || clean.length % 2) { + throw new Error('not a hex string'); + } + const out = new Uint8Array(clean.length / 2); + for (let i = 0; i < out.length; i++) out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16); + return out; +} + +function b64u(bytes) { + // base64url without padding + let s = ''; + for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]); + return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function utf8(s) { return new TextEncoder().encode(s); } + +// --- key material ---------------------------------------------------- + +/** + * Validate a hex secp256k1 private key (32 bytes / 64 hex chars). + * Returns the bytes if valid, throws otherwise. + * + * Note: Nostr's BIP-340 spec uses the same 32-byte secp256k1 secret — + * the same nsec hex pasted here is the same key Nostr signs with. + */ +export function validatePrivKey(hex) { + const bytes = hexToBytes(hex); + if (bytes.length !== 32) { + throw new Error(`secp256k1 private key must be 32 bytes (64 hex chars); got ${bytes.length}`); + } + // secp256k1 requires the privkey to be in [1, n-1]. noble validates on + // first use; trigger that here so we surface a clear error early. + secp256k1.getPublicKey(bytes, /*compressed*/false); + return bytes; +} + +/** + * Build a `JsonWebKey` verificationMethod for ES256K from a privkey. + * + * @param {object} args + * @param {Uint8Array|string} args.privKey - 32-byte privkey (Uint8Array or hex string) + * @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); + // Uncompressed 65-byte point: 0x04 || x(32) || y(32) + const pubFull = secp256k1.getPublicKey(priv, /*compressed*/false); + if (pubFull.length !== 65 || pubFull[0] !== 0x04) { + throw new Error('unexpected public key encoding'); + } + const x = pubFull.slice(1, 33); + const y = pubFull.slice(33, 65); + + const docUrl = stripHash(webId); + const kid = `${docUrl}#${fragment}`; + + const jwk = { + kty: 'EC', + crv: 'secp256k1', + alg: 'ES256K', + x: b64u(x), + y: b64u(y), + kid, + }; + const vm = { + id: kid, + type: 'JsonWebKey', + controller: webId, + publicKeyJwk: jwk, + }; + return { vm, jwk, kid }; +} + +// --- JWT signing ----------------------------------------------------- + +/** + * Sign an LWS10-CID JWT. + * + * 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. + * + * @param {object} args + * @param {Uint8Array|string} args.privKey + * @param {string} args.kid - JsonWebKey VM id (fragment URI) + * @param {string} args.webId - subject WebID + * @param {string} args.audience - target server origin (e.g. https://pod.example) + * @param {number} [args.lifetimeSec=300] + * @returns {Promise} compact JWS + */ +export async function signLwsCidJwt({ privKey, kid, webId, audience, lifetimeSec = 300 }) { + const priv = privKey instanceof Uint8Array ? privKey : validatePrivKey(privKey); + const now = Math.floor(Date.now() / 1000); + const header = { alg: 'ES256K', typ: 'JWT', kid }; + const payload = { + sub: webId, + iss: webId, + client_id: webId, + aud: [audience], + iat: now, + exp: now + lifetimeSec, + }; + const h64 = b64u(utf8(JSON.stringify(header))); + const p64 = b64u(utf8(JSON.stringify(payload))); + const signingInput = utf8(`${h64}.${p64}`); + const msgHash = sha256(signingInput); + const sig = secp256k1.sign(msgHash, priv); + // Compact 64-byte r||s — what JWS expects for ES256K. + const sigBytes = sig.toCompactRawBytes(); + return `${h64}.${p64}.${b64u(sigBytes)}`; +} + +function stripHash(u) { + if (typeof u !== 'string') return u; + try { + const url = new URL(u); + url.hash = ''; + return url.toString(); + } catch { + return u.split('#')[0]; + } +} From e43a4af3a789497330777214e86d00d3e3aef518 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Sat, 9 May 2026 16:17:53 +0200 Subject: [PATCH 2/8] Address copilot pass 1 on #4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- README.md | 2 +- doctor.js | 86 +++++++++++++++++++++++++++++++++++++------ index.html | 11 ++++-- lib/lws-cid-client.js | 11 ++++-- 4 files changed, 92 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index a79556f..6b4f15f 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Read-only — no auth, no mutations, no server roundtrip beyond the GETs. **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 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. +**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. ## Roadmap (rough) diff --git a/doctor.js b/doctor.js index bf9a316..6a0a226 100644 --- a/doctor.js +++ b/doctor.js @@ -35,7 +35,6 @@ let lastWebId = null; let lastDocUrl = null; let lastController = null; let lastIssuer = null; -let lastProfile = null; let lastVmKid = null; let memPrivKey = null; // 32-byte secp256k1 privkey, in-memory only @@ -66,13 +65,12 @@ form.addEventListener('submit', async (e) => { hideLwsAuthSection(); try { - const { checks, profileFetched, webId, docUrl, controller, profile, issuer } = await runAll(url); + const { checks, profileFetched, webId, docUrl, controller, issuer } = await runAll(url); renderChecks(checks); if (profileFetched && webId) { lastWebId = webId; lastDocUrl = docUrl; lastController = controller; - lastProfile = profile; lastIssuer = issuer; revealAddKeySection(); revealLwsAuthSection(); @@ -210,7 +208,6 @@ async function runAll(webIdUrl) { result.docUrl = docUrl.toString(); result.webId = canonicalWebId; result.controller = controllerIri; - result.profile = profile; result.issuer = extractIssuer(profile); // 4. Run LWS-CID structural checks. @@ -357,6 +354,13 @@ const session = new Session({ oidcSignOutBtn.hidden = !isActive; patchSection.hidden = !isActive; 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; patchResult.textContent = ''; patchResult.className = 'patch-result'; @@ -435,6 +439,10 @@ patchButton.addEventListener('click', async () => { const { vm, kid } = buildEs256kVerificationMethod({ privKey: priv, webId: lastWebId, + // For delegated-control profiles use the diagnosed controller, + // not the WebID — otherwise the VM's controller will mismatch + // the profile's outer controller predicate and verifiers reject. + controller: lastController ?? lastWebId, }); lastVmKid = kid; @@ -445,14 +453,27 @@ patchButton.addEventListener('click', async () => { headers: { Accept: 'application/ld+json' }, }); if (!getRes.ok) throw new Error(`GET profile: HTTP ${getRes.status}`); + const etag = getRes.headers.get('etag'); const current = await getRes.json(); const merged = mergeVerificationMethod(current, vm); + + const putHeaders = { 'Content-Type': 'application/ld+json' }; + // Use If-Match to defeat lost-update on concurrent edits. JSS + // returns ETags on profile resources; servers without ETag support + // fall through with no header. + if (etag) putHeaders['If-Match'] = etag; + const putRes = await session.authFetch(lastDocUrl, { method: 'PUT', - headers: { 'Content-Type': 'application/ld+json' }, + headers: putHeaders, body: JSON.stringify(merged, null, 2), }); + if (putRes.status === 412 || putRes.status === 409) { + throw new Error( + `profile changed since GET (HTTP ${putRes.status}). Re-run diagnostics and try again.`, + ); + } if (!putRes.ok) throw new Error(`PUT profile: HTTP ${putRes.status}`); patchResult.className = 'patch-result ok'; @@ -518,18 +539,45 @@ testButton.addEventListener('click', async () => { }); /** - * Merge a verificationMethod entry into a profile, idempotently. - * Replaces an existing entry with the same `id`, otherwise appends. - * Also adds the entry's id to `authentication` if not already there. + * Merge a verificationMethod entry into a profile. + * + * Idempotent on re-runs of the SAME key: replaces the existing entry + * (same id, same publicKeyJwk) so we don't grow duplicates. + * + * Refuses to clobber a different key sitting at the same fragment — + * an existing VM with the same id but DIFFERENT publicKeyJwk throws. + * The user can pick another fragment if they want to keep both keys + * (key rotation should happen at a fresh fragment, e.g. `lws-key-2`, + * with the old one removed from `authentication` once rotation is + * complete). + * + * Handles string-IRI verificationMethod entries (which JSON-LD + * permits) — finds them by IRI equality so the entry isn't duplicated. */ function mergeVerificationMethod(profile, vm) { const out = { ...profile }; const vms = Array.isArray(out.verificationMethod) ? [...out.verificationMethod] : out.verificationMethod ? [out.verificationMethod] : []; - const idx = vms.findIndex((v) => (v?.id || v?.['@id']) === vm.id); - if (idx >= 0) vms[idx] = vm; - else vms.push(vm); + const idx = vms.findIndex((v) => entryMatchesId(v, vm.id)); + if (idx >= 0) { + const existing = vms[idx]; + // String-IRI entries don't carry inline material — replacing + // them with our embedded VM is fine. For embedded entries with a + // different publicKeyJwk, refuse to overwrite. + if (typeof existing === 'object' && existing !== null) { + const existingJwk = existing.publicKeyJwk; + if (existingJwk && !sameJwk(existingJwk, vm.publicKeyJwk)) { + 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`, + ); + } + } + vms[idx] = vm; + } else { + vms.push(vm); + } out.verificationMethod = vms; const auth = Array.isArray(out.authentication) ? [...out.authentication] @@ -542,6 +590,22 @@ function mergeVerificationMethod(profile, vm) { return out; } +function entryMatchesId(entry, id) { + if (typeof entry === 'string') return entry === id; + if (entry && typeof entry === 'object') return (entry.id || entry['@id']) === id; + return false; +} + +function sameJwk(a, b) { + // Compare the public-key material, not auxiliary fields like `kid`, + // `alg`, or `use`. Two VMs are "the same key" iff x and y match. + return a && b + && a.kty === b.kty + && a.crv === b.crv + && a.x === b.x + && a.y === b.y; +} + 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. diff --git a/index.html b/index.html index 439a9df..4af8f87 100644 --- a/index.html +++ b/index.html @@ -70,15 +70,16 @@

Patch to apply

-

B.3 (below) closes the loop with an in-app PATCH so you don't have to copy/paste.

+

For end-to-end auth without copy/paste, see "Strict LWS10-CID auth setup" below — it signs you in, writes the VM, and tests authentication for you.