// Persistent state for dsh-remote: device identity, paired devices (master) or master // link credentials (slave), sync bookkeeping. One JSON file, written atomically, 0600. import crypto from 'node:crypto' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' let PROFILE_DIR = '' /** Set once from ctx.profileContext.dir at plugin start (authoritative). */ export function setProfileDir(dir) { if (dir) PROFILE_DIR = dir } export function profileDir() { if (PROFILE_DIR) return PROFILE_DIR if (process.env.DSH_PROFILE_DIR) return process.env.DSH_PROFILE_DIR const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh') return path.join(home, 'profiles', process.env.DSH_PROFILE || 'web') } export function stateDir() { return path.join(profileDir(), 'dsh-remote') } const b64u = (buf) => Buffer.from(buf).toString('base64url') export const randomId = (bytes = 16) => b64u(crypto.randomBytes(bytes)) export const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex') export function safeEqual(a, b) { const x = Buffer.from(String(a)) const y = Buffer.from(String(b)) return x.length === y.length && crypto.timingSafeEqual(x, y) } /** Device id: lowercase slug, safe in URL paths (/r//). */ export function slug(s) { const v = String(s || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) return v || 'device' } export class Store { constructor(file = path.join(stateDir(), 'state.json')) { this.file = file this.data = this._load() } _load() { try { const v = JSON.parse(fs.readFileSync(this.file, 'utf8')) if (v && typeof v === 'object') return v } catch { /* first run */ } return {} } save() { fs.mkdirSync(path.dirname(this.file), { recursive: true, mode: 0o700 }) const tmp = `${this.file}.${process.pid}.${Date.now()}.tmp` fs.writeFileSync(tmp, JSON.stringify(this.data, null, 2), { mode: 0o600 }) fs.renameSync(tmp, this.file) } /** This installation's stable identity. */ identity(defaultName) { if (!this.data.self) { const name = defaultName || os.hostname() this.data.self = { id: slug(name), name, createdAt: Date.now() } this.save() } return this.data.self } // ---- master side: paired devices + pairing codes ------------------------- devices() { return (this.data.devices ||= {}) } pairingCodes() { return (this.data.pairing ||= {}) } createPairingCode({ ttlMs = 15 * 60 * 1000, label = '' } = {}) { // 4 groups of 4 from an unambiguous alphabet: easy to type, ~80 bits. const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' const raw = crypto.randomBytes(16) const code = [...raw].map((b) => alphabet[b % alphabet.length]).join('').replace(/(.{4})(?=.)/g, '$1-') this.pairingCodes()[sha256(code)] = { expiresAt: Date.now() + ttlMs, label, createdAt: Date.now() } this._prunePairing() this.save() return { code, expiresAt: Date.now() + ttlMs } } consumePairingCode(code) { this._prunePairing() const key = sha256(String(code || '').trim().toUpperCase()) const rec = this.pairingCodes()[key] if (!rec) return null delete this.pairingCodes()[key] this.save() return rec } _prunePairing() { const now = Date.now() for (const [k, v] of Object.entries(this.pairingCodes())) if (!v || v.expiresAt < now) delete this.pairingCodes()[k] } /** Register (or re-register) a device; returns its secret key (shown once). */ addDevice({ id, name, platform }) { const devices = this.devices() let devId = slug(id || name) // Keep ids unique; a re-pair of the same machine replaces its key. const secret = randomId(32) devices[devId] = { id: devId, name: name || devId, platform: platform || '', keyHash: sha256(secret), createdAt: devices[devId]?.createdAt || Date.now(), revoked: false, sync: devices[devId]?.sync || { enabled: true, allow: [], deny: [], secrets: [] }, } this.save() return { id: devId, secret } } verifyDevice(id, secret) { const d = this.devices()[id] if (!d || d.revoked) return null return safeEqual(d.keyHash, sha256(String(secret || ''))) ? d : null } revokeDevice(id) { const d = this.devices()[id] if (!d) return false d.revoked = true d.revokedAt = Date.now() this.save() return true } removeDevice(id) { if (!this.devices()[id]) return false delete this.devices()[id] this.save() return true } // ---- UI-managed node settings --------------------------------------------- /** { nodeType?: 'master'|'slave', authMiddleware?: boolean } — set from Settings → Remote devices. */ settings() { return (this.data.settings ||= {}) } setSettings(patch) { this.data.settings = { ...this.settings(), ...patch } this.save() return this.data.settings } // ---- slave side: link to master ------------------------------------------ get master() { return this.data.master || null } setMaster(m) { this.data.master = m this.save() } }