Skip to content

Commit a9ba933

Browse files
Address copilot pass 2 on #4
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.
1 parent e43a4af commit a9ba933

3 files changed

Lines changed: 99 additions & 48 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 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.
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 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.
2828

2929
## Roadmap (rough)
3030

doctor.js

Lines changed: 91 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -436,16 +436,6 @@ patchButton.addEventListener('click', async () => {
436436
const priv = validatePrivKey(privkeyInput.value);
437437
memPrivKey = priv;
438438

439-
const { vm, kid } = buildEs256kVerificationMethod({
440-
privKey: priv,
441-
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,
446-
});
447-
lastVmKid = kid;
448-
449439
// Read-modify-write: GET via authFetch (so we see the
450440
// authoritative current state, including any private triples),
451441
// merge our VM into verificationMethod / authentication, PUT back.
@@ -456,6 +446,18 @@ patchButton.addEventListener('click', async () => {
456446
const etag = getRes.headers.get('etag');
457447
const current = await getRes.json();
458448

449+
// Pick a fragment that's either unused or already holds the same
450+
// key (idempotent re-run). Re-running with a different key won't
451+
// silently clobber an existing one — we walk lws-key-N until we
452+
// find a free or matching slot.
453+
const { fragment, vm, kid } = chooseFragmentAndBuildVm({
454+
privKey: priv,
455+
profile: current,
456+
webId: lastWebId,
457+
controller: lastController ?? lastWebId,
458+
});
459+
lastVmKid = kid;
460+
459461
const merged = mergeVerificationMethod(current, vm);
460462

461463
const putHeaders = { 'Content-Type': 'application/ld+json' };
@@ -478,7 +480,8 @@ patchButton.addEventListener('click', async () => {
478480

479481
patchResult.className = 'patch-result ok';
480482
patchResult.textContent =
481-
`Added ${kid} to verificationMethod and authentication.\n` +
483+
`Added ${kid} to verificationMethod and authentication ` +
484+
`(fragment chosen: #${fragment}).\n` +
482485
`Profile updated. You can now test LWS-CID auth below.`;
483486
testSection.hidden = false;
484487
privkeyInput.value = '';
@@ -539,17 +542,61 @@ testButton.addEventListener('click', async () => {
539542
});
540543

541544
/**
542-
* Merge a verificationMethod entry into a profile.
545+
* Choose a non-colliding fragment for the new VM, then build it.
543546
*
544-
* Idempotent on re-runs of the SAME key: replaces the existing entry
545-
* (same id, same publicKeyJwk) so we don't grow duplicates.
547+
* - If `lws-key-1` is unused, take it.
548+
* - If `lws-key-1` already holds *the same* public key (re-run), take
549+
* it — the merge will be a no-op replace.
550+
* - Otherwise walk lws-key-2, lws-key-3, … until we find an unused
551+
* slot or one that already matches. Cap at 99 to bound work; if a
552+
* user has somehow accumulated 99 distinct VMs they should clean
553+
* up first.
554+
*/
555+
function chooseFragmentAndBuildVm({ privKey, profile, webId, controller }) {
556+
const docUrl = stripHashLocal(webId);
557+
const vms = Array.isArray(profile.verificationMethod) ? profile.verificationMethod
558+
: profile.verificationMethod ? [profile.verificationMethod]
559+
: [];
560+
561+
// Pre-build the VM once so we can compare its JWK against existing
562+
// entries. The fragment will be re-stamped on the chosen one below.
563+
const probe = buildEs256kVerificationMethod({ privKey, webId, controller, fragment: 'probe' });
564+
565+
for (let n = 1; n <= 99; n++) {
566+
const candidateId = `${docUrl}#lws-key-${n}`;
567+
const existing = vms.find((v) => entryMatchesId(v, candidateId));
568+
if (!existing) {
569+
const result = buildEs256kVerificationMethod({
570+
privKey, webId, controller, fragment: `lws-key-${n}`,
571+
});
572+
return { fragment: `lws-key-${n}`, vm: result.vm, kid: result.kid };
573+
}
574+
// Slot taken — only re-use if the existing entry has the SAME
575+
// public-key material (idempotent re-run).
576+
if (typeof existing === 'object' && existing !== null) {
577+
const existingJwk = existing.publicKeyJwk;
578+
if (existingJwk && sameJwk(existingJwk, probe.publicKeyJwk)) {
579+
const result = buildEs256kVerificationMethod({
580+
privKey, webId, controller, fragment: `lws-key-${n}`,
581+
});
582+
return { fragment: `lws-key-${n}`, vm: result.vm, kid: result.kid };
583+
}
584+
// Different key here — try the next slot.
585+
} else {
586+
// String-IRI entry takes the slot but carries no key material.
587+
// We can't tell whether it's "ours" or someone else's. Skip to
588+
// the next slot to be safe.
589+
}
590+
}
591+
throw new Error('all lws-key-1..99 fragments are taken — clean up your profile first');
592+
}
593+
594+
/**
595+
* Merge a verificationMethod entry into a profile.
546596
*
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).
597+
* Idempotent on re-runs: when the entry's id matches an existing VM,
598+
* replaces it. (chooseFragmentAndBuildVm guarantees same-id ⇒ same-key
599+
* before we get here, so this can't silently clobber.)
553600
*
554601
* Handles string-IRI verificationMethod entries (which JSON-LD
555602
* permits) — finds them by IRI equality so the entry isn't duplicated.
@@ -560,24 +607,8 @@ function mergeVerificationMethod(profile, vm) {
560607
: out.verificationMethod ? [out.verificationMethod]
561608
: [];
562609
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-
}
610+
if (idx >= 0) vms[idx] = vm;
611+
else vms.push(vm);
581612
out.verificationMethod = vms;
582613

583614
const auth = Array.isArray(out.authentication) ? [...out.authentication]
@@ -606,15 +637,33 @@ function sameJwk(a, b) {
606637
&& a.y === b.y;
607638
}
608639

640+
function stripHashLocal(u) {
641+
try {
642+
const url = new URL(u);
643+
url.hash = '';
644+
return url.toString();
645+
} catch {
646+
return String(u).split('#')[0];
647+
}
648+
}
649+
609650
function extractIssuer(profile) {
610651
// JSS emits oidcIssuer in compact form via the profile @context. Some
611-
// clients use the full predicate URI or the prefixed form; support all.
652+
// clients use the full predicate URI or the prefixed form; support
653+
// all. The value can also be an array (JSON-LD permits it for any
654+
// predicate) — take the first usable entry.
612655
const raw = profile?.oidcIssuer
613656
?? profile?.['solid:oidcIssuer']
614657
?? profile?.['http://www.w3.org/ns/solid/terms#oidcIssuer'];
615-
if (!raw) return null;
616-
if (typeof raw === 'string') return raw;
617-
if (typeof raw === 'object') return raw['@id'] || raw.id || null;
658+
if (raw === null || raw === undefined) return null;
659+
const list = Array.isArray(raw) ? raw : [raw];
660+
for (const v of list) {
661+
if (typeof v === 'string') return v;
662+
if (v && typeof v === 'object') {
663+
const id = v['@id'] || v.id;
664+
if (typeof id === 'string') return id;
665+
}
666+
}
618667
return null;
619668
}
620669

index.html

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,9 @@ <h3>Patch to apply</h3>
7676

7777
<section class="lws-auth" id="lws-auth" hidden>
7878
<h2>Strict LWS10-CID auth setup</h2>
79-
<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 adds a
79+
<p>Sign in to your pod, paste a secp256k1 private key as 64 hex chars (the raw
80+
32-byte key behind your Nostr <code>nsec1…</code> bech32 &mdash; same key,
81+
different signature scheme), and the doctor adds a
8182
<code>JsonWebKey</code> verificationMethod to your profile (read-modify-write over
8283
authenticated GET + PUT) and signs a real
8384
<a href="https://www.w3.org/TR/2026/WD-lws10-authn-ssi-cid-20260423/" target="_blank" rel="noopener">LWS10-CID</a>
@@ -94,10 +95,11 @@ <h2>Strict LWS10-CID auth setup</h2>
9495
<!-- PATCH -->
9596
<div id="patch-section" hidden>
9697
<h3>1. Add an ES256K verificationMethod</h3>
97-
<p class="hint">Paste your secp256k1 private key as 64 hex chars. Same key used for
98-
Nostr signing (only the signature scheme differs &mdash; ECDSA vs Schnorr). The key
98+
<p class="hint">Paste your secp256k1 private key as 64 hex chars (the raw 32-byte
99+
key, not a bech32 <code>nsec1…</code>). Same key used for Nostr signing — only
100+
the signature scheme differs (ECDSA here vs Schnorr for Nostr events). The key
99101
is held in memory for this tab only; it is never persisted.</p>
100-
<label for="privkey">Private key (hex)</label>
102+
<label for="privkey">Private key (64 hex chars)</label>
101103
<input id="privkey" name="privkey" type="password" autocomplete="off" spellcheck="false"
102104
placeholder="0123…64-hex-chars…cdef">
103105
<button type="button" id="patch-button">Add VM to profile</button>

0 commit comments

Comments
 (0)