diff --git a/src/server.js b/src/server.js index 32f3cf9..ae11b6f 100644 --- a/src/server.js +++ b/src/server.js @@ -743,6 +743,14 @@ export function createServer(options = {}) { '/.well-known/did/nostr/', '/.well-known/did/nostr/:pubkeyAndExt', '/.well-known/did/nostr/*', + // /.well-known/nostr.json — NIP-05 mapping (#446). Same WAC-bypass + // concern as /.well-known/did/nostr/*: the global preHandler skips + // auth for /.well-known/* (the spec-mandated public namespace), + // so without 405 blocks the wildcard write handlers would let + // anyone PUT/DELETE/PATCH this file and hijack the pod's NIP-05 + // identity. GET/HEAD reach the LDP layer normally and serve the + // file written by --provision-keys. + '/.well-known/nostr.json', ]) { fastify.put(pat, methodNotAllowed); fastify.post(pat, methodNotAllowed); @@ -891,6 +899,30 @@ export function createServer(options = {}) { 'Filesystem reads bypass WAC; use FDE / OS keyring / restrictive umask ' + 'for any pod that matters. See docs/provision-keys.md.' ); + + // NIP-05 mapping for the bare domain (#446). Lives at the + // server root regardless of whether the pod is at / or + // // — `.well-known/` is per-origin, not per-pod. + // This branch covers BOTH single-user shapes (root pod via + // createRootPodStructure, named pod via createPodStructure) + // because the file is logically server-level identity, not + // pod-internal data. Multi-user aggregation is the next + // slice of #445. WAC bypass on /.well-known/* is balanced + // by the 405 method-not-allowed guards registered earlier + // so an attacker can't PUT-overwrite this mapping. + await storage.createContainer('/.well-known/'); + const nip05Ok = await storage.write( + '/.well-known/nostr.json', + JSON.stringify({ names: { _: creation.ownerKey.publicHex } }, null, 2) + ); + if (!nip05Ok) { + fastify.log.warn( + 'Failed to write /.well-known/nostr.json — pod is provisioned ' + + 'but NIP-05 verification will not resolve to this server.' + ); + } else { + fastify.log.info(`NIP-05 mapping at ${baseUrl}/.well-known/nostr.json`); + } } } @@ -1128,6 +1160,10 @@ export function createServer(options = {}) { ); } } + // NIP-05 mapping is written outside this function (in the + // single-user onReady block) so it covers both root pods and + // named single-user pods (which take the createPodStructure + // path), not just the root case. See #446. // Generate profile (with the owner key's VM landed in // verificationMethod when --provision-keys is on). Written last — diff --git a/test/well-known-nostr-json.test.js b/test/well-known-nostr-json.test.js new file mode 100644 index 0000000..ee160dd --- /dev/null +++ b/test/well-known-nostr-json.test.js @@ -0,0 +1,173 @@ +/** + * NIP-05 MVP — /.well-known/nostr.json on single-user pods (#446). + * + * Implementation is a static file written during pod creation, not + * a server-side handler. JSS already exposes /.well-known/* as a + * public namespace (WAC bypass + dotfile allow-list); the LDP GET + * handler serves the file like any other resource. + * + * Acceptance: + * - single-user + --provision-keys → 200 { names: { _: } } + * served at /.well-known/nostr.json, publicly readable, with + * CORS open for browser-based NIP-05 verifiers. + * - single-user without --provision-keys → no file written. + * - multi-user → no file written (next slice of #445 aggregates). + */ + +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert'; +import fs from 'fs-extra'; +import { createServer } from '../src/server.js'; + +// Per-describe DATA_DIR suffixes so the three suites in this file +// don't collide if --test-concurrency is ever raised above 1. +// Currently 1 per the npm test script, but the latent risk was +// flagged in the #447 review. +async function startServer(dataDir, options = {}) { + await fs.remove(dataDir); + await fs.ensureDir(dataDir); + const server = createServer({ + logger: false, + forceCloseConnections: true, + root: dataDir, + ...options + }); + await server.listen({ port: 0, host: '127.0.0.1' }); + const baseUrl = `http://127.0.0.1:${server.server.address().port}`; + return { server, baseUrl }; +} + +async function stopServer(server, dataDir) { + await server.close(); + await fs.remove(dataDir); +} + +describe('NIP-05 MVP — single-user with provisioned key', () => { + const DATA_DIR = './test-data-nip05-with-key'; + let server, baseUrl; + let savedDataRoot; + + before(async () => { + savedDataRoot = process.env.DATA_ROOT; + ({ server, baseUrl } = await startServer(DATA_DIR, { + singleUser: true, + provisionKeys: true + })); + }); + + after(async () => { + await stopServer(server, DATA_DIR); + if (savedDataRoot === undefined) delete process.env.DATA_ROOT; + else process.env.DATA_ROOT = savedDataRoot; + }); + + it('writes the NIP-05 mapping with the reserved `_` name', async () => { + const onDisk = JSON.parse( + await fs.readFile(`${DATA_DIR}/.well-known/nostr.json`, 'utf8') + ); + assert.deepStrictEqual(Object.keys(onDisk.names), ['_'], + 'MVP emits exactly one mapping (the `_` reserved name)'); + assert.match(onDisk.names._, /^[0-9a-f]{64}$/, + 'the `_` mapping must be a 32-byte hex pubkey'); + }); + + it('serves the mapping over HTTP without auth (NIP-05 verifiers are unauth)', async () => { + const res = await fetch(`${baseUrl}/.well-known/nostr.json`); + assert.strictEqual(res.status, 200); + const body = await res.json(); + assert.match(body.names._, /^[0-9a-f]{64}$/); + }); + + it('matches the same pubkey landed in the WebID profile VM', async () => { + // Cross-check: the NIP-05 pubkey and the profile's + // verificationMethod publicKeyMultibase should describe the + // same key. If they ever diverge, an LWS-CID verifier would + // accept the profile's VM but a NIP-05 verifier would attest + // to a different identity for the same domain. + const onDisk = JSON.parse( + await fs.readFile(`${DATA_DIR}/.well-known/nostr.json`, 'utf8') + ); + const profile = JSON.parse( + await fs.readFile(`${DATA_DIR}/profile/card.jsonld`, 'utf8') + ); + const vm = profile.verificationMethod[0]; + assert.ok(vm.publicKeyMultibase.includes(onDisk.names._), + 'NIP-05 pubkey hex must appear in the profile VM publicKeyMultibase'); + }); +}); + +describe('NIP-05 MVP — single-user without a provisioned key', () => { + const DATA_DIR = './test-data-nip05-no-key'; + let server; + let savedDataRoot; + + before(async () => { + savedDataRoot = process.env.DATA_ROOT; + ({ server } = await startServer(DATA_DIR, { singleUser: true })); + }); + + after(async () => { + await stopServer(server, DATA_DIR); + if (savedDataRoot === undefined) delete process.env.DATA_ROOT; + else process.env.DATA_ROOT = savedDataRoot; + }); + + it('does not create the file (nothing to publish)', async () => { + assert.strictEqual( + await fs.pathExists(`${DATA_DIR}/.well-known/nostr.json`), + false, + 'no key → no NIP-05 mapping → no file' + ); + }); +}); + +describe('NIP-05 MVP — multi-user mode', () => { + const DATA_DIR = './test-data-nip05-multiuser'; + let server, baseUrl; + let savedDataRoot; + + before(async () => { + savedDataRoot = process.env.DATA_ROOT; + // No singleUser → multi-user. The MVP only writes the NIP-05 + // file in single-user mode; aggregation across multi-user pods + // is the next slice of #445. + ({ server, baseUrl } = await startServer(DATA_DIR, {})); + }); + + after(async () => { + await stopServer(server, DATA_DIR); + if (savedDataRoot === undefined) delete process.env.DATA_ROOT; + else process.env.DATA_ROOT = savedDataRoot; + }); + + it('still does not write a server-level NIP-05 file when a pod is provisioned with keys', async () => { + // Actually exercise the code path that *could* have written the + // file — provision a multi-user pod with provisionKeys: true via + // POST /.pods. The pod's own //private/privkey.jsonld + // lands as expected, but the server-level /.well-known/nostr.json + // must remain absent because this is multi-user mode. + const res = await fetch(`${baseUrl}/.pods`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'alice', provisionKeys: true }) + }); + assert.strictEqual(res.status, 201, 'pod creation must succeed'); + const body = await res.json(); + assert.ok(body.ownerKey, 'pod-level owner key must still be provisioned'); + + // The pod's own privkey IS on disk — sanity check the multi-user + // provisioning path still ran. + assert.strictEqual( + await fs.pathExists(`${DATA_DIR}/alice/private/privkey.jsonld`), + true, + 'multi-user pod provisioning still writes the pod-internal privkey' + ); + + // …but no server-level NIP-05 file. + assert.strictEqual( + await fs.pathExists(`${DATA_DIR}/.well-known/nostr.json`), + false, + 'multi-user mode must not write a server-level NIP-05 mapping' + ); + }); +});