import { openSync, closeSync, fstatSync, readFileSync, lstatSync, realpathSync, constants } from 'node:fs'; import { isAbsolute, relative, resolve } from 'node:path'; import { BusError } from './broker.mjs'; const deny = (code) => { throw new BusError(code); }; const inside = (root, path) => { const r = relative(root, path); return r === '' || (!r.startsWith('..') && !isAbsolute(r)); }; const day = (value) => typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(Date.parse(value)) && new Date(value).toISOString().slice(0, 10) === value; export class Credentials { #tokens = new Map(); constructor({ references = {}, repoRoots = [], dataRoot, env = process.env }) { try { for (const [instance, services] of Object.entries(references)) for (const [service, ref] of Object.entries(services)) { if (!['gitea', 'vikunja'].includes(service) || !ref || typeof ref !== 'object') deny('credential-reference'); const date = service === 'vikunja' ? ref.expires : ref.rotateBy; if (!day(date)) deny('credential-date'); if ( (ref.service !== undefined && ref.service !== service) || Object.keys(ref).some( (k) => !['service', 'file', 'env', service === 'vikunja' ? 'expires' : 'rotateBy'].includes(k), ) || Boolean(ref.file) === Boolean(ref.env) ) deny('credential-reference'); let token; if (ref.file) { if (!isAbsolute(ref.file)) deny('credential-location'); let resolved, original; try { original = lstatSync(ref.file); resolved = realpathSync(ref.file); } catch { deny('credential-file'); } if (original.isSymbolicLink() || !original.isFile()) deny('credential-file'); if ( [...repoRoots, dataRoot].filter(Boolean).some((root) => inside( (() => { try { return realpathSync(root); } catch { return resolve(root); } })(), resolved, ), ) ) deny('credential-location'); let fd; try { fd = openSync(ref.file, constants.O_RDONLY | constants.O_NOFOLLOW); const s = fstatSync(fd); if ( !s.isFile() || s.uid !== process.getuid() || (s.mode & 0o777) !== 0o600 || s.size > 16384 || s.ino !== original.ino || s.dev !== original.dev ) deny('credential-file'); token = readFileSync(fd, 'utf8'); } catch (e) { if (e instanceof BusError) throw e; deny('credential-file'); } finally { if (fd !== undefined) closeSync(fd); } } else { if (!/^[A-Z_][A-Z0-9_]*$/.test(ref.env)) deny('credential-reference'); token = env[ref.env]; } if (typeof token !== 'string' || token.length > 16384 || !/^[-A-Za-z0-9._~]+\n?$/.test(token)) deny('credential-format'); token = token.replace(/\n$/, ''); if (token.length < 16) deny('credential-format'); this.#tokens.set(instance + ':' + service, { token, date }); } } catch (e) { this.close(); throw e; } } assertClean(value) { let encoded; try { encoded = JSON.stringify(value); } catch { deny('invalid-request'); } for (const { token } of this.#tokens.values()) if (encoded?.includes(token)) deny('credential-leak'); } async use(instance, service, operation) { const entry = this.#tokens.get(instance + ':' + service); if (!entry) deny('credential-unavailable'); if (service === 'vikunja' && Date.now() >= Date.parse(entry.date + 'T00:00:00Z')) deny('credential-expired'); let result; try { result = await operation(entry.token); } catch { deny('service-failed'); } this.assertClean(result); return result; } status() { return [...this.#tokens.entries()].map(([key, { date }]) => { const i = key.lastIndexOf(':'), service = key.slice(i + 1), left = Date.parse(date + 'T00:00:00Z') - Date.now(); return { instance: key.slice(0, i), service, date, state: service === 'gitea' ? left <= 0 ? 'rotation-due' : 'valid' : left <= 0 ? 'expired' : left < 7 * 86400000 ? 'expiring' : 'valid', }; }); } close() { this.#tokens.clear(); } }