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
+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;