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
36 changes: 36 additions & 0 deletions src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
// /<name>/ — `.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)
);
Comment on lines +913 to +917
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed: PR description updated to remove the misleading 'lock it down with .acl' claim. The file is intentionally public per NIP-05 spec and /.well-known/* is WAC-bypassed at the request-handler layer — same bypass that already exempts /.well-known/openid-configuration and /.well-known/did/nostr/*. The new description spells out that operator-written .acls don't apply here and recommends overwriting the file content if a different mapping is wanted. (No code change for this one — the bypass is correct, only the docs were wrong.)

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`);
}
}
}

Expand Down Expand Up @@ -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 —
Expand Down
173 changes: 173 additions & 0 deletions test/well-known-nostr-json.test.js
Original file line number Diff line number Diff line change
@@ -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: { _: <hex> } }
* 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 /<name>/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'
);
});
});
Comment on lines +123 to +173
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed: the multi-user test now actually creates a pod via POST /.pods with provisionKeys: true, asserts the pod-internal privkey DOES land (provisioning ran), and the server-level /.well-known/nostr.json still doesn't. Real multi-user-skip behaviour verified.