UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

233 lines • 11.6 kB
"use strict"; /** * directory/directoryPasswords — the `directory-password` strategy's check: * a person types their Windows username and password, and Active Directory * says who they are (design §4.5). * * The steps, in order (the sign-in door around this already applied the door * guard, the JSON body, the minimum time and the attempt limits): * * 1. **An empty password is refused before the directory is touched.** RFC * 4513 §5.1.2: a simple bind with a name and an empty password is an * UNAUTHENTICATED bind, which a directory may answer as a success. * 2. **The username is checked before the bind**: 1–64 characters of * `A–Z a–z 0–9 . _ -`, after removing a trailing `@<domain>` or a leading * `<NETBIOS>\` only when it is exactly the configured one (people paste * their UPN, or type the down-level `CORP\alice`). Anything else — another * `@domain` or `OTHER\`, `,`, `=`, parentheses, `*`, a space, a quote, * letters outside ASCII (`o'brien`, `mary ann`, `josé` are legal Windows * names this door does not take) — never reaches the directory. A password * holding a control character never does either (Samba cuts a password at * NUL — review idI57 N-7). * 3. **Bind as `<name>@<domain>`** with the typed password, over LDAPS only. * No service account, so no stored directory secret. * 4. **Ask who it authenticated: RFC 4532 Who-am-I**, on the same connection. * An empty answer is anonymous and refused (a second guard behind step 1). * 5. **Parse the answer**: `u:<NETBIOS>\<sAMAccountName>` (the domain must be * the configured NetBIOS domain) or `u:<SID>`. Anything else is refused. * 6. **Find EXACTLY ONE entry** for that account under the base DN, by * `sAMAccountName` or `objectSid`, every value escaped (RFC 4515). Zero or * two is a refusal. The id is THAT entry's `objectGUID` (base64 of its 16 * bytes) — never re-derived from the typed text. * 7. **The required group**, when set: nested membership of that same DN * (`LDAP_MATCHING_RULE_IN_CHAIN`), the group DN escaped as a filter value. * * ── Why Who-am-I (the rename collision, review B-3) ───────────────────────── * AD does not authenticate "the object whose name is the typed text". An * explicit userPrincipalName beats another object's implicit * `sAMAccountName@domain` (MS-ADTS "Simple Authentication"). If Jane's UPN is * `jsmith@corp` and a new John's sAMAccountName is `jsmith`, Jane typing * `jsmith` and HER password authenticates Jane — and a search for * `sAMAccountName=jsmith` finds John. Asking the directory which object it * authenticated is the only answer that cannot hand Jane John's conversations. * * **The attempt budget is kept per ACCOUNT** (`budgetKey`, review idI57 B-1): * AD folds case and binds `alice`, `CORP\alice` and `alice@corp` to one * object, so the door counts them under one key — lower-cased, prefix and * suffix removed, NFC. * * Every wrong credential — an unknown name, a wrong password, an expired or * must-change password (AD sub-codes 52e, 525, 530, 531, 532, 533, 701, 773, * 775), a name that fails the pattern, a missing group — is ONE answer * (`undefined`). A directory that cannot be reached throws * `PasswordCheckUnreachableError` (the door answers 503 and un-counts the * attempt); a failure after the bind was sent throws anything else (503, and * the attempt stays counted — AD may have counted it). The password is never * stored, logged or forwarded. * * **Why, for the operator:** each refusal is also NOTED to the sign-in door * (`PasswordChecker.check`'s `note`) as a reason class — `wrong-credential` * with AD's sub-code and its name (`52e` bad-password, `775` locked, `532` * password-expired, `773` must-change, `533` disabled, `701` * account-expired), `name-refused`, `account-unresolved`, `not-in-group` — * which the door writes to its audit record. The person's answer is the same. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.accountFilter = exports.accountBudgetKey = exports.accountName = exports.directoryPasswords = void 0; const errors_js_1 = require("../../../hosting/signin/errors.js"); const port_js_1 = require("./port.js"); const NAME = /^[A-Za-z0-9._-]{1,64}$/; const CONTROL_CHARACTER = /\p{Cc}/u; /** One RDN component per comma: `attr=value`, nothing a filter or DN parser would read twice. */ const BASE_DN = /^[A-Za-z][A-Za-z0-9-]*=[^,=+<>#;\\"()*\p{Cc}]+(?:,\s*[A-Za-z][A-Za-z0-9-]*=[^,=+<>#;\\"()*\p{Cc}]+)*$/u; /** LDAP_MATCHING_RULE_IN_CHAIN: nested group membership. */ const IN_CHAIN = '1.2.840.113556.1.4.1941'; function directoryPasswords(options) { const domain = required(options.domain, 'domain'); const netbios = required(options.netbiosDomain, 'netbiosDomain').toUpperCase(); const baseDn = required(options.baseDn, 'baseDn'); if (!BASE_DN.test(baseDn)) { throw new TypeError(`[identity] directoryPasswords: baseDn '${baseDn}' is not a DN (DC=corp,DC=example).`); } const suffix = `@${domain}`.toLowerCase(); return { strategy: 'directory-password', kind: 'directory', budgetKey: (typed) => accountBudgetKey(typed), async check(typed, password, note = () => undefined) { if (password.length === 0 || CONTROL_CHARACTER.test(password)) { note({ reason: 'malformed-request' }); return undefined; } const name = accountName(typed, suffix, netbios); if (name === undefined) { note({ reason: 'name-refused' }); return undefined; } let session; try { session = await options.directory.open(); } catch (err) { // Opening a session sends nothing (no password has left this process). if (err instanceof errors_js_1.PasswordCheckUnreachableError) throw err; throw new errors_js_1.PasswordCheckUnreachableError('the directory could not be opened', { cause: err, }); } try { return await authenticated(session, name, password, note); } finally { await session.close().catch(() => undefined); } }, }; async function authenticated(session, name, password, note) { // Sent ONCE: a refused or failed bind is never retried (a retry is a // second strike on AD's lockout counter). const bound = await session.bind(`${name}@${domain}`, password); if (bound !== 'ok') { // A JS session may answer anything that is not 'ok' — all of it is a // refusal (401), and only a STRING sub-code is read from it. const said = typeof bound === 'object' && bound !== null ? bound.adSubCode : undefined; const code = typeof said === 'string' ? said : undefined; note({ reason: 'wrong-credential', ...(code !== undefined && { adSubCode: code, adSubCodeName: (0, port_js_1.adSubCodeName)(code) }), }); return undefined; } const filter = accountFilter(await session.whoAmI(), netbios); if (filter === undefined) { options.log?.('[identity] directory-password: Who-am-I did not name an account of this domain'); note({ reason: 'account-unresolved' }); return undefined; } const entries = await session.search(baseDn, filter, 'sub'); if (entries.length !== 1) { options.log?.(`[identity] directory-password: ${entries.length} entries matched the authenticated account`); note({ reason: 'account-unresolved' }); return undefined; } const entry = entries[0]; if (entry.objectGUID === undefined || entry.objectGUID.length !== 16) { note({ reason: 'account-unresolved' }); return undefined; } if (options.requiredGroup !== undefined) { const member = await session.search(entry.dn, `(memberOf:${IN_CHAIN}:=${(0, port_js_1.escapeFilterValue)(options.requiredGroup)})`, 'base'); if (member.length !== 1) { note({ reason: 'not-in-group' }); return undefined; } } const userId = entry.objectGUID.toString('base64'); return { identity: { userId }, ...(entry.displayName !== undefined && entry.displayName.length > 0 && { displayName: entry.displayName, }), }; } } exports.directoryPasswords = directoryPasswords; /** * The account name to bind as, or `undefined` for a name that never reaches * the directory. A trailing `@<domain>` and a leading `<NETBIOS>\` are * removed only when they are exactly the configured ones. */ function accountName(typed, domainSuffix, netbiosDomain) { let name = typed.trim(); if (name.toLowerCase().endsWith(domainSuffix)) name = name.slice(0, -domainSuffix.length); const slash = name.indexOf('\\'); if (slash > 0 && netbiosDomain !== undefined) { if (name.slice(0, slash).toUpperCase() === netbiosDomain.toUpperCase()) { name = name.slice(slash + 1); } } return NAME.test(name) ? name : undefined; } exports.accountName = accountName; /** * The key a typed name's attempts are counted under — the ACCOUNT, as AD * resolves it: lower-cased (AD names are case-insensitive), ANY `@suffix` and * ANY `DOMAIN\` prefix removed, NFC. Deliberately wider than `accountName`: a * spelling that never binds still shares the account's budget, so no spelling * buys a fresh one (review idI57 B-1). */ function accountBudgetKey(typed) { let key = typed.trim().toLowerCase(); const slash = key.lastIndexOf('\\'); if (slash >= 0) key = key.slice(slash + 1); const at = key.indexOf('@'); if (at >= 0) key = key.slice(0, at); return key.trim().normalize('NFC'); } exports.accountBudgetKey = accountBudgetKey; /** * Who-am-I's answer → the filter for exactly that account, or `undefined`. * `u:CORP\alice` → `(sAMAccountName=alice)` (the domain must be ours); * `u:S-1-5-21-…` → `(objectSid=<bytes>)`; anything else (empty = anonymous, * `dn:` = AD LDS) is refused. */ function accountFilter(authzId, netbiosDomain) { if (!authzId.startsWith('u:')) return undefined; const who = authzId.slice(2); const sid = (0, port_js_1.sidToBytes)(who); if (sid !== undefined) return `(&(objectClass=user)(objectSid=${(0, port_js_1.escapeFilterValue)(sid)}))`; const slash = who.indexOf('\\'); if (slash <= 0) return undefined; if (who.slice(0, slash).toUpperCase() !== netbiosDomain.toUpperCase()) return undefined; const sam = who.slice(slash + 1); if (sam.length === 0) return undefined; return `(&(objectClass=user)(sAMAccountName=${(0, port_js_1.escapeFilterValue)(sam)}))`; } exports.accountFilter = accountFilter; function required(value, name) { if (typeof value !== 'string' || value.trim().length === 0) { throw new TypeError(`[identity] directoryPasswords: ${name} is required.`); } return value.trim(); } //# sourceMappingURL=directoryPasswords.js.map