diff --git a/server/src/app.ts b/server/src/app.ts index beda8c1..bbbfe89 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -43,9 +43,11 @@ export interface BuildAppOptions { export async function buildApp(opts: BuildAppOptions): Promise { const { config } = opts; + // Establish/preserve identity before opening or migrating application state. + // Missing keys beside existing data must not become a silent identity reset. + const serverKey = loadServerKey(config.DATA_DIR); const db = openDatabase(opts.dbPath ?? join(config.DATA_DIR, 'podsteadr.sqlite3')); const settings = new SettingsService(db, config); - const serverKey = loadServerKey(config.DATA_DIR); const app = Fastify({ logger: opts.logger ?? true, bodyLimit: 10 * 1024 * 1024 }); diff --git a/server/src/provision-server-key.ts b/server/src/provision-server-key.ts new file mode 100644 index 0000000..e65813c --- /dev/null +++ b/server/src/provision-server-key.ts @@ -0,0 +1,15 @@ +// Explicit key-only bootstrap for a reviewed installer/fixture. No server, +// database, relay, financial service or network configuration is loaded here. +import { getPublicKey } from 'nostr-tools/pure'; +import { loadServerKey } from './services/server-key.js'; + +try { + if (process.argv.length !== 3 || !process.argv[2].startsWith('/')) { + throw new Error('An absolute existing data directory is required'); + } + const publicKey = getPublicKey(loadServerKey(process.argv[2])); + process.stdout.write(JSON.stringify({ version: 1, publicKey }) + '\n'); +} catch { + process.stderr.write('Server identity provisioning refused; preserve existing data and inspect configuration\n'); + process.exitCode = 1; +} diff --git a/server/src/services/nostr.ts b/server/src/services/nostr.ts index d75c96e..cd1d6e3 100644 --- a/server/src/services/nostr.ts +++ b/server/src/services/nostr.ts @@ -1,25 +1,11 @@ -import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'; -import { join } from 'node:path'; -import { finalizeEvent, generateSecretKey, getPublicKey, type Event } from 'nostr-tools/pure'; +import { finalizeEvent, getPublicKey, type Event } from 'nostr-tools/pure'; import { SimplePool, useWebSocketImplementation } from 'nostr-tools/pool'; -import { hexToBytes, bytesToHex } from 'nostr-tools/utils'; import WebSocket from 'ws'; import type { Stream } from '../types.js'; useWebSocketImplementation(WebSocket); -/** Load the server's nostr identity, generating one on first boot. */ -export function loadServerKey(dataDir: string): Uint8Array { - const dir = join(dataDir, 'config'); - const file = join(dir, 'server-nostr-key'); - if (existsSync(file)) { - return hexToBytes(readFileSync(file, 'utf8').trim()); - } - mkdirSync(dir, { recursive: true }); - const key = generateSecretKey(); - writeFileSync(file, bytesToHex(key), { mode: 0o600 }); - return key; -} +export { loadServerKey } from './server-key.js'; export interface LiveEventInput { stream: Stream; diff --git a/server/src/services/server-key.test.ts b/server/src/services/server-key.test.ts new file mode 100644 index 0000000..630ad74 --- /dev/null +++ b/server/src/services/server-key.test.ts @@ -0,0 +1,119 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { mkdtempSync, rmSync, mkdirSync, readFileSync, writeFileSync, statSync, lstatSync, symlinkSync, linkSync, chmodSync, readdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { execFile, execFileSync } from 'node:child_process'; +import { promisify } from 'node:util'; +import { getPublicKey } from 'nostr-tools/pure'; +import { loadServerKey } from './server-key.js'; + +const roots: string[] = []; +const root = () => { const dir = mkdtempSync(join(tmpdir(), 'podsteadr-key-')); roots.push(dir); return dir; }; +const keyFile = (dir: string) => join(dir, 'config', 'server-nostr-key'); +const seed = (dir: string, value: string) => { + mkdirSync(join(dir, 'config'), { mode: 0o700 }); + writeFileSync(keyFile(dir), value, { mode: 0o600 }); +}; +afterEach(() => { for (const dir of roots.splice(0)) rmSync(dir, { recursive: true, force: true }); }); + +// CLI tests exercise compiled production bytes; run npm run build first. +const cli = resolve('dist/provision-server-key.js'); +const run = promisify(execFile); + +describe('durable server identity', () => { + it('creates once with private mode and preserves exact inode/bytes', () => { + const dir = root(); + const key = loadServerKey(dir); + const before = statSync(keyFile(dir)); + const bytes = readFileSync(keyFile(dir)); + expect(before.mode & 0o777).toBe(0o600); + expect(getPublicKey(loadServerKey(dir))).toBe(getPublicKey(key)); + expect(statSync(keyFile(dir)).ino).toBe(before.ino); + expect(readFileSync(keyFile(dir))).toEqual(bytes); + expect(readdirSync(join(dir, 'config'))).toEqual(['server-nostr-key']); + }); + + it.each(['', 'truncated', '0'.repeat(64), 'f'.repeat(64), 'a'.repeat(10000)])('refuses invalid data without replacement (%#)', value => { + const dir = root(); seed(dir, value); + const before = statSync(keyFile(dir)); + expect(() => loadServerKey(dir)).toThrow('Server identity unavailable'); + expect(readFileSync(keyFile(dir), 'utf8')).toBe(value); + expect(statSync(keyFile(dir)).ino).toBe(before.ino); + }); + + it.each(['podsteadr.sqlite3', 'config/operator-state'])('refuses missing identity beside existing data (%s)', name => { + const dir = root(); mkdirSync(join(dir, 'config'), { mode: 0o700 }); + const sentinel = join(dir, name); writeFileSync(sentinel, 'preserved application state'); + expect(() => loadServerKey(dir)).toThrow('Server identity unavailable'); + expect(readFileSync(sentinel, 'utf8')).toBe('preserved application state'); + expect(() => statSync(keyFile(dir))).toThrow(); + }); + + it('preserves legacy valid trailing-newline bytes, does not normalize or rotate', () => { + const dir = root(); const value = '01'.repeat(32) + '\n'; seed(dir, value); + expect(getPublicKey(loadServerKey(dir))).toMatch(/^[a-f0-9]{64}$/); + expect(readFileSync(keyFile(dir), 'utf8')).toBe(value); + }); + + it('refuses key symlinks without touching the target', () => { + const dir = root(); mkdirSync(join(dir, 'config')); + const target = join(dir, 'sentinel'); writeFileSync(target, '01'.repeat(32), { mode: 0o600 }); + symlinkSync(target, keyFile(dir)); + expect(() => loadServerKey(dir)).toThrow('Server identity unavailable'); + expect(lstatSync(keyFile(dir)).isSymbolicLink()).toBe(true); + expect(readFileSync(target, 'utf8')).toBe('01'.repeat(32)); + }); + + it('refuses config and data-directory symlinks', () => { + const dir = root(); const target = root(); + symlinkSync(target, join(dir, 'config')); + expect(() => loadServerKey(dir)).toThrow('Server identity unavailable'); + expect(readdirSync(target)).toEqual([]); + const alias = join(root(), 'alias'); symlinkSync(target, alias); + expect(() => loadServerKey(alias)).toThrow('Server identity unavailable'); + }); + + it('refuses hardlinks and overly broad file/directory permissions without chmod', () => { + const dir = root(); seed(dir, '01'.repeat(32)); + const alias = join(dir, 'copy'); linkSync(keyFile(dir), alias); + expect(() => loadServerKey(dir)).toThrow('Server identity unavailable'); + rmSync(alias); chmodSync(keyFile(dir), 0o644); + expect(() => loadServerKey(dir)).toThrow('Server identity unavailable'); + expect(statSync(keyFile(dir)).mode & 0o777).toBe(0o644); + chmodSync(keyFile(dir), 0o600); chmodSync(join(dir, 'config'), 0o777); + expect(() => loadServerKey(dir)).toThrow('Server identity unavailable'); + expect(statSync(join(dir, 'config')).mode & 0o777).toBe(0o777); + }); + + it('refuses FIFO instead of blocking', () => { + const dir = root(); mkdirSync(join(dir, 'config')); + execFileSync('mkfifo', [keyFile(dir)]); + expect(() => loadServerKey(dir)).toThrow('Server identity unavailable'); + expect(lstatSync(keyFile(dir)).isFIFO()).toBe(true); + }); + + it('bootstrap emits only public identity and creates no service/database state', async () => { + const dir = root(); + const result = await run(process.execPath, [cli, dir], { timeout: 5000 }); + expect(result.stderr).toBe(''); + expect(JSON.parse(result.stdout)).toEqual({ version: 1, publicKey: getPublicKey(loadServerKey(dir)) }); + expect(result.stdout).not.toContain(readFileSync(keyFile(dir), 'utf8')); + expect(readdirSync(dir)).toEqual(['config']); + }); + + it('concurrent bootstrap cannot overwrite the winning identity', async () => { + const dir = root(); + const results = await Promise.allSettled(Array.from({ length: 8 }, () => run(process.execPath, [cli, dir], { timeout: 10000 }))); + const publicKey = getPublicKey(loadServerKey(dir)); + const successes = results.filter(result => result.status === 'fulfilled'); + expect(successes.length).toBeGreaterThan(0); + for (const result of successes) if (result.status === 'fulfilled') { + expect(JSON.parse(result.value.stdout)).toEqual({ version: 1, publicKey }); + } + // A reader racing the temporary second hardlink may safely refuse; it must + // not replace the winner. A later explicit retry reads the stable identity. + const retried = await run(process.execPath, [cli, dir], { timeout: 5000 }); + expect(JSON.parse(retried.stdout)).toEqual({ version: 1, publicKey }); + expect(readdirSync(join(dir, 'config'))).toEqual(['server-nostr-key']); + }, 15000); +}); diff --git a/server/src/services/server-key.ts b/server/src/services/server-key.ts new file mode 100644 index 0000000..01f43cb --- /dev/null +++ b/server/src/services/server-key.ts @@ -0,0 +1,73 @@ +import { constants, openSync, closeSync, fstatSync, mkdirSync, readdirSync, readSync, writeFileSync, fsyncSync, linkSync, unlinkSync } from 'node:fs'; +import { randomUUID } from 'node:crypto'; +import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'; +import { bytesToHex, hexToBytes } from 'nostr-tools/utils'; + +const failure = () => new Error('Server identity unavailable: preserve existing data and inspect provisioning'); +const codeIs = (error: unknown, code: string) => (error as NodeJS.ErrnoException)?.code === code; + +/** Linux container contract: pin directory descriptors, never follow key/config + * symlinks, never overwrite an identity. A separate bootstrap can call this before + * dependent services start; it must expose only getPublicKey(result). + */ +export function loadServerKey(dataDir: string): Uint8Array { + let root: number | undefined; + let directory: number | undefined; + try { + root = openSync(dataDir, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW); + const rootStat = fstatSync(root); + if (rootStat.uid !== process.geteuid!() || (rootStat.mode & 0o022) !== 0) throw failure(); + const config = `/proc/self/fd/${root}/config`; + try { mkdirSync(config, { mode: 0o700 }); fsyncSync(root); } + catch (error) { if (!codeIs(error, 'EEXIST')) throw error; } + directory = openSync(config, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW); + const stat = fstatSync(directory); + if (stat.uid !== process.geteuid!() || (stat.mode & 0o022) !== 0) throw failure(); + const base = `/proc/self/fd/${directory}`; + const file = `${base}/server-nostr-key`; + const read = () => { + const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + try { + const info = fstatSync(fd); + if (!info.isFile() || info.nlink !== 1 || info.uid !== process.geteuid!() + || (info.mode & 0o777) !== 0o600 || info.size < 64 || info.size > 128) throw failure(); + const buffer = Buffer.alloc(129); + const length = readSync(fd, buffer, 0, buffer.length, 0); + if (length !== info.size) throw failure(); + const encoded = buffer.subarray(0, length).toString('utf8').trim(); + if (!/^[a-f0-9]{64}$/.test(encoded)) throw failure(); + const key = hexToBytes(encoded); + getPublicKey(key); // Validate scalar, including zero/out-of-range refusal. + return key; + } finally { closeSync(fd); } + }; + try { return read(); } + catch (error) { if (!codeIs(error, 'ENOENT')) throw error; } + + // Missing identity in an existing installation is loss, not first boot. + // Concurrent fresh creators may have only their unpublished temporary files. + if (readdirSync(`/proc/self/fd/${root}`).some(name => name !== 'config') + || readdirSync(base).some(name => !/^\.server-key-[0-9a-f-]{36}$/.test(name))) { + // Another creator may have published since our initial read. Reload it, + // but never generate a replacement for a missing established identity. + return read(); + } + + // Publish a fully written/fsynced inode using no-clobber hard-link creation. + // Concurrent creators all reload the winner; no rename can replace its key. + const temporary = `${base}/.server-key-${randomUUID()}`; + const fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); + try { + try { writeFileSync(fd, bytesToHex(generateSecretKey())); fsyncSync(fd); } + finally { closeSync(fd); } + try { linkSync(temporary, file); } + catch (error) { if (!codeIs(error, 'EEXIST')) throw error; } + } finally { unlinkSync(temporary); } + fsyncSync(directory); + return read(); + } catch { throw failure(); } + finally { + if (directory !== undefined) closeSync(directory); + if (root !== undefined) closeSync(root); + } +}