agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
277 lines • 12.1 kB
JavaScript
;
/**
* directory/ldapDirectory — the {@link Directory} port over LDAPS, through
* `ldapts` (an optional peer, loaded lazily — the `jose` precedent).
*
* LDAPS only: `ldap://` would send the password in the clear on the bind, and
* StartTLS needs the same DC certificate LDAPS does, so refusing it loses
* nothing real. The certificate chain is checked against EITHER the CA the
* operator supplies (`caPem`) OR — said in so many words, `systemCa: true` —
* Node's default CA store, and the host name against the URL. There is no
* switch to skip either check, and no default between the two trusts.
*
* ── What to trust: the ROOT ─────────────────────────────────────────────────
* Trust the CA at the top of the DC certificate's chain, never the DC's own
* certificate (refused at construction: it is not a CA) or an intermediate.
* A DC certificate is renewed every few months, and an intermediate is
* replaced on its own schedule; a root lives for years. Trusting the root
* means a renewal — a new leaf, even a new intermediate the DC sends in its
* chain — needs no change here. A DC certificate issued by a PUBLIC CA chains
* to a root Node already ships (`systemCa: true`); a company CA does not, so
* give its root as `caPem`.
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.intermediateCaSubjects = exports.caPemProblem = exports.pemCertificateBlocks = exports.ldapDirectory = exports.MissingLdaptsError = void 0;
const node_crypto_1 = require("node:crypto");
const node_tls_1 = require("node:tls");
const errors_js_1 = require("../../../hosting/signin/errors.js");
const lazyRequire_js_1 = require("../../../lib/lazyRequire.js");
/** Raised when `directory-password` is configured and `ldapts` is not installed. */
class MissingLdaptsError extends Error {
code = 'ERR_MISSING_LDAPTS';
constructor() {
super('directory-password requires the `ldapts` peer dependency.\n Install: npm install ldapts');
this.name = 'MissingLdaptsError';
}
}
exports.MissingLdaptsError = MissingLdaptsError;
/** RFC 4532 "Who am I?" extended operation. */
const WHO_AM_I = '1.3.6.1.4.1.4203.1.11.3';
/** LDAP result code 49: invalidCredentials — every wrong credential. */
const INVALID_CREDENTIALS = 49;
function ldapDirectory(options) {
let url;
try {
url = new URL(options.url);
}
catch {
throw new TypeError(`[identity] ldapDirectory: '${options.url}' is not an ldaps:// URL.`);
}
if (url.protocol !== 'ldaps:') {
throw new TypeError(`[identity] ldapDirectory: '${options.url}' is not ldaps://. A bind over plain LDAP sends ` +
`the password in the clear; use LDAPS (port 636) with the company CA.`);
}
const systemCa = options.systemCa === true;
if (options.systemCa !== undefined && !systemCa) {
throw new TypeError(`[identity] ldapDirectory: systemCa is true or left out.`);
}
if (systemCa && options.caPem !== undefined) {
throw new TypeError(`[identity] ldapDirectory: give caPem OR systemCa: true, not both — one trust, said once.`);
}
if (!systemCa) {
const caProblem = caPemProblem(options.caPem);
if (caProblem !== undefined)
throw new TypeError(`[identity] ldapDirectory: ${caProblem}.`);
}
// No `ca` at all is Node's default store — reached only by `systemCa: true`.
const trust = systemCa ? {} : { ca: [options.caPem] };
const timeout = options.timeoutMs ?? 5_000;
let lib = options.backend;
return {
async open() {
lib ??= await loadLdapts();
// Whether the TLS connection OPENED — the one bit that says whether a
// failed bind could have reached the DC (review idI57 S-6).
let reached = false;
const client = new lib.Client({
url: options.url,
timeout,
connectTimeout: timeout,
tlsOptions: { ...trust, minVersion: 'TLSv1.2', rejectUnauthorized: true },
createSecureConnection: (port, host, tls) => {
const socket = (0, node_tls_1.connect)(port, host, tls);
socket.once('secureConnect', () => {
reached = true;
});
return socket;
},
});
return {
async bind(name, password) {
try {
await client.bind(name, password);
return 'ok';
}
catch (err) {
if (err?.code === INVALID_CREDENTIALS) {
// The sub-code only — `data 52e` — never the rest of the
// directory's text. It goes to the audit record, not the person.
const sub = /data ([0-9a-f]{3,4})\b/i.exec(String(err.message))?.[1];
return sub === undefined
? 'invalid'
: { kind: 'invalid', adSubCode: sub.toLowerCase() };
}
// Never connected (refused, unreachable, a TLS failure): the
// password never left, so the attempt is un-counted. Connected,
// then failed (a bind that timed out): AD may have counted it,
// so the error stays an ordinary one and the door counts it.
if (!reached) {
const code = errorCodeOf(err);
options.log?.(`[identity] directory unreachable (no TLS connection opened${code === undefined ? '' : `: ${code}`})`);
throw new errors_js_1.PasswordCheckUnreachableError('the directory could not be reached', {
cause: err,
});
}
options.log?.('[identity] directory bind failed after it was sent (counted)');
throw err;
}
},
async whoAmI() {
const answer = await client.exop(WHO_AM_I);
return typeof answer.value === 'string' ? answer.value : '';
},
async search(base, filter, scope) {
const found = await client.search(base, {
scope,
filter,
attributes: ['objectGUID', 'displayName'],
explicitBufferAttributes: ['objectGUID'],
sizeLimit: 2,
});
return found.searchEntries.map(toEntry);
},
async close() {
await client.unbind();
},
};
},
};
}
exports.ldapDirectory = ldapDirectory;
const PEM_BEGIN = '-----BEGIN CERTIFICATE-----';
const PEM_END = '-----END CERTIFICATE-----';
/**
* Every certificate block in a PEM bundle, in order — what
* `pem.match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g)`
* finds, in one pass. The regex re-reads the rest of the bundle from every
* BEGIN line that has no END after it, which is quadratic on a file of
* repeated BEGIN lines; once a BEGIN has no END after it, no later one has
* either, so the scan stops there.
*/
function pemCertificateBlocks(pem) {
const blocks = [];
let from = 0;
for (;;) {
const start = pem.indexOf(PEM_BEGIN, from);
if (start === -1)
return blocks;
// `[\s\S]+?` — at least one character between the two lines.
const end = pem.indexOf(PEM_END, start + PEM_BEGIN.length + 1);
if (end === -1)
return blocks;
from = end + PEM_END.length;
blocks.push(pem.slice(start, from));
}
}
exports.pemCertificateBlocks = pemCertificateBlocks;
/**
* Why a CA file is not one, or `undefined`: every PEM block must parse as an
* X.509 certificate with the CA flag set (review idI57 N-10 — a leaf
* certificate booted, then failed every sign-in).
*/
function caPemProblem(caPem) {
if (typeof caPem !== 'string')
return 'caPem must be a PEM CA certificate';
const blocks = pemCertificateBlocks(caPem);
if (blocks.length === 0)
return 'caPem must be a PEM CA certificate';
for (const block of blocks) {
let cert;
try {
cert = new node_crypto_1.X509Certificate(block);
}
catch {
return 'caPem holds a block that is not an X.509 certificate';
}
if (!cert.ca) {
return `caPem holds a certificate that is not a CA (${cert.subject.replace(/\n/g, ', ')}); give the CA that issued the DC's certificate`;
}
}
return undefined;
}
exports.caPemProblem = caPemProblem;
/**
* The subjects of the CA certificates in a PEM bundle that are NOT
* self-signed roots — intermediates. Trusting one alone breaks at the next
* renewal that changes it, so boot says so (`directory-password`'s banner).
*/
function intermediateCaSubjects(caPem) {
const blocks = pemCertificateBlocks(caPem);
const found = [];
for (const block of blocks) {
let cert;
try {
cert = new node_crypto_1.X509Certificate(block);
}
catch {
continue;
}
const selfSigned = cert.checkIssued(cert) && cert.verify(cert.publicKey);
if (cert.ca && !selfSigned)
found.push(cert.subject.replace(/\n/g, ', '));
}
return found;
}
exports.intermediateCaSubjects = intermediateCaSubjects;
/**
* A connection or TLS error's CODE (`ECONNREFUSED`, `ETIMEDOUT`,
* `UNABLE_TO_VERIFY_LEAF_SIGNATURE`, `ERR_TLS_CERT_ALTNAME_INVALID`) — a
* class, never text — from the error or its cause; `undefined` when there is
* none in that shape.
*/
function errorCodeOf(err) {
for (let e = err, depth = 0; e !== undefined && depth < 3; depth += 1) {
const code = e?.code;
if (typeof code === 'string' && /^[A-Z][A-Z0-9_]{1,63}$/.test(code))
return code;
e = e?.cause;
}
return undefined;
}
function toEntry(raw) {
const guid = raw.objectGUID;
const name = raw.displayName;
return {
dn: String(raw.dn ?? ''),
...(Buffer.isBuffer(guid) && { objectGUID: guid }),
...(typeof name === 'string' && { displayName: name }),
};
}
async function loadLdapts() {
try {
const spec = 'ldapts';
return (await Promise.resolve(`${spec}`).then(s => __importStar(require(s))));
}
catch {
try {
return (0, lazyRequire_js_1.lazyRequire)('ldapts');
}
catch {
throw new MissingLdaptsError();
}
}
}
//# sourceMappingURL=ldapDirectory.js.map