From e96c69499dc3fffa82efa7c60040f5d24128c286 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Thu, 22 Oct 2026 14:00:00 +0200 Subject: [PATCH 1/2] Profile-key logic in lib/profile-keys.js with tests and CI The merge, idempotence, fragment choice and removal rules move out of the page into pure functions (find a method by key, merge one in, take one out, next free fragment), covered by node --test fixtures with relative and absolute ids, string and object entries. package.json gains test and check scripts; a workflow runs both. --- .github/workflows/test.yml | 17 ++++++++ lib/profile-keys.js | 80 ++++++++++++++++++++++++++++++++++++ package.json | 10 +++++ test/profile-keys.test.js | 84 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 191 insertions(+) create mode 100644 .github/workflows/test.yml create mode 100644 lib/profile-keys.js create mode 100644 package.json create mode 100644 test/profile-keys.test.js diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..afde733 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,17 @@ +name: Tests + +on: + push: + branches: [gh-pages] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + - run: npm run check + - run: npm test diff --git a/lib/profile-keys.js b/lib/profile-keys.js new file mode 100644 index 0000000..b403f3f --- /dev/null +++ b/lib/profile-keys.js @@ -0,0 +1,80 @@ +/** + * Pure helpers for the verification methods in a WebID profile document (JSON-LD as + * Solid pods serve it): find the method that holds a Nostr key, merge one in, take one + * out. No network, no DOM; see test/profile-keys.test.js. + * + * Entries in `verificationMethod`, `authentication` and `assertionMethod` may be + * objects or bare IRIs, absolute or relative to the document; every comparison + * resolves them against `docUrl` first. + */ +import { multikeyToNostrPubkey } from './multikey.js'; + +export function absolute(u, base) { + try { return new URL(u, base).href; } catch { return u; } +} + +export function idOf(entry) { + return typeof entry === 'string' ? entry : entry?.id ?? entry?.['@id']; +} + +export function asList(v) { + return Array.isArray(v) ? v : v === undefined || v === null ? [] : [v]; +} + +const PURPOSES = ['authentication', 'assertionMethod']; + +/** Every Multikey method that encodes a Nostr key: [{ id, pubkey, authenticates }]. */ +export function nostrMethods(profile, docUrl) { + const auth = new Set(asList(profile.authentication).map((a) => absolute(idOf(a), docUrl))); + const out = []; + for (const v of asList(profile.verificationMethod)) { + if (!v || typeof v !== 'object' || typeof v.publicKeyMultibase !== 'string') continue; + const decoded = multikeyToNostrPubkey(v.publicKeyMultibase); + if (!decoded) continue; + const id = absolute(idOf(v), docUrl); + out.push({ id, pubkey: decoded.xOnlyHex, authenticates: auth.has(id) }); + } + return out; +} + +/** The id of the method holding `pubkey` that is usable for authentication, or null. */ +export function linkedAs(profile, docUrl, pubkey) { + return nostrMethods(profile, docUrl).find((m) => m.pubkey === pubkey && m.authenticates)?.id ?? null; +} + +/** The id of any method holding `pubkey`, authentication reference or not, or null. */ +export function methodFor(profile, docUrl, pubkey) { + return nostrMethods(profile, docUrl).find((m) => m.pubkey === pubkey)?.id ?? null; +} + +/** `${docUrl}#${prefix}-${n}` for the first n whose fragment no method uses yet. */ +export function freeFragment(profile, docUrl, prefix) { + const taken = new Set(asList(profile.verificationMethod).map((v) => absolute(idOf(v), docUrl))); + let n = 1; + while (taken.has(`${docUrl}#${prefix}-${n}`)) n++; + return `${docUrl}#${prefix}-${n}`; +} + +/** + * A copy of the profile with `vm` in verificationMethod (replacing an entry with the + * same id) and its id listed under authentication. Everything else is left as it is. + */ +export function mergeMethod(profile, docUrl, vm) { + const vms = [...asList(profile.verificationMethod)]; + const at = vms.findIndex((v) => absolute(idOf(v), docUrl) === vm.id); + if (at >= 0) vms[at] = vm; + else vms.push(vm); + const auth = [...asList(profile.authentication)]; + if (!auth.some((a) => absolute(idOf(a), docUrl) === vm.id)) auth.push(vm.id); + return { ...profile, verificationMethod: vms, authentication: auth }; +} + +/** A copy of the profile without the method `vmId` and without any reference to it. */ +export function removeMethod(profile, docUrl, vmId) { + const target = absolute(vmId, docUrl); + const out = { ...profile }; + const keep = (list) => list.filter((e) => absolute(idOf(e), docUrl) !== target); + if (profile.verificationMethod !== undefined) out.verificationMethod = keep(asList(profile.verificationMethod)); + for (const purpose of PURPOSES) if (profile[purpose] !== undefined) out[purpose] = keep(asList(profile[purpose])); + return out; +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f1e1d13 --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "name": "doctor", + "private": true, + "type": "module", + "license": "AGPL-3.0-only", + "scripts": { + "test": "node --test", + "check": "for f in doctor.js lib/*.js; do node --check \"$f\"; done && cd vendor && sha256sum -c SHA256SUMS" + } +} diff --git a/test/profile-keys.test.js b/test/profile-keys.test.js new file mode 100644 index 0000000..933aa0e --- /dev/null +++ b/test/profile-keys.test.js @@ -0,0 +1,84 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { freeFragment, linkedAs, mergeMethod, methodFor, nostrMethods, removeMethod } from '../lib/profile-keys.js'; +import { buildNostrVerificationMethod, nostrPubkeyToMultikey } from '../lib/multikey.js'; + +const doc = 'https://pod.example/alice/profile/card.jsonld'; +const webId = `${doc}#me`; +const keyA = 'a'.repeat(64); +const keyB = 'b'.repeat(64); + +const profile = () => ({ + '@context': { cid: 'https://www.w3.org/ns/cid/v1#' }, + '@id': '#me', + name: 'Alice', + controller: '#me', + verificationMethod: [ + { id: '#nostr-key-1', type: 'Multikey', controller: webId, publicKeyMultibase: nostrPubkeyToMultikey(keyA) }, + { id: `${doc}#lws-key-1`, type: 'JsonWebKey', controller: webId, publicKeyJwk: { kty: 'EC', crv: 'secp256k1', x: 'x', y: 'y' } }, + ], + authentication: ['#nostr-key-1'], + assertionMethod: [{ '@id': `${doc}#nostr-key-1` }], +}); + +test('finds Nostr methods whether ids are relative, absolute, strings or objects', () => { + assert.deepEqual(nostrMethods(profile(), doc), [{ id: `${doc}#nostr-key-1`, pubkey: keyA, authenticates: true }]); + assert.equal(linkedAs(profile(), doc, keyA), `${doc}#nostr-key-1`); + assert.equal(linkedAs(profile(), doc, keyB), null); + const unlinked = { ...profile(), authentication: [] }; + assert.equal(linkedAs(unlinked, doc, keyA), null, 'a method not listed under authentication does not count'); + assert.equal(methodFor(unlinked, doc, keyA), `${doc}#nostr-key-1`); + assert.deepEqual(nostrMethods({}, doc), []); + assert.deepEqual(nostrMethods({ verificationMethod: '#x', authentication: '#x' }, doc), []); +}); + +test('a free fragment skips the ones in use, under any spelling', () => { + assert.equal(freeFragment(profile(), doc, 'nostr-key'), `${doc}#nostr-key-2`); + assert.equal(freeFragment({}, doc, 'nostr-key'), `${doc}#nostr-key-1`); + assert.equal(freeFragment({ verificationMethod: [{ id: `${doc}#nostr-key-1` }, { id: '#nostr-key-2' }, '#nostr-key-3'] }, doc, 'nostr-key'), `${doc}#nostr-key-4`); +}); + +test('merging adds a method and its authentication reference once, and keeps the rest', () => { + const vm = buildNostrVerificationMethod({ webId, xOnlyHex: keyB, controller: webId, fragment: 'nostr-key-2' }); + const merged = mergeMethod(profile(), doc, vm); + assert.equal(merged.verificationMethod.length, 3); + assert.deepEqual(merged.authentication, ['#nostr-key-1', `${doc}#nostr-key-2`]); + assert.equal(merged.name, 'Alice'); + assert.deepEqual(merged.assertionMethod, profile().assertionMethod, 'untouched'); + // Running it again changes nothing. + const again = mergeMethod(merged, doc, vm); + assert.deepEqual(again, merged); + // Replacing an existing id keeps the list length. + const replaced = mergeMethod(profile(), doc, { ...vm, id: `${doc}#nostr-key-1` }); + assert.equal(replaced.verificationMethod.length, 2); + assert.equal(replaced.verificationMethod[0].publicKeyMultibase, vm.publicKeyMultibase); + assert.deepEqual(replaced.authentication, ['#nostr-key-1'], 'the relative reference already covered it'); +}); + +test('a profile with no methods yet gets arrays', () => { + const vm = buildNostrVerificationMethod({ webId, xOnlyHex: keyA, controller: webId }); + const merged = mergeMethod({ '@id': '#me' }, doc, vm); + assert.deepEqual(merged.verificationMethod, [vm]); + assert.deepEqual(merged.authentication, [vm.id]); +}); + +test('removing a method drops it and every reference, nothing else', () => { + const out = removeMethod(profile(), doc, '#nostr-key-1'); + assert.deepEqual(out.verificationMethod.map((v) => v.id), [`${doc}#lws-key-1`]); + assert.deepEqual(out.authentication, []); + assert.deepEqual(out.assertionMethod, []); + assert.equal(out.name, 'Alice'); + assert.equal(linkedAs(out, doc, keyA), null); + // Absent lists stay absent; a single non-array value is handled. + const single = removeMethod({ verificationMethod: { id: '#k' }, authentication: '#k' }, doc, `${doc}#k`); + assert.deepEqual(single, { verificationMethod: [], authentication: [] }); + assert.deepEqual(removeMethod({ name: 'x' }, doc, '#k'), { name: 'x' }); +}); + +test('round trip: the method doctor writes is the one it later finds and removes', () => { + const vm = buildNostrVerificationMethod({ webId, xOnlyHex: keyB, controller: webId, fragment: freeFragment(profile(), doc, 'nostr-key').split('#')[1] }); + assert.equal(vm.id, `${doc}#nostr-key-2`); + const merged = mergeMethod(profile(), doc, vm); + assert.equal(linkedAs(merged, doc, keyB), vm.id); + assert.equal(linkedAs(removeMethod(merged, doc, vm.id), doc, keyB), null); +}); From d40bcb46825c697f8dd3a6f560002149fe2b6f24 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Thu, 22 Oct 2026 14:00:00 +0200 Subject: [PATCH 2/2] Advanced: list the Nostr keys in the profile, with removal For a lost or replaced key: the keys the pod accepts are listed with a Remove button that asks twice, then PUTs the profile without the method and without any authentication or assertionMethod reference to it. The page uses the shared lib for merging too. --- README.md | 10 +++-- doctor.js | 105 +++++++++++++++++++++++++++++++++-------------------- index.html | 8 ++++ 3 files changed, 81 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 7f345ed..b701575 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,10 @@ sign-in. Nothing secret leaves the browser: the pod only ever sees the public ke `WAC-Allow`. [JSS](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer) resolves such a key to the pod owner's WebID. -Under **Advanced**: the read-only LWS / W3C Controlled Identifiers v1.0 profile-shape -check, and the strict LWS10-CID test client (ES256K `JsonWebKey` method plus a signed JWT), -loaded only when used. +Under **Advanced**: the Nostr keys the profile accepts, each with a Remove button (for a +lost or replaced key; pods that cache profiles take a few minutes to notice), the read-only +LWS / W3C Controlled Identifiers v1.0 profile-shape check, and the strict LWS10-CID test +client (ES256K `JsonWebKey` method plus a signed JWT), loaded only when used. ## Stack @@ -34,6 +35,9 @@ static site from the `gh-pages` branch. `?webid=…` or `?pod=…` in the URL prefills the address, so another app can link here. +`npm test` runs the profile-key logic (`lib/profile-keys.js`) against fixtures; `npm run check` +syntax-checks the modules and verifies the vendored checksums. Both run in CI. + ## Refs - [JSS#386](https://github.com/JavaScriptSolidServer/JavaScriptSolidServer/issues/386) — convergence tracker diff --git a/doctor.js b/doctor.js index 78260f1..79c20d5 100644 --- a/doctor.js +++ b/doctor.js @@ -11,8 +11,9 @@ * unchanged from before, loaded only when used. */ import { Session } from './vendor/solid-oidc.js'; -import { buildNostrVerificationMethod, multikeyToNostrPubkey } from './lib/multikey.js'; +import { buildNostrVerificationMethod } from './lib/multikey.js'; import { runLwsCidChecks, normalizeControllers } from './lib/lws-cid.js'; +import { absolute, asList as asListed, freeFragment, idOf, linkedAs, mergeMethod, methodFor, nostrMethods, removeMethod } from './lib/profile-keys.js'; const $ = (id) => document.getElementById(id); const el = { @@ -21,6 +22,7 @@ const el = { check: $('check'), checks: $('checks'), raw: $('raw'), rawBody: $('raw-body'), privkey: $('privkey'), jwtAdd: $('jwt-add'), jwtTest: $('jwt-test'), jwtOut: $('jwt-out'), sessionWebId: $('session-webid'), signout: $('signout'), + keys: $('keys'), keysList: $('keys-list'), keysRefresh: $('keys-refresh'), keysNote: $('keys-note'), }; const ICONS = { pass: '✓', warn: '!', fail: '✗', busy: '…', skip: '·' }; @@ -168,18 +170,6 @@ async function prove(found, pubkey) { el.done.hidden = false; } -// The id of the verificationMethod that already holds this key and is usable for -// authentication, or null. -function linkedAs(profile, docUrl, pubkey) { - const auth = asList(profile.authentication).map((a) => absolute(idOf(a), docUrl)); - for (const v of asList(profile.verificationMethod)) { - const decoded = typeof v === 'object' && v?.publicKeyMultibase ? multikeyToNostrPubkey(v.publicKeyMultibase) : null; - if (decoded?.xOnlyHex !== pubkey) continue; - const id = absolute(idOf(v), docUrl); - if (auth.includes(id)) return id; - } - return null; -} // The WebID profile behind a WebID or a pod address. Pods put the profile at a few // conventional places; a WebID is used as given. @@ -242,11 +232,6 @@ function issuerOf(profile) { return null; } -// Hoisted: run() may start while the module is still evaluating (resuming after the redirect). -function absolute(u, base) { try { return new URL(u, base).href; } catch { return u; } } -function idOf(entry) { return typeof entry === 'string' ? entry : entry?.id ?? entry?.['@id']; } -function asList(v) { return Array.isArray(v) ? v : v ? [v] : []; } - // Read the live profile with the session: { current, etag }. async function readLive(found) { const res = await session.authFetch(found.docUrl, { headers: { Accept: 'application/ld+json' } }); @@ -259,16 +244,14 @@ async function readLive(found) { // advanced JWK path. async function writeMethod(found, vm) { const { current, etag } = await readLive(found); - const vms = [...asList(current.verificationMethod)]; - const at = vms.findIndex((v) => absolute(idOf(v), found.docUrl) === vm.id); - if (at >= 0) vms[at] = vm; - else vms.push(vm); - const auth = [...asList(current.authentication)]; - if (!auth.some((a) => absolute(idOf(a), found.docUrl) === vm.id)) auth.push(vm.id); + await putProfile(found, mergeMethod(current, found.docUrl, vm), etag); +} + +async function putProfile(found, profile, etag) { const put = await session.authFetch(found.docUrl, { method: 'PUT', headers: { 'Content-Type': 'application/ld+json', ...(etag ? { 'If-Match': etag } : {}) }, - body: JSON.stringify({ ...current, verificationMethod: vms, authentication: auth }, null, 2), + body: JSON.stringify(profile, null, 2), }); if (put.status === 412 || put.status === 409) throw new Error('The profile changed while doctor was working. Try again.'); if (put.status === 401 || put.status === 403) throw new Error('The pod did not let this session write the profile. Are you signed in as its owner?'); @@ -277,22 +260,19 @@ async function writeMethod(found, vm) { // Reuse the method that already holds this key; otherwise take the first free #nostr-key-N. async function addKeyToProfile(found, pubkey) { - const { current } = await readLive(found); - const vms = asList(current.verificationMethod); + const { current, etag } = await readLive(found); const linked = linkedAs(current, found.docUrl, pubkey); if (linked) return { vmId: linked, existing: true }; - for (const v of vms) { - const decoded = typeof v === 'object' && v?.publicKeyMultibase ? multikeyToNostrPubkey(v.publicKeyMultibase) : null; - if (decoded?.xOnlyHex !== pubkey) continue; - const vmId = absolute(idOf(v), found.docUrl); - await writeMethod(found, { ...v, id: vmId }); // known key, not yet usable for authentication - return { vmId, existing: false }; + const known = methodFor(current, found.docUrl, pubkey); + if (known) { + // The key is there but not usable for authentication yet: add the reference. + const vm = asListed(current.verificationMethod).find((v) => absolute(idOf(v), found.docUrl) === known); + await putProfile(found, mergeMethod(current, found.docUrl, { ...vm, id: known }), etag); + return { vmId: known, existing: false }; } - const taken = new Set(vms.map((v) => absolute(idOf(v), found.docUrl))); - let n = 1; - while (taken.has(`${found.docUrl}#nostr-key-${n}`)) n++; - const vm = buildNostrVerificationMethod({ webId: found.webId, xOnlyHex: pubkey, controller: found.controller, fragment: `nostr-key-${n}` }); - await writeMethod(found, vm); + const id = freeFragment(current, found.docUrl, 'nostr-key'); + const vm = buildNostrVerificationMethod({ webId: found.webId, xOnlyHex: pubkey, controller: found.controller, fragment: id.split('#')[1] }); + await putProfile(found, mergeMethod(current, found.docUrl, vm), etag); return { vmId: vm.id, existing: false }; } @@ -361,7 +341,7 @@ el.jwtAdd.addEventListener('click', async () => { el.privkey.value = ''; // Reuse the slot holding the same key; otherwise the first free lws-key-N. const { current } = await readLive(found); - const vms = asList(current.verificationMethod); + const vms = asListed(current.verificationMethod); const probe = lws.buildEs256kVerificationMethod({ privKey: memPrivKey, webId: found.webId, controller: found.controller, fragment: 'probe' }).jwk; let n = 1; for (;;) { @@ -394,3 +374,50 @@ el.jwtTest.addEventListener('click', async () => { say('error', `Failed: ${err.message || err}`); } }); + +// ---------- Advanced: keys in this profile, with removal ---------- + +async function showKeys() { + el.keysList.innerHTML = ''; + el.keysNote.textContent = ''; + if (!session.isActive) { + el.keysNote.textContent = 'Sign in first (press "Connect my key" above, or "Check again").'; + return; + } + const found = profileCache?.webId === session.webId ? profileCache : await findProfile(session.webId); + profileCache = found; + const { current } = await readLive(found); + const methods = nostrMethods(current, found.docUrl); + if (!methods.length) { + el.keysNote.textContent = 'No Nostr keys in this profile.'; + return; + } + for (const m of methods) { + const li = document.createElement('li'); + li.className = 'step pass'; + li.innerHTML = '🔑
'; + li.querySelector('.label').textContent = `did:nostr:${m.pubkey}`; + li.querySelector('.detail').textContent = `${m.id}${m.authenticates ? '' : ' (not listed for authentication)'}`; + const button = li.querySelector('button'); + let armed = false; + button.addEventListener('click', async () => { + if (!armed) { + armed = true; + button.textContent = 'Remove for good?'; + return; + } + button.disabled = true; + try { + const { current: live, etag } = await readLive(found); + await putProfile(found, removeMethod(live, found.docUrl, m.id), etag); + await showKeys(); + el.keysNote.textContent = `Removed ${m.id}. Apps signing with that key stop being you on this pod; pods that cache profiles may take a few minutes to notice.`; + } catch (err) { + el.keysNote.textContent = `Failed: ${err.message || err}`; + button.disabled = false; + } + }); + el.keysList.appendChild(li); + } +} +el.keysRefresh.addEventListener('click', () => showKeys().catch((err) => (el.keysNote.textContent = `Failed: ${err.message || err}`))); diff --git a/index.html b/index.html index effe2bd..73bc947 100644 --- a/index.html +++ b/index.html @@ -71,6 +71,14 @@

Strict LWS10-CID auth (ES256K JWT)

+
+

Nostr keys in this profile

+

Every key the pod accepts as you. Remove one you have lost or replaced; the pod then stops treating it as you.

+ +
    +

    +
    +

    Signed in via Solid-OIDC as nobody.