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
JavaScript
;
/**
* 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