From 2491aa931730f5199c97c465a69e9ebb9ae7c836 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Sun, 10 May 2026 01:14:23 +0200 Subject: [PATCH 1/2] idp: "Sign in with Schnorr" resolves user via typed username + VM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The button at /idp/interaction/:uid existed and was wired (handleSchnorrLogin → verifyNostrAuth → findByWebId), but findByWebId only succeeded when the user had published a did:nostr DID document with bidirectional alsoKnownAs to their WebID — the existing #4 resolver path. The new VM-lookup path (#400) needs a resource owner to derive the WebID, and the IdP login URL has no pod owner. So the button was effectively inert for everyone who'd added a Nostr key to their profile via the doctor's B.2. Fix: use the username already in the login form. The signature is still verified first; the username only chooses which account's profile to check the verified pubkey against. Typing someone else's username doesn't grant access — if their profile doesn't contain this pubkey, lookup fails and the request is rejected. Changes: - src/auth/nostr.js: export verifyNostrPubkeyAgainstWebId(webId, pubkeyHex). Bundles the existing internal helpers (fetch CID document via the shared cached fetcher, find Nostr VM by Multikey or JWK match, confirm authentication membership, confirm subject identity). - src/idp/interactions.js: handleSchnorrLogin falls back to findByUsername + verifyNostrPubkeyAgainstWebId when the primary did:nostr-resolver path returns no account. Body parsed via the same Buffer→URLSearchParams pattern handleLogin uses (since JSS registers a wildcard parseAs:'buffer' content parser). - src/idp/views.js: loginWithSchnorr() reads the form's username field and sends it as application/x-www-form-urlencoded body. Tests: 5 new for verifyNostrPubkeyAgainstWebId (Multikey match, not-in-authentication rejection, no-matching-key, subject mismatch, bad input). Full suite 704 → 709, no regressions. Closes #403. Refs #4 (Phase 2A — original Schnorr-login design), #400 (resource-side VM lookup, paired path). --- src/auth/nostr.js | 39 +++++++++++++++++++++++++ src/idp/interactions.js | 61 ++++++++++++++++++++++++++++++++++----- src/idp/views.js | 13 +++++++-- test/nostr-cid-vm.test.js | 41 +++++++++++++++++++++++++- 4 files changed, 144 insertions(+), 10 deletions(-) diff --git a/src/auth/nostr.js b/src/auth/nostr.js index ca649a18..31c314df 100644 --- a/src/auth/nostr.js +++ b/src/auth/nostr.js @@ -512,6 +512,45 @@ function firstHeaderValue(v) { * Returns the entry (object form) on match, normalized so .id is the * absolute IRI. Returns null on no match. */ +/** + * Confirm that a verified Nostr pubkey is declared as a CID + * verificationMethod referenced from `authentication` in the given + * WebID's profile. Used by the Schnorr-login IdP path (#403): once + * the signature is verified and the user has typed their username, + * the IdP layer can derive the candidate WebID and ask this whether + * the verified pubkey actually belongs to that WebID. + * + * Returns true on match, false on no-match / fetch failure / VM not + * in authentication / controller mismatch. + * + * @param {string} webId - canonical WebID URI (with or without #me) + * @param {string} pubkeyHex - 32-byte x-only Nostr pubkey hex + * @returns {Promise} + */ +export async function verifyNostrPubkeyAgainstWebId(webId, pubkeyHex) { + if (typeof webId !== 'string' || !webId) return false; + if (typeof pubkeyHex !== 'string' || !/^[0-9a-f]{64}$/i.test(pubkeyHex)) return false; + const docUrl = stripHash(webId); + let profile; + try { + profile = await fetchCidDocument(docUrl); + } catch { + return false; + } + if (!profile || typeof profile !== 'object' || Array.isArray(profile)) return false; + + // Confirm the profile actually identifies itself as the WebID we're + // asking about — otherwise a profile hosted at the WebID's URL could + // declare a different fragment as its subject and trick us. + const subject = absolutize(profile['@id'] || profile.id, docUrl); + if (!subject || subject !== webId) return false; + + const vm = findNostrVmInProfile(profile, pubkeyHex.toLowerCase(), docUrl); + if (!vm) return false; + if (!isInProofPurpose(profile, 'authentication', vm.id, docUrl)) return false; + return true; +} + function findNostrVmInProfile(profile, pubkeyHex, baseUrl) { const target = pubkeyHex.toLowerCase(); const targetB64u = hexToBase64url(target); diff --git a/src/idp/interactions.js b/src/idp/interactions.js index d2ebfd0e..2877dbe8 100644 --- a/src/idp/interactions.js +++ b/src/idp/interactions.js @@ -3,12 +3,12 @@ * Handles the user-facing parts of the authentication flow */ -import { authenticate, findById, findByWebId, createAccount, updateLastLogin, setPasskeyPromptDismissed } from './accounts.js'; +import { authenticate, findById, findByUsername, findByWebId, createAccount, updateLastLogin, setPasskeyPromptDismissed } from './accounts.js'; import { loginPage, consentPage, errorPage, registerPage, passkeyPromptPage } from './views.js'; import * as storage from '../storage/filesystem.js'; import { createPodStructure } from '../handlers/container.js'; import { validateInvite } from './invites.js'; -import { verifyNostrAuth } from '../auth/nostr.js'; +import { verifyNostrAuth, getNostrPubkey, verifyNostrPubkeyAgainstWebId } from '../auth/nostr.js'; // Security: Maximum body size for IdP form submissions (1MB) const MAX_BODY_SIZE = 1024 * 1024; @@ -658,6 +658,33 @@ export async function handlePasskeySkip(request, reply, provider) { } } +/** + * Pull the optional `username` field out of a schnorr-login POST. + * + * JSS registers a wildcard parseAs:'buffer' content-type parser + * (src/server.js), so request.body for application/x-www-form-urlencoded + * arrives as a Buffer that needs string-decode + URLSearchParams. JSON + * and already-parsed object bodies are also accepted for flexibility. + */ +function parseUsernameField(request) { + const body = request.body; + if (!body) return ''; + const ct = (request.headers?.['content-type'] || '').toLowerCase(); + let bag = {}; + if (Buffer.isBuffer(body) || typeof body === 'string') { + const s = Buffer.isBuffer(body) ? body.toString() : body; + if (ct.includes('application/json')) { + try { bag = JSON.parse(s); } catch { bag = {}; } + } else { + try { bag = Object.fromEntries(new URLSearchParams(s).entries()); } + catch { bag = {}; } + } + } else if (typeof body === 'object') { + bag = body; + } + return (bag.username || '').toString().trim(); +} + /** * Handle POST /idp/interaction/:uid/schnorr-login * Authenticates user via Schnorr signature (NIP-98) @@ -689,16 +716,36 @@ export async function handleSchnorrLogin(request, reply, provider) { const identity = authResult.webId; request.log.info({ identity, uid }, 'Schnorr auth verified'); - // Try to find an existing account linked to this identity + // Try to find an existing account linked to this identity. The + // primary path: identity is already a WebID (e.g. resolved via the + // existing did:nostr DID-doc resolver) and an account exists for it. let account = await findByWebId(identity); if (!account) { - // No account linked to this did:nostr - // For now, return error - user needs to link their did:nostr to an account - // Future: could auto-create account or prompt for linking + // Fallback: if the user typed a username on the login form, check + // whether the verified Nostr pubkey is declared as a CID + // verificationMethod referenced from `authentication` in that + // user's WebID profile (#400's IdP-side parallel — #403). The + // signature has already been verified above, so this is just + // "does this verified pubkey belong to the typed user". + const typedUsername = parseUsernameField(request); + if (typedUsername) { + const candidate = await findByUsername(typedUsername); + if (candidate?.webId) { + const pubkey = await getNostrPubkey(request); + if (pubkey && await verifyNostrPubkeyAgainstWebId(candidate.webId, pubkey)) { + account = candidate; + request.log.info({ accountId: account.id, webId: candidate.webId, uid }, + 'Schnorr login resolved via typed username + profile VM'); + } + } + } + } + + if (!account) { return reply.code(403).type('application/json').send({ success: false, - error: 'No account linked to this identity. Please register or link your Schnorr key to an existing account.' + error: 'No account linked to this identity. Type your username and add a Schnorr verificationMethod to your WebID profile (or link via did:nostr DID document).' }); } diff --git a/src/idp/views.js b/src/idp/views.js index 4bc6d9a2..828ba97e 100644 --- a/src/idp/views.js +++ b/src/idp/views.js @@ -381,12 +381,21 @@ export function loginPage(uid, clientId, error = null, passkeyEnabled = true, sc // Sign with NIP-07 extension const signedEvent = await window.nostr.signEvent(event); + // Read the typed username so the server can resolve which + // account this Nostr key belongs to, in case the existing + // did:nostr DID-doc resolver doesn't have a binding yet. + // The signature is verified BEFORE the username is consulted — + // typing someone else's username doesn't grant access. + const typedUsername = (document.getElementById('username')?.value || '').trim(); + // Send to server const response = await fetch(authUrl, { method: 'POST', headers: { - 'Authorization': 'Nostr ' + btoa(JSON.stringify(signedEvent)) - } + 'Authorization': 'Nostr ' + btoa(JSON.stringify(signedEvent)), + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: typedUsername ? 'username=' + encodeURIComponent(typedUsername) : '' }); const result = await response.json(); diff --git a/test/nostr-cid-vm.test.js b/test/nostr-cid-vm.test.js index cc0f0fe1..834b7673 100644 --- a/test/nostr-cid-vm.test.js +++ b/test/nostr-cid-vm.test.js @@ -16,7 +16,7 @@ import { describe, it, before, beforeEach, after } from 'node:test'; import assert from 'node:assert'; import { secp256k1 } from '@noble/curves/secp256k1'; import { generateSecretKey, getPublicKey, finalizeEvent } from '../src/nostr/event.js'; -import { verifyNostrAuth } from '../src/auth/nostr.js'; +import { verifyNostrAuth, verifyNostrPubkeyAgainstWebId } from '../src/auth/nostr.js'; import { _clearProfileCacheForTests } from '../src/auth/cid-doc-fetch.js'; /** Compute the BIP-340 even-y JWK coordinates for an x-only Nostr pubkey. */ @@ -492,6 +492,45 @@ describe('NIP-98 + CID verificationMethod lookup (#399)', () => { assert.strictEqual(r.webId, WEBID); }); + // --- IdP Schnorr-login helper (#403) --------------------------------- + + describe('verifyNostrPubkeyAgainstWebId', () => { + it('returns true when the pubkey is a Multikey VM in authentication', async () => { + _clearProfileCacheForTests(); + nextProfile = buildProfile({ pubkey: pk }); + const ok = await verifyNostrPubkeyAgainstWebId(WEBID, pk); + assert.strictEqual(ok, true); + }); + + it('returns false when the pubkey is in verificationMethod but NOT in authentication', async () => { + _clearProfileCacheForTests(); + nextProfile = buildProfile({ pubkey: pk, withAuth: false }); + const ok = await verifyNostrPubkeyAgainstWebId(WEBID, pk); + assert.strictEqual(ok, false); + }); + + it('returns false when the profile has no matching VM', async () => { + _clearProfileCacheForTests(); + const otherPk = getPublicKey(generateSecretKey()); + nextProfile = buildProfile({ pubkey: otherPk }); + const ok = await verifyNostrPubkeyAgainstWebId(WEBID, pk); + assert.strictEqual(ok, false); + }); + + it("returns false when the profile's @id differs from the asked WebID", async () => { + _clearProfileCacheForTests(); + nextProfile = { ...buildProfile({ pubkey: pk }), '@id': `${DOC_URL}#bob` }; + const ok = await verifyNostrPubkeyAgainstWebId(WEBID, pk); + assert.strictEqual(ok, false); + }); + + it('returns false on bad input', async () => { + assert.strictEqual(await verifyNostrPubkeyAgainstWebId('', pk), false); + assert.strictEqual(await verifyNostrPubkeyAgainstWebId(WEBID, 'not-hex'), false); + assert.strictEqual(await verifyNostrPubkeyAgainstWebId(WEBID, ''), false); + }); + }); + it('still rejects an invalid signature regardless of the profile', async () => { const url = `https://${POD_HOST}/private/data.ttl`; const { authHeader } = nip98Authorization({ method: 'GET', url, secretKey: sk }); From 3e5dc53e34e3c5aecac847ae704950a050ea1e4b Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Sun, 10 May 2026 01:24:45 +0200 Subject: [PATCH 2/2] Address copilot pass 1 on #405 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four findings, all real: 1. Controller consistency missing in verifyNostrPubkeyAgainstWebId (line 551). The resource-side path (tryResolveViaCidVerificationMethod) checks vm.controller against the profile's expected controller set; my new IdP-side helper skipped this. Now mirrors the resource path: VM controller MUST be in the profile's expected controller set, with empty set failing closed. New test exercises an attacker.example controller on an otherwise-valid VM → rejected. 2. Subject comparison was strict-equality `subject !== webId` (line 546). Docstring claimed "with or without #me" but the strict equality would have rejected a fragment-less webId against a #me profile. Now both sides go through absolutize so relative @ids and absent fragments don't accidentally pass or fail. Tightened docstring to require the fragment-bearing form while staying defensive about input. 3. Stale JSDoc above verifyNostrPubkeyAgainstWebId (line 516). The previous JSDoc for findNostrVmInProfile got orphaned when the new function landed between them. Moved the helper's JSDoc back above its function. 4. parseUsernameField didn't enforce MAX_BODY_SIZE (line 686). Now returns { tooLarge: true } when the body exceeds the limit; handleSchnorrLogin emits 413 with a JSON error, matching how handleLogin / handleRegisterPost handle the same case. Test count: 24 → 25 in this module. Full suite: 709 pass. --- src/auth/nostr.js | 53 ++++++++++++++++++++++++++++----------- src/idp/interactions.js | 21 +++++++++++++--- test/nostr-cid-vm.test.js | 14 +++++++++++ 3 files changed, 70 insertions(+), 18 deletions(-) diff --git a/src/auth/nostr.js b/src/auth/nostr.js index 31c314df..c5460f13 100644 --- a/src/auth/nostr.js +++ b/src/auth/nostr.js @@ -26,7 +26,7 @@ import { secp256k1 } from '@noble/curves/secp256k1'; import crypto from 'crypto'; import { resolveDidNostrToWebId } from './did-nostr.js'; import { fetchCidDocument } from './cid-doc-fetch.js'; -import { normalizeControllers } from './lws-cid.js'; +import { normalizeControllers } from './lws-cid.js'; // shared JSON-LD controller helper // NIP-98 event kind (references RFC 7235) const HTTP_AUTH_KIND = 27235; @@ -503,15 +503,6 @@ function firstHeaderValue(v) { return first || null; } -/** - * Find a verificationMethod whose key material matches the Nostr - * x-only pubkey hex. Two encodings supported: - * - f-form Multikey: publicKeyMultibase = "f" + "e701" + parity + xonly - * - JsonWebKey: publicKeyJwk.x = base64url(xonly) (kty:EC, crv:secp256k1) - * - * Returns the entry (object form) on match, normalized so .id is the - * absolute IRI. Returns null on no match. - */ /** * Confirm that a verified Nostr pubkey is declared as a CID * verificationMethod referenced from `authentication` in the given @@ -520,10 +511,17 @@ function firstHeaderValue(v) { * the IdP layer can derive the candidate WebID and ask this whether * the verified pubkey actually belongs to that WebID. * - * Returns true on match, false on no-match / fetch failure / VM not - * in authentication / controller mismatch. + * Mirrors the same controller-consistency / subject-identity / + * authentication-membership checks as the resource-side path + * (tryResolveViaCidVerificationMethod) so the two paths apply the + * same key-binding semantics. + * + * Returns true on match, false on any failure (fetch, VM not in + * authentication, subject mismatch, controller mismatch, bad input). * - * @param {string} webId - canonical WebID URI (with or without #me) + * @param {string} webId - canonical fragment-bearing WebID URI (e.g. + * `https://alice.example.com/profile/card.jsonld#me`). The profile's + * own `@id` must match this exactly after absolutization. * @param {string} pubkeyHex - 32-byte x-only Nostr pubkey hex * @returns {Promise} */ @@ -541,16 +539,41 @@ export async function verifyNostrPubkeyAgainstWebId(webId, pubkeyHex) { // Confirm the profile actually identifies itself as the WebID we're // asking about — otherwise a profile hosted at the WebID's URL could - // declare a different fragment as its subject and trick us. + // declare a different fragment as its subject and trick us. Both + // sides absolutized so a relative @id (e.g. "#me") resolves against + // docUrl and a webId without fragment (which the docstring no longer + // permits, but be defensive) doesn't accidentally match a fragment + // form. const subject = absolutize(profile['@id'] || profile.id, docUrl); - if (!subject || subject !== webId) return false; + const expectedSubject = absolutize(webId, docUrl); + if (!subject || subject !== expectedSubject) return false; const vm = findNostrVmInProfile(profile, pubkeyHex.toLowerCase(), docUrl); if (!vm) return false; if (!isInProofPurpose(profile, 'authentication', vm.id, docUrl)) return false; + + // Controller consistency: the VM's `controller` MUST be in the + // profile's expected controller set (declared `controller`, with + // @id fallback). Without this, a profile with a Nostr-keyed VM + // controlled by some unrelated identity would pass — a binding the + // actual subject never asserted. Mirrors the resource-path check. + const expectedCtrls = normalizeControllers(profile.controller ?? profile['@id'] ?? profile.id, docUrl); + if (expectedCtrls.length === 0) return false; + const vmCtrls = normalizeControllers(vm.controller, docUrl); + if (!vmCtrls.some((c) => expectedCtrls.includes(c))) return false; + return true; } +/** + * Find a verificationMethod whose key material matches the Nostr + * x-only pubkey hex. Two encodings supported: + * - f-form Multikey: publicKeyMultibase = "f" + "e701" + parity + xonly + * - JsonWebKey: publicKeyJwk.x = base64url(xonly) (kty:EC, crv:secp256k1) + * + * Returns the entry (object form) on match, normalized so .id is the + * absolute IRI. Returns null on no match. + */ function findNostrVmInProfile(profile, pubkeyHex, baseUrl) { const target = pubkeyHex.toLowerCase(); const targetB64u = hexToBase64url(target); diff --git a/src/idp/interactions.js b/src/idp/interactions.js index 2877dbe8..c1c1dfad 100644 --- a/src/idp/interactions.js +++ b/src/idp/interactions.js @@ -665,11 +665,19 @@ export async function handlePasskeySkip(request, reply, provider) { * (src/server.js), so request.body for application/x-www-form-urlencoded * arrives as a Buffer that needs string-decode + URLSearchParams. JSON * and already-parsed object bodies are also accepted for flexibility. + * + * Returns either: + * - { tooLarge: true } if the body exceeds MAX_BODY_SIZE (matching + * handleLogin / handleRegisterPost — caller emits 413). + * - { username: string } otherwise, possibly empty. */ function parseUsernameField(request) { const body = request.body; - if (!body) return ''; + if (!body) return { username: '' }; const ct = (request.headers?.['content-type'] || '').toLowerCase(); + if (Buffer.isBuffer(body) && body.length > MAX_BODY_SIZE) return { tooLarge: true }; + if (typeof body === 'string' && body.length > MAX_BODY_SIZE) return { tooLarge: true }; + let bag = {}; if (Buffer.isBuffer(body) || typeof body === 'string') { const s = Buffer.isBuffer(body) ? body.toString() : body; @@ -682,7 +690,7 @@ function parseUsernameField(request) { } else if (typeof body === 'object') { bag = body; } - return (bag.username || '').toString().trim(); + return { username: (bag.username || '').toString().trim() }; } /** @@ -728,7 +736,14 @@ export async function handleSchnorrLogin(request, reply, provider) { // user's WebID profile (#400's IdP-side parallel — #403). The // signature has already been verified above, so this is just // "does this verified pubkey belong to the typed user". - const typedUsername = parseUsernameField(request); + const parsed = parseUsernameField(request); + if (parsed.tooLarge) { + return reply.code(413).type('application/json').send({ + success: false, + error: 'Request body exceeds maximum size.', + }); + } + const typedUsername = parsed.username; if (typedUsername) { const candidate = await findByUsername(typedUsername); if (candidate?.webId) { diff --git a/test/nostr-cid-vm.test.js b/test/nostr-cid-vm.test.js index 834b7673..641fb7c7 100644 --- a/test/nostr-cid-vm.test.js +++ b/test/nostr-cid-vm.test.js @@ -529,6 +529,20 @@ describe('NIP-98 + CID verificationMethod lookup (#399)', () => { assert.strictEqual(await verifyNostrPubkeyAgainstWebId(WEBID, 'not-hex'), false); assert.strictEqual(await verifyNostrPubkeyAgainstWebId(WEBID, ''), false); }); + + it('returns false when VM controller is unrelated to profile controller', async () => { + _clearProfileCacheForTests(); + // VM with right Multikey but its controller points at a different + // identity — the profile's outer controller is the WebID, but the + // VM claims to be controlled by `https://attacker.example/#me`. + // This is the "key bound by an unrelated controller" attack the + // controller-consistency check defends against. + const profile = buildProfile({ pubkey: pk }); + profile.verificationMethod[0].controller = 'https://attacker.example/profile/card.jsonld#me'; + nextProfile = profile; + const ok = await verifyNostrPubkeyAgainstWebId(WEBID, pk); + assert.strictEqual(ok, false); + }); }); it('still rejects an invalid signature regardless of the profile', async () => {