Files
archy/docker/podsteadr/podsteadr-server-key.patch

275 lines
14 KiB
Diff

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<FastifyInstance> {
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);
+ }
+}