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
64 changes: 63 additions & 1 deletion src/auth/nostr.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<boolean>}
*/
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;
Comment on lines +540 to +565
}

/**
* Find a verificationMethod whose key material matches the Nostr
* x-only pubkey hex. Two encodings supported:
Expand Down
76 changes: 69 additions & 7 deletions src/idp/interactions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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() };
}
Comment on lines +674 to +694

/**
* Handle POST /idp/interaction/:uid/schnorr-login
* Authenticates user via Schnorr signature (NIP-98)
Expand Down Expand Up @@ -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).'
});
}

Expand Down
13 changes: 11 additions & 2 deletions src/idp/views.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
55 changes: 54 additions & 1 deletion test/nostr-cid-vm.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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 });
Expand Down