Rocko's round 2 candidate, approved by Darkwing (#1519 comment 26757).
build.patch 40d7e838, manifest 61519059, 24 files under packages/bus,
schema v3b (179ffe35, lead decision 60). Integration gate in a git
worktree of 942dca9e (S1 in the tree) plus the patch: bus 43/43 and
business 60/60 on Node 24 and 26, every package test and every
scripts/test-*.sh green, test-task 98/98 with the live-provider cases.
Rulings from lead decisions 62 and 63: the human proof is cooperative in
slice 1, and a self-raised cross-role decision routes to the human.
Single-use gated approvals follow in row 43.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
144 lines
4.8 KiB
JavaScript
144 lines
4.8 KiB
JavaScript
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();
|
|
}
|
|
}
|