Files
dashcaddy/dashcaddy-api/src/security/share-store.js
T
Krystie d9e61ce1b7
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
DC-053: Public share links + Tailscale-mediated share (Pro-gated)
- Share-store: HMAC-signed tokens bound to serviceId+kind, persistent
  signing secret in dataDir/.share-secret, atomic writes, auto-prune
- Routes: admin endpoints gated on licenseManager.isPro() (402 Free);
  public endpoints CSRF-exempt (token IS proof)
- Tailscale path: mints single-use ephemeral pre-auth key, emails
  join link, rolls back share record if createAuthKey throws
- Email-failure path: exposes urlPath for manual delivery fallback
- 53 new tests (24 store + 29 routes), full suite 1372/1372
- Drift-test parser hardened against quoted-word comments
- share-store dataDir resolver handles Proxy/function values

CHANGELOG + BACKLOG updated.
2026-07-21 00:45:46 -07:00

414 lines
14 KiB
JavaScript

/**
* Share store — DC-053.
*
* Signed share tokens that let the host share a service with non-authenticated
* visitors. Two flavors:
*
* 1. **Public share links** — anonymous-readable preview URLs. Visitor sees
* a service card + status; no auth required. Host sets a TTL (1h / 24h /
* 7d). Optional email subscribe to receive status-change notifications.
*
* 2. **Tailscale-mediated share** — a one-shot Tailscale pre-auth key scoped
* 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.
*
* Public-share token also carries a HMAC signature binding it to the
* serviceId so a leaked token cannot be silently retargeted. The signature
* is verified at peek() time using a server-side secret (licenseManager's
* install secret if available, otherwise a derived per-store key).
*
* Lifecycle:
* - issuePublic({ serviceId, ttlMs, createdBy }) → { id, token, url, expiresAt }
* - issueTailscale({ serviceId, email, ttlMs, createdBy }) → { id, token, url, expiresAt, authKeyId }
* - peek(token) → { kind, serviceId, expiresAt, remainingUses, usedAt? } | null
* - recordUse(token, { kind: 'public-subscribe' }) → { ok, count } | { ok: false, reason }
* - revoke(id) → boolean
* - list() → outstanding shares (admin view)
* - listForService(serviceId) → outstanding shares for a specific service
*/
'use strict';
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const platformPaths = require('../../platform-paths');
const DEFAULT_PUBLIC_TTL_MS = 24 * 60 * 60 * 1000; // 24h
const DEFAULT_TAILSCALE_TTL_MS = 24 * 60 * 60 * 1000; // 24h
const MAX_PUBLIC_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7d
const MAX_TAILSCALE_TTL_MS = 24 * 60 * 60 * 1000; // 24h
const PRUNE_AFTER_MS = 7 * 24 * 60 * 60 * 1000; // auto-prune used/expired after 7d
const ALLOWED_PUBLIC_TTLS = new Set([60 * 60 * 1000, 24 * 60 * 60 * 1000, 7 * 24 * 60 * 60 * 1000]);
const TAILSCALE_MAX_USES = 1;
const PUBLIC_DEFAULT_SUBSCRIBE_CAP = 1000; // bound on subscribe events per link
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');
return JSON.parse(raw);
} catch (err) {
if (err && err.code === 'ENOENT') return fallback;
return fallback;
}
}
function _defaultData() { return { shares: {} }; }
function _sha256(s) {
return crypto.createHash('sha256').update(s, 'utf8').digest('hex');
}
function _hmacSign(secret, payload) {
return crypto.createHmac('sha256', secret).update(payload, 'utf8').digest('base64url');
}
function createShareStore(opts = {}) {
// Defensive resolver mirrors user-store / invite-store.
const candidates = [
opts.dataDir,
opts.platformPaths && opts.platformPaths.dataDir,
platformPaths && platformPaths.dataDir,
];
const dataDir = candidates.find(c => typeof c === 'string' && c.length > 0)
|| require('os').tmpdir();
const log = opts.log || { info() {}, warn() {}, error() {} };
const file = path.join(dataDir, 'shares.json');
// Server-side secret. Prefer an explicit install secret if provided so the
// signature can outlive a reinstall. Fall back to a random per-store key
// persisted in dataDir (rotated on next start if the file moves).
const _secretFile = path.join(dataDir, '.share-secret');
function _loadSecret() {
if (opts.signingSecret && typeof opts.signingSecret === 'string') {
return opts.signingSecret;
}
try {
const existing = fs.readFileSync(_secretFile, 'utf8').trim();
if (existing && existing.length >= 32) return existing;
} catch (_) { /* missing or unreadable — generate fresh */ }
const fresh = crypto.randomBytes(32).toString('base64url');
try {
fs.writeFileSync(_secretFile, fresh + '\n', { mode: 0o600 });
} catch (err) {
log.warn && log.warn('share', 'failed to persist signing secret', { err: err && err.message });
}
return fresh;
}
const signingSecret = _loadSecret();
let _mutex = Promise.resolve();
function _enqueue(fn) {
const next = _mutex.then(fn, fn);
_mutex = next.catch(() => {});
return next;
}
function _load() {
const data = _readJSON(file, _defaultData());
if (!data.shares || typeof data.shares !== 'object') data.shares = {};
return data;
}
function _save(data) { _atomicWriteJSON(file, data); }
function _prune(data) {
const cutoff = _nowMs() - PRUNE_AFTER_MS;
for (const id of Object.keys(data.shares)) {
const s = data.shares[id];
if (!s) { delete data.shares[id]; continue; }
const isTerminal = (s.kind === 'public' && s.subscribeCount >= (s.subscribeCap || PUBLIC_DEFAULT_SUBSCRIBE_CAP))
|| (s.kind === 'tailscale' && s.usedAt)
|| (s.expiresAt && new Date(s.expiresAt).getTime() < cutoff);
if (isTerminal) delete data.shares[id];
}
return data;
}
function _findByHash(data, hash) {
for (const id of Object.keys(data.shares)) {
const s = data.shares[id];
if (s && s.hash === hash) return s;
}
return null;
}
function _verifySignature(s, token) {
if (!s.signature || !s.serviceId) return false;
const expected = _hmacSign(signingSecret, `${s.kind}:${s.id}:${s.serviceId}:${token}`);
// constant-time compare; both are base64url strings of equal length
const a = Buffer.from(s.signature);
const b = Buffer.from(expected);
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
function _publicView(s) {
return {
kind: s.kind,
id: s.id,
serviceId: s.serviceId,
expiresAt: s.expiresAt,
createdAt: s.createdAt,
createdBy: s.createdBy,
usedAt: s.usedAt || null,
usedBy: s.usedBy || null,
remainingUses: s.kind === 'tailscale' ? (s.usedAt ? 0 : 1) : Infinity,
subscribeCount: s.subscribeCount || 0,
subscribeCap: s.subscribeCap || null,
};
}
function issuePublic({ serviceId, ttlMs = DEFAULT_PUBLIC_TTL_MS, createdBy = 'admin', subscribeCap } = {}) {
return _enqueue(() => {
if (typeof serviceId !== 'string' || !serviceId.trim()) {
return { ok: false, reason: 'invalid_service' };
}
// Clamp TTL to allowed set so share links can't outlive their visibility intent.
const effectiveTtl = ALLOWED_PUBLIC_TTLS.has(ttlMs) ? ttlMs : DEFAULT_PUBLIC_TTL_MS;
const id = crypto.randomUUID();
const token = crypto.randomBytes(32).toString('base64url');
const hash = _sha256(token);
const signature = _hmacSign(signingSecret, `public:${id}:${serviceId}:${token}`);
const createdAt = _nowIso();
const expiresAt = new Date(_nowMs() + effectiveTtl).toISOString();
const cap = Number.isInteger(subscribeCap) && subscribeCap > 0
? Math.min(subscribeCap, 10000)
: PUBLIC_DEFAULT_SUBSCRIBE_CAP;
const data = _load();
_prune(data);
data.shares[id] = {
id,
kind: 'public',
hash,
signature,
serviceId,
createdBy,
createdAt,
expiresAt,
ttlMs: effectiveTtl,
usedAt: null,
usedBy: null,
subscribeCount: 0,
subscribeCap: cap,
};
_save(data);
log.info && log.info('share', 'public share issued', {
id, serviceId, createdBy, ttlMs: effectiveTtl,
});
return {
ok: true,
id,
token,
signature,
kind: 'public',
serviceId,
expiresAt,
ttlMs: effectiveTtl,
urlPath: `/share/${token}`,
};
});
}
function issueTailscale({ serviceId, email, ttlMs = DEFAULT_TAILSCALE_TTL_MS, createdBy = 'admin' } = {}) {
return _enqueue(() => {
if (typeof serviceId !== 'string' || !serviceId.trim()) {
return { ok: false, reason: 'invalid_service' };
}
if (typeof email !== 'string' || !email.includes('@')) {
return { ok: false, reason: 'invalid_email' };
}
// Tailscale pre-auth keys max at 90 days but our share-window is 24h.
const effectiveTtl = Math.max(60 * 1000, Math.min(ttlMs, MAX_TAILSCALE_TTL_MS));
const id = crypto.randomUUID();
const token = crypto.randomBytes(32).toString('base64url');
const hash = _sha256(token);
const signature = _hmacSign(signingSecret, `tailscale:${id}:${serviceId}:${token}`);
const createdAt = _nowIso();
const expiresAt = new Date(_nowMs() + effectiveTtl).toISOString();
const data = _load();
_prune(data);
data.shares[id] = {
id,
kind: 'tailscale',
hash,
signature,
serviceId,
email: email.toLowerCase().trim(),
createdBy,
createdAt,
expiresAt,
ttlMs: effectiveTtl,
usedAt: null,
usedBy: null,
// authKeyId + authKey are written by the route layer after calling
// tailscale-coord.createAuthKey(); peek() doesn't surface them.
authKeyId: null,
};
_save(data);
log.info && log.info('share', 'tailscale share issued', {
id, serviceId, email: email.toLowerCase().trim(), createdBy, ttlMs: effectiveTtl,
});
return {
ok: true,
id,
token,
signature,
kind: 'tailscale',
serviceId,
email: email.toLowerCase().trim(),
expiresAt,
ttlMs: effectiveTtl,
urlPath: `/share/${token}`,
};
});
}
function attachAuthKey(id, authKeyId) {
return _enqueue(() => {
const data = _load();
const s = data.shares[id];
if (!s) return { ok: false, reason: 'not_found' };
if (s.kind !== 'tailscale') return { ok: false, reason: 'wrong_kind' };
s.authKeyId = authKeyId;
_save(data);
return { ok: true };
});
}
function peek(token) {
if (!token || typeof token !== 'string') return null;
return _enqueue(() => {
const data = _load();
const hash = _sha256(token);
const s = _findByHash(data, hash);
if (!s) return null;
if (!_verifySignature(s, token)) {
log.warn && log.warn('share', 'peek rejected: bad signature', { id: s.id });
return null;
}
if (s.expiresAt && new Date(s.expiresAt).getTime() < _nowMs()) return null;
if (s.kind === 'tailscale' && s.usedAt) return null;
if (s.kind === 'public' && s.subscribeCount >= (s.subscribeCap || PUBLIC_DEFAULT_SUBSCRIBE_CAP)) {
return null;
}
return _publicView(s);
});
}
function getRaw(token) {
if (!token || typeof token !== 'string') return null;
return _enqueue(() => {
const data = _load();
const hash = _sha256(token);
const s = _findByHash(data, hash);
if (!s) return null;
if (!_verifySignature(s, token)) return null;
return s;
});
}
function recordPublicSubscribe(token) {
return _enqueue(() => {
const data = _load();
const hash = _sha256(token);
const s = _findByHash(data, hash);
if (!s) return { ok: false, reason: 'not_found' };
if (s.kind !== 'public') return { ok: false, reason: 'wrong_kind' };
if (!_verifySignature(s, token)) return { ok: false, reason: 'invalid_signature' };
if (s.expiresAt && new Date(s.expiresAt).getTime() < _nowMs()) {
return { ok: false, reason: 'expired' };
}
const cap = s.subscribeCap || PUBLIC_DEFAULT_SUBSCRIBE_CAP;
if (s.subscribeCount >= cap) return { ok: false, reason: 'cap_reached' };
s.subscribeCount += 1;
_save(data);
return { ok: true, count: s.subscribeCount, cap };
});
}
function recordTailscaleUse(token, { deviceId } = {}) {
return _enqueue(() => {
const data = _load();
const hash = _sha256(token);
const s = _findByHash(data, hash);
if (!s) return { ok: false, reason: 'not_found' };
if (s.kind !== 'tailscale') return { ok: false, reason: 'wrong_kind' };
if (!_verifySignature(s, token)) return { ok: false, reason: 'invalid_signature' };
if (s.usedAt) return { ok: false, reason: 'already_used' };
if (s.expiresAt && new Date(s.expiresAt).getTime() < _nowMs()) {
return { ok: false, reason: 'expired' };
}
s.usedAt = _nowIso();
s.usedBy = typeof deviceId === 'string' ? deviceId : 'unknown';
_save(data);
return { ok: true, share: _publicView(s) };
});
}
function revoke(id) {
return _enqueue(() => {
const data = _load();
if (!data.shares[id]) return false;
delete data.shares[id];
_save(data);
log.info && log.info('share', 'share revoked', { id });
return true;
});
}
function list() {
return _enqueue(() => {
const data = _load();
_prune(data);
return Object.values(data.shares).map(_publicView);
});
}
function listForService(serviceId) {
return _enqueue(() => {
const data = _load();
_prune(data);
return Object.values(data.shares)
.filter(s => s.serviceId === serviceId)
.map(_publicView);
});
}
return {
issuePublic,
issueTailscale,
attachAuthKey,
peek,
getRaw,
recordPublicSubscribe,
recordTailscaleUse,
revoke,
list,
listForService,
// expose for tests
_signingSecret: signingSecret,
_file: file,
};
}
module.exports = { createShareStore };