import type { Env } from "../env-token.js"; /** Which link of the chain supplied the credential. */ export type CredentialTier = "argument" | "override" | "pointer" | "profile" | "keychain" | "disk" | "env"; export interface ResolvedCredential { /** * The secret. * * NON-ENUMERABLE on purpose, so `Object.keys`, `{ ...resolved }`, and * `JSON.stringify(resolution)` cannot spill it; and separately REDACTED by a * custom-inspect hook, because non-enumerability alone does not stop an * inspector — `console.log` printed it verbatim under Bun until the hook was * added. CONTRACT.md §3a promises both. Property access (`resolved.apiKey`) * and destructuring still work; only enumeration, serialization, and printing * are blocked. Note that `{ ...resolved }` therefore DROPS the key — which is * the safe direction. */ readonly apiKey: string; readonly tier: CredentialTier; /** * Where it came from: an env key NAME, an absolute file path, or a Keychain * item reference (`keychain:@`). Never a value. */ readonly source: string; /** True for tiers an operator sets on purpose. These never fall through. */ readonly deliberate: boolean; /** * When tier === "pointer", the vault ITEM KEY to resolve through the * @hasna/secrets SDK at request time. Never a credential value. Non-enumerable * like apiKey, so it cannot be spilled by enumeration or serialization. */ readonly pointerVaultKey?: string; /** * The disk paths that were consulted before this credential was chosen. * * Carried so an auth failure can tell an operator exactly where the fleet * credential SHOULD live, instead of advising a fix that silently drops the * client onto its local store. */ readonly diskCandidates: readonly string[]; /** Human-readable advisory. Never contains key material. */ readonly warning: string | null; } /** The captured outcome of one `security` invocation. `stdout` IS the secret; it is never logged. */ export interface KeychainCommandResult { /** Exit status; null when the tool could not be started or was killed. */ status: number | null; stdout: string; stderr: string; } /** Runs `/usr/bin/security` with the given argv — no shell. Injected by tests. */ export type KeychainCommandRunner = (argv: readonly string[]) => KeychainCommandResult; /** Tier 3 controls. Every field is optional; production callers pass nothing. */ export interface KeychainTierOptions { /** * Whether the Keychain is consulted for a caller-built env object. * * The tier is AMBIENT: by default it runs only when the resolver is handed * the live `process.env`, because a caller-built env is the whole world (the * hermetic seam) and the machine's Keychain is outside it. `true` turns the * tier on for a caller-built env; `false` turns it off even for the live * environment (a CI job on a Mac runner that must never touch a login * keychain). Injecting `run` implies `true`. */ enabled?: boolean; /** Defaults to `process.platform`; the tier exists only on `"darwin"`. */ platform?: string; /** * The machine's host name, used as the account when `HASNA_STATION` is * unset. Only the label before the first dot is used (`hostname -s`). * Defaults to `os.hostname()`. */ hostname?: () => string; /** The `security` runner. Defaults to spawning `/usr/bin/security` by argv. */ run?: KeychainCommandRunner; } export interface CredentialChainOptions { /** Tier 1: an explicit key, e.g. from `--api-key`. */ apiKey?: string; /** Tier 1: an explicit profile name, e.g. from `--profile`. Beats `HASNA_PROFILE`. */ profile?: string; /** Tier 3: Keychain controls — a fake `security` runner in tests, an opt-out on CI. */ keychain?: KeychainTierOptions; } /** * A deliberate credential selection could not be honoured, or a credential * source produced something unusable. * * Thrown rather than resolved-around: an override or profile pointer that * cannot produce a key must fail loudly, because the alternative is acting as * a different principal than the operator asked for. A corrupt credential file * throws for the same reason. */ export declare class CredentialResolutionError extends Error { readonly appName: string; readonly attempted: readonly string[]; constructor(appName: string, message: string, attempted: readonly string[]); } /** An existing credential/config file is unsafe and is never treated as absent. */ export declare class CredentialFileUnsafeError extends Error { readonly path: string; constructor(path: string, reason: string); } export declare const HASNA_HOME_ENV_KEY = "HASNA_HOME"; export declare const HASNA_CONFIG_HOME_ENV_KEY = "HASNA_CONFIG_HOME"; /** The Keychain account; absent, the short hostname is used, then `USER`. */ export declare const KEYCHAIN_STATION_ENV_KEY = "HASNA_STATION"; /** One on-disk credential source: its absolute path and its tier. */ export interface DiskCredentialSource { path: string; tier: CredentialTier; } /** * All on-disk credential sources for an app, in precedence order. * * Exactly one disk layer exists: `~/.hasna//config/credentials`, or the * `credentials-` file beside it. Returns an empty list when neither * HOME nor HASNA_HOME anchors the root, or when the app name is not safe to * place in a path. */ export declare function credentialDiskSourceList(name: string, env: Env, profile?: string | null): DiskCredentialSource[]; /** * The disk files that may hold an app's credential, in precedence order. * * Exactly one disk layer exists. Exported so callers and error messages can * name the exact path consulted. */ export declare function credentialDiskSources(name: string, env: Env): string[]; /** * True when a security-relevant file's permission bits are exactly owner-only * 0400 or 0600 across the FULL 07777 mask (setuid/setgid/sticky refused — a * 04600 never passes because masking with 0777 keeps the setuid bit). */ export declare function configFileModeAllowed(mode: number): boolean; /** * True when two fstat snapshots of the same descriptor describe the same * file. A change on any axis (dev/ino/size/mtime/ctime) means the path was * replaced or mutated while it was being read — the read must be refused. */ export declare function configFileReadsCoherent(before: { dev: number; ino: number; size: number; mtimeMs: number; ctimeMs: number; }, after: { dev: number; ino: number; size: number; mtimeMs: number; ctimeMs: number; }): boolean; /** A non-secret config value read off disk, with the file that supplied it. */ export interface AppConfigDiskHit { /** The key that matched, in the caller's precedence order. */ key: string; /** The value as written in the file. Never a credential — see below. */ value: string; /** Absolute path of the file that supplied it, so a diagnostic can name it. */ path: string; /** The key was explicitly declared but blank or malformed. */ unusable?: boolean; } /** * Read a NON-SECRET config value from the app's credentials file on disk. * * This is the tier that closes the gap the credential chain left open: the same * file already supplies the API key, and every other field in it was discarded. * A non-interactive shell may inherit no service environment, so this source * keeps the authority and credential together without introducing a local-data * fallback. * * Precedence is file-major, then the caller's key order within a file: the first * disk layer that can answer wins, and inside it the caller's first key wins * over the file's line order. * * Values found here are NOT policed for legacy-ness. A live fleet file may still * carry keys this reader never asks for; it simply ignores them. Throwing on a * file's contents would take down every client on the fleet for a stale line * nobody reads. */ export declare function appConfigDiskValue(name: string, env: Env, keys: readonly string[]): AppConfigDiskHit | null; export declare const CALLER_SUPPLIED_CREDENTIAL_PROVIDER_SOURCE = "caller-supplied CredentialProvider"; /** * Build the credential for a key a caller handed in DIRECTLY as a string. * * `createHasnaHttpTransport({ apiKey })` accepts a bare string, and that branch * used to construct its resolution as an object literal — reaching the request * having run NEITHER {@link assertUsableCredential} NOR {@link sealCredential}, * so the one public constructor most consumers call bypassed both protections * this module exists to provide. A key carrying a CR then travelled all the way * into `fetch`, which rejects it with a `TypeError` whose message quotes THE * WHOLE HEADER VALUE — putting the plaintext key into logs and stack traces, * which is the exact failure `ILLEGAL_IN_HEADER_VALUE` was added to prevent. * * Every credential in this system is now built here or by * {@link resolveCredential}. There is deliberately no third construction site. */ export declare function explicitCredential(appName: string, apiKey: string): ResolvedCredential; /** * Reapply the credential protections at a caller-supplied provider boundary. * * A {@link ResolvedCredential} is structurally typed, so a caller can satisfy * the provider contract with a plain object instead of a value returned by one * of the credential constructors. Snapshot its key once, validate it, and * preserve diagnostic metadata only when the value already carries the internal * seal those constructors apply. Raw provider-shaped objects keep the key, but * not untrusted metadata that could be printed by an auth failure. */ export declare function validateAndSealResolvedCredential(appName: string, credential: ResolvedCredential): ResolvedCredential; /** A Keychain item's value and its diagnostic source. The value is never logged. */ export interface KeychainItemHit { value: string; /** `keychain:@` — names the item, never its value. */ source: string; } /** * The Keychain's `api-url` item for an app — the authority tier that sits * beside the credential item, so a station can pin a non-default service URL * without a file or an env var. Same account rules and failure semantics as * the credential item. */ export declare function keychainConfigValue(name: string, env: Env, options?: KeychainTierOptions): KeychainItemHit | null; /** * Resolve an app's API key through the provider chain, at call time. * * Returns `null` when no tier produces a credential. THROWS * {@link CredentialResolutionError} when a DELIBERATE tier was selected but * could not be honoured, or when a credential is unusable — silently * continuing in either case would authenticate as somebody other than the * principal the operator named. */ export declare function resolveCredential(name: string, env: Env, options?: CredentialChainOptions): ResolvedCredential | null; /** * Complete a pointer-tier resolution through the secrets vault. * * Called by the transport at REQUEST time, never at construction. The pointer * is a DELIBERATE selection, so every failure — SDK not installed, client * unconfigured, vault unreachable, item missing or empty — is a TERMINAL * {@link CredentialResolutionError}. The chain never falls through to a * literal, an env var, or a local store: authenticating as a different * principal than the one the operator named is exactly the failure a * deliberate pointer exists to prevent. * * The @hasna/secrets module is imported lazily (via a non-literal specifier) * so consumers that never set a pointer pay no import cost and need no peer * dependency at load time; a pointer REQUIRES it, and its absence is one of * the TERMINAL cases. */ export declare function completePointerCredential(name: string, pointerResolution: ResolvedCredential, env?: Env): Promise;