UNPKG

agentfootprint

Version:

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

102 lines (101 loc) • 6.13 kB
/** * 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. */ import type { PasswordChecker } from '../../../hosting/signin/types.js'; import { type Directory } from './port.js'; export interface DirectoryPasswordsOptions { /** Where connections come from — `ldapDirectory({ … })`, or a fake in tests. */ readonly directory: Directory; /** The DNS domain people bind as `<name>@<domain>` (`corp.example`). */ readonly domain: string; /** The NetBIOS domain Who-am-I must name (`CORP`). */ readonly netbiosDomain: string; /** Where the account's entry is searched (`DC=corp,DC=example`). */ readonly baseDn: string; /** Optional: the DN of a group the person must be in, directly or nested. */ readonly requiredGroup?: string; /** Told the directory's refusal reason for an operator log — never the password. */ readonly log?: (line: string) => void; } export declare function directoryPasswords(options: DirectoryPasswordsOptions): PasswordChecker; /** * 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. */ export declare function accountName(typed: string, domainSuffix: string, netbiosDomain?: string): string | undefined; /** * 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). */ export declare function accountBudgetKey(typed: string): string; /** * 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. */ export declare function accountFilter(authzId: string, netbiosDomain: string): string | undefined;