UNPKG

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
"use strict"; /** * 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