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/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.
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);
+});