diff --git a/dashcaddy-api/__tests__/atomic-write-dc099.test.js b/dashcaddy-api/__tests__/atomic-write-dc099.test.js index 195de70..40a6527 100644 --- a/dashcaddy-api/__tests__/atomic-write-dc099.test.js +++ b/dashcaddy-api/__tests__/atomic-write-dc099.test.js @@ -297,3 +297,66 @@ describe('DC-101 user-store on canonical atomic-write (real fs)', () => { expect(leftovers).toEqual([]); }); }); + +// DC-102: share-store migrated off its private _atomicWriteJSON copy onto +// the canonical writer. Store-level pins: shares.json AND the .share-secret +// signing key land as complete content at mode 0600 with no temp leftovers — +// a torn secret write would silently rotate the key and invalidate every +// outstanding share signature on next boot. +describe('DC-102 share-store on canonical atomic-write (real fs)', () => { + let dir, store; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc102-share-')); + store = require('../src/security/share-store').createShareStore({ dataDir: dir }); + }); + afterEach(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) {} }); + + test('issued share + persisted signing secret land at 0600, complete, no temp leftovers', async () => { + const r = await store.issuePublic({ serviceId: 'svc', ttlMs: 60 * 60 * 1000 }); + expect(r.ok).toBe(true); + + const sharesFile = path.join(dir, 'shares.json'); + const secretFile = path.join(dir, '.share-secret'); + const sharesSt = fs.statSync(sharesFile); + const secretSt = fs.statSync(secretFile); + expect(sharesSt.mode & 0o777).toBe(0o600); + expect(secretSt.mode & 0o777).toBe(0o600); + + // complete JSON — a torn write would fail JSON.parse right here + const data = JSON.parse(fs.readFileSync(sharesFile, 'utf8')); + expect(Object.keys(data.shares)).toHaveLength(1); + // complete secret — readable, 32+ bytes after trim, trailing newline kept + const secret = fs.readFileSync(secretFile, 'utf8'); + expect(secret.trim().length).toBeGreaterThanOrEqual(32); + expect(secret.endsWith('\n')).toBe(true); + + const leftovers = fs.readdirSync(dir).filter((f) => f !== 'shares.json' && f !== '.share-secret'); + expect(leftovers).toEqual([]); + }); + + test('back-to-back mutations (issue x2, subscribe, tailscale use, revoke) never collide on tmp names', async () => { + const a = await store.issuePublic({ serviceId: 'svc', subscribeCap: 5 }); + const b = await store.issueTailscale({ serviceId: 'svc', email: 'dc102@x.com' }); + await store.recordPublicSubscribe(a.token, { email: 'sub@x.com' }); + await store.recordTailscaleUse(b.token, { deviceId: 'device-1' }); + await store.revoke(a.id); + + // b remains outstanding and fully redeemable state on disk + const data = JSON.parse(fs.readFileSync(path.join(dir, 'shares.json'), 'utf8')); + expect(Object.keys(data.shares)).toEqual([b.id]); + expect(data.shares[b.id].usedAt).toBeTruthy(); + expect(data.shares[b.id].usedBy).toBe('device-1'); + + // signature verification still passes against the atomically persisted + // secret — getRaw checks hash + HMAC only (not used-state), so a rotated + // or torn secret would return null here. + const raw = await store.getRaw(b.token); + expect(raw).toBeTruthy(); + expect(raw.id).toBe(b.id); + expect(raw.kind).toBe('tailscale'); + + const leftovers = fs.readdirSync(dir).filter((f) => f !== 'shares.json' && f !== '.share-secret'); + expect(leftovers).toEqual([]); + }); +}); diff --git a/dashcaddy-api/src/security/share-store.js b/dashcaddy-api/src/security/share-store.js index ac3279d..62509ff 100644 --- a/dashcaddy-api/src/security/share-store.js +++ b/dashcaddy-api/src/security/share-store.js @@ -12,9 +12,10 @@ * to a device tag. Invitee clicks the link → device joins the tailnet → * Caddy forward_auth inducts them into the service. Single-use, 24h TTL. * - * Storage: data/shares.json. Atomic writes via tmp+rename. The on-disk shape - * is identical to the invite store — UUID-keyed map of records with SHA-256 - * hashed tokens. Raw token is only returned at issue() time. + * Storage: data/shares.json. Atomic durable writes via the canonical + * shared atomic-write util (DC-099/DC-102) — fsync'd tmp+rename. The on-disk + * shape is identical to the invite store — UUID-keyed map of records with + * SHA-256 hashed tokens. Raw token is only returned at issue() time. * * Public-share token also carries a HMAC signature binding it to the * serviceId so a leaked token cannot be silently retargeted. The signature @@ -37,6 +38,7 @@ const path = require('path'); const fs = require('fs'); const crypto = require('crypto'); const platformPaths = require('../../platform-paths'); +const { atomicWriteFile, atomicWriteJSON } = require('../utils/atomic-write'); const DEFAULT_PUBLIC_TTL_MS = 24 * 60 * 60 * 1000; // 24h const DEFAULT_TAILSCALE_TTL_MS = 24 * 60 * 60 * 1000; // 24h @@ -97,12 +99,6 @@ function validatePublicDeviceId(raw) { function _nowMs() { return Date.now(); } function _nowIso() { return new Date().toISOString(); } -function _atomicWriteJSON(filePath, data) { - const tmp = filePath + '.tmp.' + process.pid + '.' + Date.now(); - fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 }); - fs.renameSync(tmp, filePath); -} - function _readJSON(filePath, fallback) { try { const raw = fs.readFileSync(filePath, 'utf8'); @@ -150,7 +146,12 @@ function createShareStore(opts = {}) { } catch (_) { /* missing or unreadable — generate fresh */ } const fresh = crypto.randomBytes(32).toString('base64url'); try { - fs.writeFileSync(_secretFile, fresh + '\n', { mode: 0o600 }); + // DC-102: canonical atomic write. A torn `.share-secret` write would + // silently rotate the signing key on next boot — invalidating every + // outstanding share signature (all peeks fail, links 404) — with no + // error anywhere. fsync'd tmp+rename guarantees the file is either the + // complete old secret or the complete new one. + atomicWriteFile(_secretFile, fresh + '\n'); } catch (err) { log.warn && log.warn('share', 'failed to persist signing secret', { err: err && err.message }); } @@ -170,7 +171,7 @@ function createShareStore(opts = {}) { if (!data.shares || typeof data.shares !== 'object') data.shares = {}; return data; } - function _save(data) { _atomicWriteJSON(file, data); } + function _save(data) { atomicWriteJSON(file, data); } function _prune(data) { const cutoff = _nowMs() - PRUNE_AFTER_MS;