Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
105 changes: 66 additions & 39 deletions doctor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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: '·' };
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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' } });
Expand All @@ -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?');
Expand All @@ -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 };
}

Expand Down Expand Up @@ -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 (;;) {
Expand Down Expand Up @@ -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 = '<span class="icon">🔑</span><div class="body"><div class="label"></div><div class="detail"></div></div><button type="button" class="secondary">Remove</button>';
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}`)));
8 changes: 8 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,14 @@ <h2>Strict LWS10-CID auth (ES256K JWT)</h2>
<pre id="jwt-out" hidden></pre>
</section>

<section id="keys">
<h2>Nostr keys in this profile</h2>
<p>Every key the pod accepts as you. Remove one you have lost or replaced; the pod then stops treating it as you.</p>
<button type="button" id="keys-refresh">Show keys</button>
<ol id="keys-list" class="steps"></ol>
<p class="hint" id="keys-note"></p>
</section>

<section>
<p class="hint">Signed in via Solid-OIDC as <code id="session-webid">nobody</code>.
<button type="button" id="signout" class="link" hidden>Sign out</button></p>
Expand Down
80 changes: 80 additions & 0 deletions lib/profile-keys.js
Original file line number Diff line number Diff line change
@@ -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;
}
10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
84 changes: 84 additions & 0 deletions test/profile-keys.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
Loading