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