agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
202 lines • 12 kB
JavaScript
/**
* strategies/directoryChoice — `directory-password` from config: plain Active
* Directory over LDAPS, for a company with no federation server (design §4.5).
*
* Allowed in production (unlike `local-password`) — it is the company's real
* directory — and the banner says what it costs: a password bind skips
* whatever MFA the company's single sign-on enforces.
*
* Attempt limits come from AD's OWN lockout policy, which the AD admin
* supplies (review idI57 B-1, B-2, S-4):
* - **a THIRD of the threshold per ACCOUNT**, counted under the account the
* name reaches (`alice`, `ALICE`, `CORP\alice` and `alice@corp.example` are
* one budget), over a window that — like AD's own "reset account lockout
* counter after" — restarts from the LAST failure;
* - a third, not a half, because one AD account can have TWO logon names the
* door cannot tie together without a service account: the UPN prefix and
* the sAMAccountName (Jane: `jsmith@corp`, `jsmith2`). Two budgets of
* ⌊T/3⌋ still leave AD a third of its threshold for the person's VPN, mail
* and typos. The residual: an account with THREE distinct logon names (an
* alternative UPN suffix) could reach T through this door alone;
* - a threshold of 1 or 2 is REFUSED at boot — no budget of whole attempts
* stays under it — unless `IDENTITY_LDAP_ACCEPT_LOW_THRESHOLD=yes`, whose
* banner says the door cannot protect those accounts;
* - a threshold of 0 means AD never locks; the door keeps its default.
* This REDUCES lockout risk; it cannot rule it out (AD counts every source).
* The protection that holds whatever this door does is AD's own: a lockout
* DURATION of minutes, not "until an admin unlocks", and a threshold of 10 or
* more (the Microsoft security baseline's number).
*
* Trust (`IDENTITY_LDAP_CA_FILE`): a PEM file holding the ROOT CA, or exactly
* `system` — Node's default CA store, for a DC certificate a public CA issued.
* Never unset-means-system: an operator who forgot the key is refused, not
* quietly trusting every public CA. A file holding an INTERMEDIATE boots with
* a banner warning (it is replaced at renewal; trust the root).
*/
import { readFileSync } from 'node:fs';
import { memorySignIns } from '../../../hosting/signin/memorySignIns.js';
import { DEFAULT_IDLE_MINUTES, signInDoor } from '../../../hosting/signin/door.js';
import { directoryPasswords } from '../directory/directoryPasswords.js';
import { intermediateCaSubjects, ldapDirectory, } from '../directory/ldapDirectory.js';
import { IdentityConfigError } from './config.js';
import { addressRefusalLimits, doorRefusal } from './doorError.js';
import { keyLabel } from './vocabulary.js';
/** Build the `directory-password` choice, or refuse to boot naming the key. */
export function directoryPasswordChoice(config, boot, production) {
const url = need(config.ldapUrl, 'ldapUrl', 'IDENTITY_LDAP_URL');
const caFile = need(config.ldapCaFile, 'ldapCaFile', 'IDENTITY_LDAP_CA_FILE');
const domain = need(config.ldapDomain, 'ldapDomain', 'IDENTITY_LDAP_DOMAIN');
const netbiosDomain = need(config.ldapNetbiosDomain, 'ldapNetbiosDomain', 'IDENTITY_LDAP_NETBIOS_DOMAIN');
const baseDn = need(config.ldapBaseDn, 'ldapBaseDn', 'IDENTITY_LDAP_BASE_DN');
const publicUrl = need(config.publicUrl, 'publicUrl', 'IDENTITY_PUBLIC_URL');
const threshold = needNumber(config.ldapLockoutThreshold, 'IDENTITY_LDAP_LOCKOUT_THRESHOLD');
const windowMinutes = needNumber(config.ldapLockoutWindowMinutes, 'IDENTITY_LDAP_LOCKOUT_WINDOW_MINUTES');
if (windowMinutes < 1) {
throw new IdentityConfigError("IDENTITY_LDAP_LOCKOUT_WINDOW_MINUTES is AD's observation window, at least 1 minute.", 'IDENTITY_LDAP_LOCKOUT_WINDOW_MINUTES');
}
const systemCa = caFile === SYSTEM_TRUST;
let caPem;
if (!systemCa) {
try {
caPem = readFileSync(caFile, 'utf8');
}
catch {
throw new IdentityConfigError(`IDENTITY_LDAP_CA_FILE names a file that cannot be read. Give the ROOT CA's PEM file, ` +
`or '${SYSTEM_TRUST}' to trust Node's default CA store (a DC certificate from a public CA).`, 'IDENTITY_LDAP_CA_FILE');
}
}
let directory;
try {
directory = ldapDirectory({
url,
...(systemCa ? { systemCa: true } : { caPem: caPem }),
...(boot.ldapts !== undefined && { backend: boot.ldapts }),
// Why the directory could not answer — a TLS trust or host-name refusal
// by its code — so an operator sees more than `backend-unreachable`.
log: (line) => console.warn(line),
});
}
catch (err) {
const text = err instanceof Error ? err.message.replace(/^\[identity\] /, '') : String(err);
throw new IdentityConfigError(`directory-password cannot start: ${text}`, /caPem/.test(text) ? 'IDENTITY_LDAP_CA_FILE' : 'IDENTITY_LDAP_URL');
}
let passwords;
try {
passwords = directoryPasswords({
directory,
domain,
netbiosDomain,
baseDn,
...(config.ldapRequiredGroup !== undefined && { requiredGroup: config.ldapRequiredGroup }),
log: (line) => console.warn(line),
});
}
catch (err) {
const text = err instanceof Error ? err.message.replace(/^\[identity\] /, '') : String(err);
throw new IdentityConfigError(`directory-password cannot start: ${text}`, 'IDENTITY_LDAP_BASE_DN');
}
const perName = attemptsPerAccount(threshold, config.ldapAcceptLowThreshold);
const addressRefusal = addressRefusalLimits(config.signInAddressRefuseAfter);
const intermediates = caPem === undefined ? [] : intermediateCaSubjects(caPem);
let door;
try {
door = signInDoor({
passwords,
store: memorySignIns({
...(config.signInMax !== undefined && { max: config.signInMax }),
idleMinutes: config.signInIdleMinutes ?? DEFAULT_IDLE_MINUTES.password,
}),
publicUrl,
production,
limits: {
...(perName !== undefined && { perName }),
windowMinutes,
...addressRefusal,
},
...(boot.crossSite !== undefined && { guard: boot.crossSite }),
...(config.signInHours !== undefined && { hours: config.signInHours }),
...(config.signInIdleMinutes !== undefined && { idleMinutes: config.signInIdleMinutes }),
...(config.trustedProxies !== undefined && { trustedProxies: config.trustedProxies }),
...(boot.onSignInAudit !== undefined && { onAudit: boot.onSignInAudit }),
...(boot.signInLog !== undefined && { log: boot.signInLog }),
});
}
catch (err) {
throw doorRefusal('directory-password', err);
}
return {
strategy: 'directory-password',
mode: 'password',
identity: door.identity,
signInDoor: door,
hostSignIn: door.hostSignIn,
banner: [
`identity: strategy directory-password — ${url}, bind as <name>@${domain}, Who-am-I must name ${netbiosDomain.toUpperCase()}\\…`,
systemCa
? `identity: LDAPS trust = Node's default CA store (public roots; IDENTITY_LDAP_CA_FILE=${SYSTEM_TRUST}); host name checked against ${url}`
: `identity: LDAPS trust = ${caFile}; host name checked against ${url}`,
...intermediates.map((subject) => `identity: WARNING IDENTITY_LDAP_CA_FILE holds an INTERMEDIATE CA (${subject}). Trust the ROOT: an intermediate is replaced at renewal, and sign-in then fails until this file changes`),
'identity: directory-password is PENDING INDEPENDENT REVIEW before a company install',
`identity: user id = the authenticated entry's objectGUID (base64) under ${baseDn}` +
(config.ldapRequiredGroup !== undefined
? `; required group ${config.ldapRequiredGroup}`
: ''),
...(config.ldapRequiredGroup === undefined && production
? [
'identity: WARNING no IDENTITY_LDAP_REQUIRED_GROUP: every enabled account in the domain can sign in. Set the access group (nested membership is honoured)',
]
: []),
...(addressRefusal !== undefined
? [
`identity: a client address is REFUSED after ${addressRefusal.refuseAddressAfter} failed sign-ins per window (IDENTITY_SIGN_IN_ADDRESS_REFUSE_AFTER) — everybody behind one NAT or an untrusted proxy shares that budget`,
]
: []),
'identity: every sign-in outcome is logged as one `[identity] sign-in {…}` line (account, address, time, AD sub-code; never a password)',
`identity: attempt limits from AD's lockout policy: ${perName === undefined
? 'AD never locks (threshold 0), door default'
: `${perName} per account (a third of AD's ${threshold}; alice, ALICE, ${netbiosDomain.toUpperCase()}\\alice and alice@${domain} are one account)`}, counter reset ${windowMinutes} min after the LAST failure — this REDUCES lockout risk; AD also counts VPN, mail and typos, and an account with a separate UPN prefix has two budgets`,
...(threshold > 0 && threshold < 3
? [
`identity: WARNING AD locks an account after ${threshold} wrong password${threshold === 1 ? '' : 's'} — this door CANNOT protect accounts from lockout (IDENTITY_LDAP_ACCEPT_LOW_THRESHOLD=yes); raise AD's threshold or set a short lockout duration`,
]
: []),
"identity: WARNING this strategy bypasses the company's MFA / Conditional Access: a password bind skips whatever the single sign-on enforces",
...door.banner,
],
};
}
/**
* Attempts per account per window: a third of AD's threshold (see the header),
* the door default when AD never locks, and a boot refusal for 1 or 2 unless
* the operator said, in so many words, that the door cannot protect them.
*/
function attemptsPerAccount(threshold, acceptLow) {
const low = threshold > 0 && threshold < 3;
if (acceptLow !== undefined && (acceptLow !== 'yes' || !low)) {
throw new IdentityConfigError(acceptLow !== 'yes'
? "IDENTITY_LDAP_ACCEPT_LOW_THRESHOLD takes one value, 'yes'."
: `IDENTITY_LDAP_ACCEPT_LOW_THRESHOLD is set but the threshold is ${threshold}; it only applies to a threshold of 1 or 2, so remove it.`, 'IDENTITY_LDAP_ACCEPT_LOW_THRESHOLD');
}
if (low && acceptLow === undefined) {
throw new IdentityConfigError(`IDENTITY_LDAP_LOCKOUT_THRESHOLD=${threshold}: AD locks an account after ${threshold} wrong ` +
`password${threshold === 1 ? '' : 's'}, and no attempt budget through this door can stay under ` +
`that. Raise AD's threshold (the Microsoft baseline is 10), or set ` +
`IDENTITY_LDAP_ACCEPT_LOW_THRESHOLD=yes to boot knowing the door cannot protect accounts from lockout.`, 'IDENTITY_LDAP_LOCKOUT_THRESHOLD');
}
if (threshold === 0)
return undefined;
return Math.max(1, Math.floor(threshold / 3));
}
/** The one `IDENTITY_LDAP_CA_FILE` value that is not a file: Node's default CA store. */
const SYSTEM_TRUST = 'system';
function need(value, field, key) {
if (typeof value === 'string' && value.trim().length > 0)
return value.trim();
throw new IdentityConfigError(`directory-password needs ${keyLabel(field)}.`, key);
}
function needNumber(value, key) {
if (typeof value === 'number' && Number.isInteger(value) && value >= 0)
return value;
throw new IdentityConfigError(`directory-password needs ${key}: the AD admin supplies it (a threshold of 0 means AD never locks).`, key);
}
//# sourceMappingURL=directoryChoice.js.map