diff --git a/README.md b/README.md index 9c548c4..57ed335 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 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. + ## 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 written into profile (GET → merge → PUT with `If-Match`), 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..8e44276 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,24 @@ const copyStatus = document.getElementById('copy-status'); let lastWebId = null; let lastDocUrl = null; let lastController = null; +let lastIssuer = null; +let lastVmKid = null; +let memPrivKey = null; // 32-byte secp256k1 privkey, in-memory only + +/** + * Zero out + drop the in-memory privkey. JS gives us no real memory + * clearing — once we release a Uint8Array reference, the previous + * bytes hang around in the heap until GC. But we own this buffer, so + * overwriting in place at least removes the secret from anything we + * still hold, which is meaningful for a multi-step UI flow where the + * key sits around between PATCH and Test. + */ +function clearMemPrivKey() { + if (memPrivKey) { + try { memPrivKey.fill(0); } catch { /* not a Uint8Array — fine */ } + } + memPrivKey = null; +} // Allow ?webid=… in the URL to pre-fill (handy for sharing / bookmarks). const params = new URLSearchParams(window.location.search); @@ -57,17 +77,21 @@ 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, issuer } = await runAll(url); renderChecks(checks); if (profileFetched && webId) { lastWebId = webId; lastDocUrl = docUrl; lastController = controller; + lastIssuer = issuer; revealAddKeySection(); + revealLwsAuthSection(); } else { hideAddKeySection(); + hideLwsAuthSection(); } } catch (err) { renderChecks([{ @@ -199,6 +223,7 @@ async function runAll(webIdUrl) { result.docUrl = docUrl.toString(); result.webId = canonicalWebId; result.controller = controllerIri; + result.issuer = extractIssuer(profile); // 4. Run LWS-CID structural checks. for (const c of runLwsCidChecks(profile, { webIdUrl })) { @@ -320,6 +345,399 @@ 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) { + // 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. + clearMemPrivKey(); + lastVmKid = null; + privkeyInput.value = ''; + 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 a session was restored from IndexedDB the user is already + // authenticated — don't clobber the signed-in status with a + // pre-login warning. The "no issuer" message only matters before + // we have a session. + if (!lastIssuer && !session.isActive) { + 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; + clearMemPrivKey(); + lastVmKid = null; + // Clear the input element too — the in-memory privkey is gone but a + // pasted value would otherwise persist in the DOM across diagnostic + // re-runs / WebID switches and could be accidentally reused on a + // different identity. + privkeyInput.value = ''; +} + +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; + + // 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 etag = getRes.headers.get('etag'); + // Validate Content-Type before JSON.parse so a Turtle (or HTML + // error page) response gives a clear actionable message rather + // than a generic "Unexpected token in JSON" SyntaxError. + const ct = (getRes.headers.get('content-type') || '').toLowerCase(); + const body = await getRes.text(); + if (!ct.includes('json')) { + throw new Error( + `profile GET returned Content-Type "${ct || '(none)'}" — expected JSON-LD. ` + + `First bytes: ${body.slice(0, 80).replace(/\s+/g, ' ')}`, + ); + } + let current; + try { + current = JSON.parse(body); + } catch (err) { + throw new Error(`profile body is not valid JSON: ${err.message}`); + } + + // Pick a fragment that's either unused or already holds the same + // key (idempotent re-run). Re-running with a different key won't + // silently clobber an existing one — we walk lws-key-N until we + // find a free or matching slot. + const { fragment, vm, kid } = chooseFragmentAndBuildVm({ + privKey: priv, + profile: current, + webId: lastWebId, + controller: lastController ?? lastWebId, + }); + lastVmKid = kid; + + 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: 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'; + patchResult.textContent = + `Added ${kid} to verificationMethod and authentication ` + + `(fragment chosen: #${fragment}).\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}`; + // Reset everything that depended on the patch having succeeded. + // Otherwise a UI that already showed the test section from a + // prior successful run would still claim "ready to test" with a + // stale kid against a now-uncertain server state. + clearMemPrivKey(); + lastVmKid = null; + testSection.hidden = true; + testResult.textContent = ''; + testResult.className = 'test-result'; + } +}); + +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}`; + } +}); + +/** + * Choose a non-colliding fragment for the new VM, then build it. + * + * - If `lws-key-1` is unused, take it. + * - If `lws-key-1` already holds *the same* public key (re-run), take + * it — the merge will be a no-op replace. + * - Otherwise walk lws-key-2, lws-key-3, … until we find an unused + * slot or one that already matches. Cap at 99 to bound work; if a + * user has somehow accumulated 99 distinct VMs they should clean + * up first. + */ +function chooseFragmentAndBuildVm({ privKey, profile, webId, controller }) { + const docUrl = stripHashLocal(webId); + const vms = Array.isArray(profile.verificationMethod) ? profile.verificationMethod + : profile.verificationMethod ? [profile.verificationMethod] + : []; + + // Pre-build a VM once so we can compare its JWK against existing + // entries. The fragment will be re-stamped on the chosen one below. + // (buildEs256kVerificationMethod returns { vm, jwk, kid } — the JWK + // sits on .jwk, not .publicKeyJwk.) + const probe = buildEs256kVerificationMethod({ privKey, webId, controller, fragment: 'probe' }); + const probeJwk = probe.jwk; + + for (let n = 1; n <= 99; n++) { + const candidateId = `${docUrl}#lws-key-${n}`; + const existing = vms.find((v) => entryMatchesId(v, candidateId, docUrl)); + if (!existing) { + const result = buildEs256kVerificationMethod({ + privKey, webId, controller, fragment: `lws-key-${n}`, + }); + return { fragment: `lws-key-${n}`, vm: result.vm, kid: result.kid }; + } + // Slot taken — only re-use if the existing entry has the SAME + // public-key material (idempotent re-run). + if (typeof existing === 'object' && existing !== null) { + const existingJwk = existing.publicKeyJwk; + if (existingJwk && sameJwk(existingJwk, probeJwk)) { + const result = buildEs256kVerificationMethod({ + privKey, webId, controller, fragment: `lws-key-${n}`, + }); + return { fragment: `lws-key-${n}`, vm: result.vm, kid: result.kid }; + } + // Different key here — try the next slot. + } else { + // String-IRI entry takes the slot but carries no key material. + // We can't tell whether it's "ours" or someone else's. Skip to + // the next slot to be safe. + } + } + throw new Error('all lws-key-1..99 fragments are taken — clean up your profile first'); +} + +/** + * Merge a verificationMethod entry into a profile. + * + * Idempotent on re-runs: when the entry's id matches an existing VM, + * replaces it. (chooseFragmentAndBuildVm guarantees same-id ⇒ same-key + * before we get here, so this can't silently clobber.) + * + * 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 baseUrl = stripHashLocal(vm.id); + 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; + return out; +} + +function entryMatchesId(entry, id, baseUrl) { + // Handle relative IRIs the way JSON-LD does: resolve against the + // document URL before comparing. This lets us recognize existing + // entries written as "#lws-key-1" as equivalent to the absolute + // form we generate. + const resolve = (s) => (typeof s === 'string' ? absolutizeLocal(s, baseUrl) : s); + if (typeof entry === 'string') return resolve(entry) === resolve(id); + if (entry && typeof entry === 'object') { + const raw = entry.id || entry['@id']; + return typeof raw === 'string' && resolve(raw) === resolve(id); + } + return false; +} + +function absolutizeLocal(u, base) { + if (!u) return u; + try { return new URL(u, base).toString(); } catch { return u; } +} + +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 stripHashLocal(u) { + try { + const url = new URL(u); + url.hash = ''; + return url.toString(); + } catch { + return String(u).split('#')[0]; + } +} + +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. The value can also be an array (JSON-LD permits it for any + // predicate) — take the first usable entry. + const raw = profile?.oidcIssuer + ?? profile?.['solid:oidcIssuer'] + ?? profile?.['http://www.w3.org/ns/solid/terms#oidcIssuer']; + if (raw === null || raw === undefined) return null; + const list = Array.isArray(raw) ? raw : [raw]; + for (const v of list) { + if (typeof v === 'string') return v; + if (v && typeof v === 'object') { + const id = v['@id'] || v.id; + if (typeof id === 'string') return id; + } + } + return null; +} + function renderChecks(checks) { checksEl.innerHTML = ''; for (const c of checks) { diff --git a/index.html b/index.html index 2e9c75f..72a2f29 100644 --- a/index.html +++ b/index.html @@ -70,7 +70,54 @@
B.3 will close the loop with an in-app PATCH so you don't need 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.
+ + + +Sign in to your pod, 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 verificationMethod to your profile (read-modify-write over
+ authenticated GET + PUT) and signs a real
+ LWS10-CID
+ JWT to authenticate. The private key never leaves your browser.
Paste your secp256k1 private key as 64 hex chars (the raw 32-byte
+ key, not a bech32 nsec1…). Same key used for Nostr signing — only
+ the signature scheme differs (ECDSA here vs Schnorr for Nostr events). The key
+ is held in memory for this tab only; it is never persisted.
Implementation: authenticated GET → merge VM → PUT (with
+ If-Match when the server provides an ETag). True
+ PATCH isn't used because JSS's JSON-LD ↔ Turtle conneg
+ layer has known edge cases on patch round-trips.
Builds an LWS10-CID JWT signed with your privkey, hits your pod
+ with Authorization: Bearer <jwt>, and reports the verifier's
+ response.