Skip to content

Commit e43a4af

Browse files
Address copilot pass 1 on #4
Ten findings, all real. Five for behavior, four for wording, one cleanup. Behavior: 1. VM controller hard-coded to webId. The builder now accepts an explicit `controller` (defaulting to webId for the common self-controlled case); the doctor passes `lastController` from diagnostics so delegated-control profiles produce VMs that match the profile's outer controller predicate. Verified end-to-end against the JSS verifier in self-controlled, delegated-controlled, and mismatched scenarios. 2. memPrivKey + lastVmKid weren't cleared on sign-out. The UI promised sign-out clears state, and a privkey sitting in a tab that's no longer authenticated is just exposure with no purpose. Now nulled (and the input field cleared) when the session goes inactive. 3. Read-modify-write PUT had no concurrency control. Now captures ETag from the GET and sends it via If-Match on the PUT, with a clear "profile changed since GET" error on 412/409. 4. mergeVerificationMethod only matched object entries, missing string-IRI entries. JSON-LD permits VMs to be referenced by IRI string, so an existing string entry could leave a duplicate when merged. Now matches both forms via entryMatchesId. 5. Idempotent merge could silently clobber a different key sitting at the same fragment. Now compares publicKeyJwk material (kty, crv, x, y) and refuses to overwrite if the existing key differs, pointing the user to a fresh fragment. Wording (README/UI said "PATCH" but code does GET+PUT): 6. README's B.3 description. 7. lws-auth section's intro. 8. The "future" hint in the B.2 section is now stale — replaced with a pointer to B.3. Cleanup: 9. lastProfile was assigned but never read — dropped. 10. New patch-section hint clarifies why we PUT instead of PATCH (JSS conneg-layer edge cases on patch round-trips).
1 parent b0f2daa commit e43a4af

4 files changed

Lines changed: 92 additions & 18 deletions

File tree

README.md

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

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

