UNPKG

agentfootprint

Version:

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

217 lines • 10.9 kB
"use strict"; /** * localPassword — the `local-password` strategy: a password list the server * holds, for development, tests and a demo box. Refused in production by * `identityFromConfig` (Q3): a list in an environment variable is not a * production identity store. * * Pattern: a {@link PasswordChecker} behind the sign-in door. The door owns * everything a password door must do around the check (the guard, * attempt limits, one answer, a minimum response time); this file only * answers "is this the password for that name?". * * ── Hashed passwords only, with scrypt ────────────────────────────────────── * A list entry is `name:scrypt$<log2 N>$<r>$<p>$<salt>$<key>` — never a plain * password. Make one with {@link hashPassword}. * * Why scrypt: it is in `node:crypto`, so it adds no dependency (argon2 needs a * native module; bcrypt a package), and it is memory-hard, which PBKDF2 is not. * The default cost is OWASP's recommended scrypt setting (N = 2^17, r = 8, * p = 1: 128 MiB and a noticeable fraction of a second per check — on purpose). * The cost travels in the hash. Anything weaker than N = 2^14, r = 8, p = 1 is * refused, and so is anything dearer than 256 MiB per check (128·N·r) or * p > 4 — a check is run for every unknown name too, so an absurd cost is a * denial of service on the door. * * ── One cost per list ─────────────────────────────────────────────────────── * Every entry must carry the SAME cost. An unknown name is checked against a * decoy hash so it takes as long as a known one; with mixed costs no single * decoy can match every entry, and the time would tell which names exist. A * cost change (a re-hash) is therefore done for the whole list at once: this is * a development/test list the operator regenerates, not a live store that has * to migrate one person at a time as they sign in. * * ── Ids ───────────────────────────────────────────────────────────────────── * The one exception to "never a name a person types" (rule 7): a local * password's id IS its configured name, matched exactly (the door trims the * typed name once, as the app's own door did) — so one person cannot get two * owners by typing `Alice` and `alice`. Renaming someone orphans their * conversations. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.localPasswords = exports.hashPassword = exports.LocalPasswordConfigError = exports.SCRYPT_MAX_P = exports.SCRYPT_MAX_BYTES = exports.SCRYPT_FLOOR = exports.SCRYPT_DEFAULT = void 0; const node_crypto_1 = require("node:crypto"); /** The scrypt cost this library writes. OWASP Password Storage Cheat Sheet: N=2^17, r=8, p=1. */ exports.SCRYPT_DEFAULT = { log2N: 17, r: 8, p: 1 }; /** The weakest cost a stored hash may carry. */ exports.SCRYPT_FLOOR = { log2N: 14, r: 8, p: 1 }; /** The most memory one check may use: 128·N·r bytes. */ exports.SCRYPT_MAX_BYTES = 256 * 1024 * 1024; /** The most parallel passes one check may run. */ exports.SCRYPT_MAX_P = 4; const KEY_BYTES = 32; const SALT_BYTES = 16; /** A local-password list entry is refused. */ class LocalPasswordConfigError extends Error { code = 'ERR_LOCAL_PASSWORD_CONFIG'; constructor(sentence) { super(`[identity] ${sentence}`); this.name = 'LocalPasswordConfigError'; } } exports.LocalPasswordConfigError = LocalPasswordConfigError; /** * Hash a password for a `local-password` list entry. * * @example * // Read the password from stdin, so it never lands in shell history: * // read -rs PW && PW="$PW" node -e \ * // "import('agentfootprint/security').then(m => m.hashPassword(process.env.PW)).then(console.log)" * // Then SINGLE-QUOTE the list — a shell or a compose file expands `$17`: * // IDENTITY_LOCAL_USERS='priya:scrypt$17$8$1$…$…' */ async function hashPassword(password, cost = exports.SCRYPT_DEFAULT) { if (typeof password !== 'string' || password.length === 0) { throw new LocalPasswordConfigError('hashPassword needs a non-empty password.'); } // The sign-in door refuses a password holding a control character before // any check (review idI57 N-7), so a hash of one could never sign in. if (/\p{Cc}/u.test(password)) { throw new LocalPasswordConfigError('hashPassword refuses a password with a control character (a tab, a newline, NUL): the sign-in door never accepts one.'); } checkCost(cost, 'hashPassword'); const salt = (0, node_crypto_1.randomBytes)(SALT_BYTES); const key = await derive(password, salt, cost); return [ 'scrypt', cost.log2N, cost.r, cost.p, salt.toString('base64url'), key.toString('base64url'), ].join('$'); } exports.hashPassword = hashPassword; /** * The `local-password` checker over a list: `name:hash` entries, comma * separated (the `IDENTITY_LOCAL_USERS` form), or a `{ name: hash }` object. * Refused at construction: an empty list, a plain password, a malformed or * too-weak hash, a name with `:` `,` or a control character, a duplicate name. */ function localPasswords(users) { const entries = typeof users === 'string' ? parseList(users) : Object.entries(users); if (entries.length === 0) throw new LocalPasswordConfigError('the local-password list is empty.'); const table = new Map(); for (const [rawName, hash] of entries) { const name = checkName(rawName); if (table.has(name)) { throw new LocalPasswordConfigError(`the local-password list names '${name}' twice (names are compared after Unicode NFC).`); } table.set(name, parseHash(name, hash)); } const costs = new Set([...table.values()].map((h) => `${h.cost.log2N}/${h.cost.r}/${h.cost.p}`)); if (costs.size > 1) { throw new LocalPasswordConfigError(`the local-password list mixes scrypt costs (${[...costs].join(', ')}). Every entry must ` + `share one cost — an unknown name is timed against one decoy, and mixed costs would ` + `tell which names exist. Re-hash the whole list at one cost.`); } // Checked against when the name is unknown, so an unknown name costs what a // known one does. const decoy = [...table.values()][0]; return { strategy: 'local-password', kind: 'local', names: [...table.keys()], // The attempt budget is kept under the name the list compares — the // trimmed NFC form (review idI57 B-1: the checker names the key). budgetKey: (username) => localName(username), async check(username, password, note) { const name = localName(username); const stored = table.get(name); const against = stored ?? decoy; const key = await derive(password, against.salt, against.cost); const match = (0, node_crypto_1.timingSafeEqual)(key, against.key); // The reason goes to the operator's audit record only; the person gets // one answer, after the same scrypt cost, whichever it was. if (stored === undefined) note?.({ reason: 'unknown-account' }); else if (!match) note?.({ reason: 'wrong-credential' }); if (stored === undefined || !match) return undefined; return { identity: { userId: name }, displayName: name }; }, }; } exports.localPasswords = localPasswords; function parseList(list) { return list .split(',') .map((entry) => entry.trim()) .filter((entry) => entry.length > 0) .map((entry) => { const colon = entry.indexOf(':'); if (colon <= 0) { throw new LocalPasswordConfigError(`a local-password entry is 'name:scrypt$…' (an entry has no name).`); } return [entry.slice(0, colon), entry.slice(colon + 1)]; }); } /** A name as the list compares it: trimmed, Unicode NFC. Case is kept — `Priya` and `priya` are two entries. */ function localName(raw) { return raw.trim().normalize('NFC'); } function checkName(raw) { const name = localName(raw); // eslint-disable-next-line no-control-regex if (name.length === 0 || name.length > 256 || /[:,\u0000-\u001f\u007f]/.test(name)) { throw new LocalPasswordConfigError(`a local-password name is 1–256 characters with no ':', ',' or control character.`); } return name; } function parseHash(name, hash) { const parts = hash.trim().split('$'); if (parts[0] !== 'scrypt') { throw new LocalPasswordConfigError(`the local-password entry for '${name}' is not a scrypt hash. Plain passwords are never ` + `accepted; make one with hashPassword().`); } const [, log2N, r, p, salt, key] = parts; const cost = { log2N: Number(log2N), r: Number(r), p: Number(p) }; const saltBytes = Buffer.from(salt ?? '', 'base64url'); const keyBytes = Buffer.from(key ?? '', 'base64url'); if (parts.length !== 6 || saltBytes.length < 16 || keyBytes.length !== KEY_BYTES) { throw new LocalPasswordConfigError(`the local-password hash for '${name}' is malformed. If the value holds '$', single-quote ` + `it: a shell or a compose file expands $17, $8 and $1 and hands over what is left.`); } checkCost(cost, `the local-password hash for '${name}'`); return { cost, salt: saltBytes, key: keyBytes }; } function checkCost(cost, who) { const ok = Number.isInteger(cost.log2N) && Number.isInteger(cost.r) && Number.isInteger(cost.p) && cost.log2N >= exports.SCRYPT_FLOOR.log2N && cost.log2N <= 20 && cost.r >= exports.SCRYPT_FLOOR.r && cost.p >= exports.SCRYPT_FLOOR.p && cost.p <= exports.SCRYPT_MAX_P && 128 * 2 ** cost.log2N * cost.r <= exports.SCRYPT_MAX_BYTES; if (!ok) { throw new LocalPasswordConfigError(`${who} has a scrypt cost below the floor (N=2^14, r=8, p=1) or past the ceiling ` + `(256 MiB per check, 128·N·r — e.g. N=2^18 with r=8 — and p ≤ ${exports.SCRYPT_MAX_P}).`); } } function derive(password, salt, cost) { const N = 2 ** cost.log2N; const options = { N, r: cost.r, p: cost.p, maxmem: 256 * N * cost.r + 1024 * 1024, }; return new Promise((resolve, reject) => { (0, node_crypto_1.scrypt)(password.normalize('NFC'), salt, KEY_BYTES, options, (err, key) => err ? reject(err) : resolve(key)); }); } //# sourceMappingURL=localPassword.js.map