diff --git a/src/auth/nostr.js b/src/auth/nostr.js index ca649a18..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,6 +503,68 @@ function firstHeaderValue(v) { return first || null; } +/** + * 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. + * + * 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 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} + */ +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. 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); + 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: diff --git a/src/idp/interactions.js b/src/idp/interactions.js index d2ebfd0e..c1c1dfad 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,41 @@ 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. + * + * 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 { 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; + 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 { username: (bag.username || '').toString().trim() }; +} + /** * Handle POST /idp/interaction/:uid/schnorr-login * Authenticates user via Schnorr signature (NIP-98) @@ -689,16 +724,43 @@ 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 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) { + 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..641fb7c7 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,59 @@ 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('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 () => { const url = `https://${POD_HOST}/private/data.ttl`; const { authHeader } = nip98Authorization({ method: 'GET', url, secretKey: sk });