refactor(persistence): migrate share-store to canonical atomic-write util (DC-102) [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s

Drops share-store's private _atomicWriteJSON copy (pid+Date.now() tmp
names, no fsync, no failure cleanup) in favor of src/utils/atomic-write.js:
fsync'd same-dir tmp+rename, exclusive-create 0600, parent-dir fsync,
cleanup-on-failure. Also fixes a latent bug in the same file: .share-secret
signing-key persistence used plain fs.writeFileSync — a torn write would
silently rotate the HMAC key on next boot, invalidating every outstanding
share signature (links 404, no error logged). Now atomicWriteFile.
Sole consumers parse JSON / trim(), so the dropped trailing newline on
shares.json is unobservable. +2 regression tests pin 0600 on both files,
complete content, zero tmp leftovers, and HMAC-still-verifies via getRaw.
Judge: GLM-5.3 cold read, round-1 A, deleg_50d5c239 (41s, 4 calls).
Verdict: urn:ump:ehblegyr5eko4hgyfylnrqk72hh3crmc43o2yt5ragfgvyc4zrdq
Full suite: 121 suites / 2774 tests green.
This commit is contained in:
Hermes
2026-08-23 00:50:47 -07:00
parent 7fd651f388
commit 521b2f24a1
2 changed files with 75 additions and 11 deletions
@@ -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([]);
});
});
+12 -11
View File
@@ -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;