27-
**3. Strict [LWS10-CID](https://www.w3.org/TR/2026/WD-lws10-authn-ssi-cid-20260423/) auth client** — sign in to your pod via Solid-OIDC (using the [`solid-oidc`](https://www.npmjs.com/package/solid-oidc) package), paste a secp256k1 private key (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.
27+
**3. Strict [LWS10-CID](https://www.w3.org/TR/2026/WD-lws10-authn-ssi-cid-20260423/) auth client** — sign in to your pod via Solid-OIDC (using the [`solid-oidc`](https://www.npmjs.com/package/solid-oidc) package), paste a secp256k1 private key (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.
2828

2929
## Roadmap (rough)
3030

doctor.js

Lines changed: 75 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ let lastWebId = null;
3535
let lastDocUrl = null;
3636
let lastController = null;
3737
let lastIssuer = null;
38-
let lastProfile = null;
3938
let lastVmKid = null;
4039
let memPrivKey = null; // 32-byte secp256k1 privkey, in-memory only
4140

@@ -66,13 +65,12 @@ form.addEventListener('submit', async (e) => {
6665
hideLwsAuthSection();
6766

6867
try {
69-
const { checks, profileFetched, webId, docUrl, controller, profile, issuer } = await runAll(url);
68+
const { checks, profileFetched, webId, docUrl, controller, issuer } = await runAll(url);
7069
renderChecks(checks);
7170
if (profileFetched && webId) {
7271
lastWebId = webId;
7372
lastDocUrl = docUrl;
7473
lastController = controller;
75-
lastProfile = profile;
7674
lastIssuer = issuer;
7775
revealAddKeySection();
7876
revealLwsAuthSection();
@@ -210,7 +208,6 @@ async function runAll(webIdUrl) {
210208
result.docUrl = docUrl.toString();
211209
result.webId = canonicalWebId;
212210
result.controller = controllerIri;
213-
result.profile = profile;
214211
result.issuer = extractIssuer(profile);
215212

216213
// 4. Run LWS-CID structural checks.
@@ -357,6 +354,13 @@ const session = new Session({
357354
oidcSignOutBtn.hidden = !isActive;
358355
patchSection.hidden = !isActive;
359356
if (!isActive) {
357+
// Drop any pasted privkey + cached VM kid the moment the session
358+
// ends. The UI promises sign-out clears state, and a privkey
359+
// sitting in a tab that's no longer authenticated is just
360+
// exposure with no purpose.
361+
memPrivKey = null;
362+
lastVmKid = null;
363+
privkeyInput.value = '';
360364
testSection.hidden = true;
361365
patchResult.textContent = '';
362366
patchResult.className = 'patch-result';
@@ -435,6 +439,10 @@ patchButton.addEventListener('click', async () => {
435439
const { vm, kid } = buildEs256kVerificationMethod({
436440
privKey: priv,
437441
webId: lastWebId,
442+
// For delegated-control profiles use the diagnosed controller,
443+
// not the WebID — otherwise the VM's controller will mismatch
444+
// the profile's outer controller predicate and verifiers reject.
445+
controller: lastController ?? lastWebId,
438446
});
439447
lastVmKid = kid;
440448

@@ -445,14 +453,27 @@ patchButton.addEventListener('click', async () => {
445453
headers: { Accept: 'application/ld+json' },
446454
});
447455
if (!getRes.ok) throw new Error(`GET profile: HTTP ${getRes.status}`);
456+
const etag = getRes.headers.get('etag');
448457
const current = await getRes.json();
449458

450459
const merged = mergeVerificationMethod(current, vm);
460+
461+
const putHeaders = { 'Content-Type': 'application/ld+json' };
462+
// Use If-Match to defeat lost-update on concurrent edits. JSS
463+
// returns ETags on profile resources; servers without ETag support
464+
// fall through with no header.
465+
if (etag) putHeaders['If-Match'] = etag;
466+
451467
const putRes = await session.authFetch(lastDocUrl, {
452468
method: 'PUT',
453-
headers: { 'Content-Type': 'application/ld+json' },
469+
headers: putHeaders,
454470
body: JSON.stringify(merged, null, 2),
455471
});
472+
if (putRes.status === 412 || putRes.status === 409) {
473+
throw new Error(
474+
`profile changed since GET (HTTP ${putRes.status}). Re-run diagnostics and try again.`,
475+
);
476+
}
456477
if (!putRes.ok) throw new Error(`PUT profile: HTTP ${putRes.status}`);
457478

458479
patchResult.className = 'patch-result ok';
@@ -518,18 +539,45 @@ testButton.addEventListener('click', async () => {
518539
});
519540

520541
/**
521-
* Merge a verificationMethod entry into a profile, idempotently.
522-
* Replaces an existing entry with the same `id`, otherwise appends.
523-
* Also adds the entry's id to `authentication` if not already there.
542+
* Merge a verificationMethod entry into a profile.
543+
*
544+
* Idempotent on re-runs of the SAME key: replaces the existing entry
545+
* (same id, same publicKeyJwk) so we don't grow duplicates.
546+
*
547+
* Refuses to clobber a different key sitting at the same fragment —
548+
* an existing VM with the same id but DIFFERENT publicKeyJwk throws.
549+
* The user can pick another fragment if they want to keep both keys
550+
* (key rotation should happen at a fresh fragment, e.g. `lws-key-2`,
551+
* with the old one removed from `authentication` once rotation is
552+
* complete).
553+
*
554+
* Handles string-IRI verificationMethod entries (which JSON-LD
555+
* permits) — finds them by IRI equality so the entry isn't duplicated.
524556
*/
525557
function mergeVerificationMethod(profile, vm) {
526558
const out = { ...profile };
527559
const vms = Array.isArray(out.verificationMethod) ? [...out.verificationMethod]
528560
: out.verificationMethod ? [out.verificationMethod]
529561
: [];
530-
const idx = vms.findIndex((v) => (v?.id || v?.['@id']) === vm.id);
531-
if (idx >= 0) vms[idx] = vm;
532-
else vms.push(vm);
562+
const idx = vms.findIndex((v) => entryMatchesId(v, vm.id));
563+
if (idx >= 0) {
564+
const existing = vms[idx];
565+
// String-IRI entries don't carry inline material — replacing
566+
// them with our embedded VM is fine. For embedded entries with a
567+
// different publicKeyJwk, refuse to overwrite.
568+
if (typeof existing === 'object' && existing !== null) {
569+
const existingJwk = existing.publicKeyJwk;
570+
if (existingJwk && !sameJwk(existingJwk, vm.publicKeyJwk)) {
571+
throw new Error(
572+
`verificationMethod ${vm.id} already exists with a different public key — ` +
573+
`pick a new fragment (e.g. lws-key-2) or remove the existing entry first`,
574+
);
575+
}
576+
}
577+
vms[idx] = vm;
578+
} else {
579+
vms.push(vm);
580+
}
533581
out.verificationMethod = vms;
534582

535583
const auth = Array.isArray(out.authentication) ? [...out.authentication]
@@ -542,6 +590,22 @@ function mergeVerificationMethod(profile, vm) {
542590
return out;
543591
}
544592

593+
function entryMatchesId(entry, id) {
594+
if (typeof entry === 'string') return entry === id;
595+
if (entry && typeof entry === 'object') return (entry.id || entry['@id']) === id;
596+
return false;
597+
}
598+
599+
function sameJwk(a, b) {
600+
// Compare the public-key material, not auxiliary fields like `kid`,
601+
// `alg`, or `use`. Two VMs are "the same key" iff x and y match.
602+
return a && b
603+
&& a.kty === b.kty
604+
&& a.crv === b.crv
605+
&& a.x === b.x
606+
&& a.y === b.y;
607+
}
608+
545609
function extractIssuer(profile) {
546610
// JSS emits oidcIssuer in compact form via the profile @context. Some
547611
// clients use the full predicate URI or the prefixed form; support all.

index.html

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,15 +70,16 @@ <h3>Patch to apply</h3>
7070
<button type="button" id="copy-snippet">Copy snippet</button>
7171
<span class="copy-status" id="copy-status" aria-live="polite"></span>
7272

73-
<p class="hint future">B.3 (below) closes the loop with an in-app PATCH so you don't have to copy/paste.</p>
73+
<p class="hint">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.</p>
7474
</div>
7575
</section>
7676

7777
<section class="lws-auth" id="lws-auth" hidden>
7878
<h2>Strict LWS10-CID auth setup</h2>
7979
<p>Sign in to your pod, paste a secp256k1 private key (your Nostr nsec hex works
80-
&mdash; same key, different signature scheme), and the doctor PATCHes a
81-
<code>JsonWebKey</code> verificationMethod into your profile and signs a real
80+
&mdash; same key, different signature scheme), and the doctor adds a
81+
<code>JsonWebKey</code> verificationMethod to your profile (read-modify-write over
82+
authenticated GET + PUT) and signs a real
8283
<a href="https://www.w3.org/TR/2026/WD-lws10-authn-ssi-cid-20260423/" target="_blank" rel="noopener">LWS10-CID</a>
8384
JWT to authenticate. <strong>The private key never leaves your browser.</strong></p>
8485

@@ -101,6 +102,10 @@ <h3>1. Add an ES256K verificationMethod</h3>
101102
placeholder="0123…64-hex-chars…cdef">
102103
<button type="button" id="patch-button">Add VM to profile</button>
103104
<div class="patch-result" id="patch-result" aria-live="polite"></div>
105+
<p class="hint">Implementation: authenticated GET → merge VM → PUT (with
106+
<code>If-Match</code> when the server provides an ETag). True
107+
<code>PATCH</code> isn't used because JSS's JSON-LD ↔ Turtle conneg
108+
layer has known edge cases on patch round-trips.</p>
104109
</div>
105110

106111
<!-- Test auth -->

lib/lws-cid-client.js

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,16 @@ export function validatePrivKey(hex) {
6565
*
6666
* @param {object} args
6767
* @param {Uint8Array|string} args.privKey - 32-byte privkey (Uint8Array or hex string)
68-
* @param {string} args.webId - WebID URI used as VM controller
68+
* @param {string} args.webId - WebID URI used as the VM `id` base
69+
* @param {string} [args.controller=webId] - The CID document that
70+
* controls this verificationMethod. Defaults to the WebID itself
71+
* (the common self-controlled case). For delegated-control profiles
72+
* pass the profile's declared `controller` so the VM agrees with
73+
* the outer controller predicate.
6974
* @param {string} [args.fragment='lws-key-1'] - VM fragment id
7075
* @returns {{ vm: object, jwk: object, kid: string }}
7176
*/
72-
export function buildEs256kVerificationMethod({ privKey, webId, fragment = 'lws-key-1' }) {
77+
export function buildEs256kVerificationMethod({ privKey, webId, controller, fragment = 'lws-key-1' }) {
7378
const priv = privKey instanceof Uint8Array ? privKey : validatePrivKey(privKey);
7479
// Uncompressed 65-byte point: 0x04 || x(32) || y(32)
7580
const pubFull = secp256k1.getPublicKey(priv, /*compressed*/false);
@@ -93,7 +98,7 @@ export function buildEs256kVerificationMethod({ privKey, webId, fragment = 'lws-
9398
const vm = {
9499
id: kid,
95100
type: 'JsonWebKey',
96-
controller: webId,
101+
controller: controller ?? webId,
97102
publicKeyJwk: jwk,
98103
};
99104
return { vm, jwk, kid };

0 commit comments

Comments
 (0)