/** * Service-account key write-ahead provisioning and reconciliation for * `hikoutei setup`. * * The key create is a real typed write-ahead state, not an ad-hoc nullable * shape: before the first gcloud key create the flow lists the user-managed * keys of the service account (`gcloud iam service-accounts keys list * --managed-by=user`, which keeps the list to user-owned keys only) with a * stable machine-readable format (`--format=value(name)`), validates the * output, and persists a checkpoint * status `key_create_started` carrying a UUID key marker and the sorted/ * deduplicated baseline of pre-existing key resource names. The marker * derives a DETERMINISTIC private sibling staging directory * (`.hikoutei-key-stage-`), so a crash at any boundary — before * gcloud, after the remote/local create, after the atomic hardlink install, * before/after staged cleanup, before `key_ready` — resumes by reconciling * the staged and/or final key file against the current user-managed key * list instead of creating a second key. * * Reconciliation rules (fail closed, never auto-delete a cloud key or a * local credential): * - a staged key that validates (JSON/RSA/project/email and non-secret * `private_key_id`) and corresponds to an ACTIVE user-managed key is * installed/recovered without creating another; * - a final key that validates and is active finishes cleanup and promotes; * - every current post-baseline key must be represented by a securely * validated local credential: an unmatched delta entry (a cloud key this * setup neither created nor can identify) fails with * `key_create_uncertain` BEFORE any install, cleanup, or promotion; * - only the same invocation that JUST persisted a fresh * `key_create_started` checkpoint may issue the ONE key create; any * invocation loaded from an existing `key_create_started` checkpoint is * reconcile-only and NEVER creates, even when no stage/final/delta is * visible; * - after the ONE fresh create call (whatever its result), and on every * reconcile-only resume, the flow performs an immediate post-create * settlement check and then re-checks `keys list` plus staged/final * evidence after the schedule 2, 4, 8, 16, 30, 30, 30 seconds — exactly * eight post-create evidence checks, at most 120 seconds of waiting. If * a recoverable credential or cloud key appears it is settled; if all * checks still show no local credential and no post-baseline key, the * run stays `key_create_uncertain` and the create is NEVER retried * automatically — a user who has verified/removed an orphan must * intentionally reset the key checkpoint rather than have setup guess; * - an invalid, mismatched, or inactive staged/final file fails closed and * is retained securely (never blindly removed). * * gcloud nonzero/throw/lost-result cases are treated the same way: the * deterministic stage and the current key list are inspected, and any only * credential for an active key is preserved. A thrown keys-list invocation * fails with the sanitized `key_create_failed` (baseline) or * `key_create_uncertain` (resume/reconcile) code; a thrown key-create * invocation is reconciled exactly like a lost result. Thrown or stream * text is never forwarded. * * The deterministic staging directory is owner-only (0700) before gcloud * may write a credential into it: a pre-existing directory is verified as * a plain directory and its mode enforced through a no-follow descriptor; * unsafe types or permission failures reject the run without touching * foreign entries. The key create runs gcloud with a RELATIVE `key.json` * destination from the staging directory as the subprocess working * directory (runner `cwd`, expected identity, and a pinned directory fd), * with the staging directory re-verified through a no-follow descriptor * immediately before spawn and its identity checked again immediately * after spawn, before the result is recorded or staged output is trusted. * The isolated Node child (`process.execPath`, parent CWD untouched) * compares its `statSync('.')` with `fstatSync(3)` for the inherited pinned * fd. A replacement cannot pass by reusing the old inode, and gcloud * inherits the verified object-bound CWD — the * staging-cwd write window (#673) is closed with portable built-ins, no * native binding. The post-spawn identity re-check stays as defense in * depth. A transient swap-back (the * replacement removed before the post-spawn check) leaves no key in the * real directory, so the bounded settlement below ends `uncertain` * without ever trusting the foreign directory. The same parent validation runs BEFORE the staged key is ever read during reconciliation: `key.json` is never inspected, * opened, chmod'ed, or read unless its deterministic parent is absent or a * plain directory verified/secured to 0700 through the no-follow * descriptor, and a symlinked or non-directory stage parent fails closed * with `key_create_failed` before the key list, create, install, or * cleanup can run (the foreign target is never followed, chmod'ed, or * read). The install step re-verifies the stage directory identity * captured at inspection before the atomic hard link. * * Staged cleanup happens only after a verified installation and is * ownership-bound and crash-resumable: the final key path is verified with * lstat (a final symlink is refused), the deterministic stage directory is * atomically quarantined to a deterministic private cleanup sibling path * (derived from the same persisted key marker) with the device/inode * captured before the rename verified after it, and the staged entry is * re-checked inside the quarantined 0700 directory as a regular file with * the same device/inode as the no-follow final key immediately before its * unlink; only that owned link is unlinked and only the then-empty owned * directory is rmdir'd. A crash at any boundary resumes: a crash-left * cleanup directory with the matching staged hardlink is finished, an * empty cleanup directory is rmdir'd, and an absent stage + cleanup pair * means the cleanup already completed. Both existing at once fails closed. * Foreign or non-empty entries are preserved (fail closed, never deleted * recursively). Node offers no literal unlinkat without a raw libuv * binding, so this quarantine + post-rename identity design is the * permitted boundary for the unlink race: the setup lock is held for the * whole run (a second setup process cannot acquire it concurrently), and * the cleanup path is derived from the persisted marker, so only runs * resuming THIS checkpoint ever touch the quarantined directory. If * cleanup cannot be confirmed the run fails safely with the * `key_create_started` checkpoint retained, so a resume never creates a * new key. * * Key material never appears in messages, results, or the checkpoint; only * paths, the non-secret key id, and resource names are referenced. */ import { type Stats } from "node:fs"; import { type PlannedCommand, type SetupErrorResult } from "./flowResult.js"; import type { GcloudRunner } from "./gcloudRunner.js"; /** Prefix of the deterministic private sibling staging directory for a key. */ export declare const KEY_STAGE_DIR_PREFIX = ".hikoutei-key-stage-"; /** * Prefix of the deterministic private sibling cleanup directory for a key. * * The cleanup path is derived from the SAME persisted key marker as the * staging path (never an unrecoverable random name), so a crash between * the quarantine rename and the staged unlink/rmdir resumes on the next * run without losing track of the quarantined directory. */ export declare const KEY_CLEANUP_DIR_PREFIX = ".hikoutei-key-cleanup-"; /** File name of the staged key inside the staging directory. */ export declare const KEY_STAGE_FILE_NAME = "key.json"; /** * Dry-run placeholder for the gcloud key create destination. * * The real staging path is derived from a per-run UUID marker and is never * promised byte-for-byte in a plan; the placeholder makes it explicit that * the final key path is never the gcloud destination. */ export declare const KEY_STAGE_PLACEHOLDER = "/key.json"; /** Stable argv for listing the user-managed keys of a service account. */ export declare const KEY_LIST_COMMAND: readonly ["iam", "service-accounts", "keys", "list", "--managed-by=user", "--format=value(name)"]; /** Deterministic private sibling staging directory for a key marker. */ export declare function keyStageDir(keyPath: string, keyMarker: string): string; /** Deterministic private sibling cleanup directory for a key marker. */ export declare function keyCleanupDir(keyPath: string, keyMarker: string): string; /** Deterministic staged key path for a key marker. */ export declare function stagedKeyPath(keyPath: string, keyMarker: string): string; /** IAM resource name of a user-managed key with the given non-secret id. */ export declare function keyResourceNameFor(projectId: string, saEmail: string, keyId: string): string; /** * Parses and validates the machine-readable output of the keys list * command. * * Each non-empty line is normalized into the canonical IAM key resource * name (`projects//serviceAccounts//keys/`) by * {@link normalizeUserManagedKeyLine}; any line that matches neither the * full-resource-name shape nor the bare key-id shape is refused, and the * caller treats the whole list as unusable (fail closed). The hex key-id * segment is case-folded so baseline comparisons and local-file matching * against {@link keyResourceNameFor} are exact regardless of the casing * gcloud emits. The returned list is sorted and deduplicated so it can be * stored as a checkpoint baseline. */ export declare function parseUserManagedKeyList(stdout: string, projectId: string, saEmail: string): readonly string[] | null; /** * Normalizes one line of `keys list --format=value(name)` output into the * canonical IAM key resource name, or returns `null` when the line is * refused. * * gcloud emits the user-managed key list in two known shapes for the same * `--format=value(name)` projection: * - older versions print the full resource name * `projects//serviceAccounts//keys/`; a line for a * foreign project or service account fails the exact project/email * comparison and is refused, preserving the foreign-key guard; * - newer versions (e.g. gcloud 574) print only the bare key id — the last * segment of the resource name, e.g. `82bb5bd2…335db`. The list is scoped * to exactly this service account and project by the `--iam-account` and * `--project` flags of the command, so a bare id that matches the key-id * pattern is the same key and is reconstructed into the canonical * resource name with {@link keyResourceNameFor}. * * The hex key-id segment is case-folded in both shapes so the result is * comparable against local-file metadata and the checkpoint baseline. */ export declare function normalizeUserManagedKeyLine(line: string, projectId: string, saEmail: string): string | null; /** * Lists the user-managed keys of the service account and validates the * output; never forwards raw gcloud stream content. * * A thrown runner (spawn failure, transport error) is treated exactly like * a failed invocation: the run fails with the purpose-appropriate stable * code (`key_create_failed` for a baseline, `key_create_uncertain` for a * reconcile) and no secret-bearing text ever reaches the message. */ export declare function listUserManagedServiceAccountKeys(runner: GcloudRunner, executed: PlannedCommand[], input: { readonly projectId: string; readonly saEmail: string; readonly purpose: "baseline" | "reconcile"; }): Promise<{ readonly status: "ok"; readonly names: readonly string[]; } | { readonly status: "error"; readonly error: SetupErrorResult; }>; /** * Delays in milliseconds between the post-create key-settlement checks. * * After the ONE fresh key create (whatever its result) — and on every * reconcile-only resume — the flow performs an immediate post-create * settlement check and then re-checks `keys list` plus staged/final * evidence after these seven delays: exactly eight post-create evidence * checks in total, at most 120 seconds of waiting. This is the same * safety-class schedule the SA access verify phase uses. */ export declare const KEY_SETTLE_POLL_DELAYS_MS: readonly [2000, 4000, 8000, 16000, 30000, 30000, 30000]; /** Injectable timer used between key-settlement propagation checks. */ export interface Sleeper { sleep(ms: number): Promise; } /** * Events reported by the bounded key-settlement evidence checks. * * `check_started` / `check_completed` bracket one of the eight propagation * evidence checks (1-based attempt; the immediate post-create check of a * fresh create and the first reconcile check are both 1/8). `wait_started` * precedes a scheduled sleep with the delay before the NEXT check and * carries the 1-based index of the check that just ran. Only numbers are * reported — never key material, paths, ids, or raw gcloud output. The * reporter is decoupled from the progress UI so this module never imports * the CLI renderer; `setupFlow` wires it to the progress sink. */ export type KeySettleProgressEvent = { readonly type: "check_started"; readonly attempt: number; readonly maxAttempts: number; } | { readonly type: "check_completed"; readonly attempt: number; readonly maxAttempts: number; } | { readonly type: "wait_started"; readonly attempt: number; readonly maxAttempts: number; readonly delayMs: number; }; /** * Optional reporter for the bounded key-settlement evidence checks and * waits. A throwing callback is swallowed so it can never affect the * settlement result, the mutation order, or the run. */ export interface KeySettleProgressReporter { (event: KeySettleProgressEvent): void; } /** Total propagation evidence checks the key settlement performs (immediate + the scheduled delays). */ export declare const KEY_SETTLE_MAX_ATTEMPTS: number; /** Production sleeper: waits with `setTimeout` so the bounded poll actually waits. */ export declare const realSleeper: Sleeper; /** Inputs shared by the key create/reconcile helpers. */ interface KeySettleInput { readonly keyPath: string; readonly projectId: string; readonly saEmail: string; /** Key marker persisted in the `key_create_started` checkpoint. */ readonly keyMarker: string; /** Baseline of pre-existing user-managed key resource names from the checkpoint. */ readonly baseline: readonly string[]; /** * `fresh`: this invocation JUST persisted the `key_create_started` * checkpoint and may issue the ONE key-create call. `reconcile`: the * invocation loaded the checkpoint from an existing file and must never * issue a create, even when no stage/final/delta is visible. */ readonly createPermission: KeyCreatePermission; /** Timer for the bounded propagation poll; injectable so tests are instant. */ readonly sleeper: Sleeper; /** Optional reporter for the bounded evidence checks and waits; never affects the result. */ readonly onSettleProgress?: KeySettleProgressReporter; } /** * Whether this invocation may issue the single key create. * * Explicit fresh-vs-reconcile semantics: a lagging IAM key list must never * permit a duplicate, so only the invocation that just persisted a fresh * `key_create_started` checkpoint may create; every resume is * reconcile-only. */ export type KeyCreatePermission = "fresh" | "reconcile"; /** Outcome of settling the key state. */ export type KeySettleOutcome = { readonly status: "ok"; readonly keyReused: boolean; } | { readonly status: "error"; readonly error: SetupErrorResult; }; /** * Settles (or performs) the service-account key create and settles the * key state. * * Inspects the deterministic staged key and the final key with the secure * descriptor read (regular file, no-follow, owner-only mode 0600 enforced), * lists and validates the current user-managed keys, and applies the * reconciliation rules documented at the top of this module. `fresh` * permission issues exactly ONE create attempt, and only when this * invocation JUST persisted the `key_create_started` checkpoint; every * other path is reconcile-only and never creates. The first pass is the * pre-create evidence check (and, for `fresh`, carries the one create). * When no credential and no post-baseline key are visible after it, an * IMMEDIATE post-create settlement check runs, followed by one check * after each of the seven `KEY_SETTLE_POLL_DELAYS_MS` delays — exactly * eight post-create evidence checks — and if nothing appears the run * stays `key_create_uncertain`; the create is never retried * automatically. Every other outcome fails closed with the checkpoint * retained. Returns `keyReused` so the caller can preserve accurate * keyFresh/keyReused summary and verify-retry semantics (a key whose * resource was in the baseline pre-existed this setup; a recovered or * freshly created key did not). */ export declare function settleServiceAccountKey(runner: GcloudRunner, executed: PlannedCommand[], input: KeySettleInput): Promise; /** * Creates or secures the deterministic staging directory and pins it open. * * A pre-existing entry is accepted only when it is a real directory (a * crash between the checkpoint write and the create leaves an empty owned * directory that is reused); anything else is refused and left untouched. * Before gcloud may write a credential into the directory its permissions * are enforced to owner-only 0700 through a no-follow descriptor where the * platform supports it (the mode is applied and verified on the open * descriptor, never via a check-then-chmod path race), and any unsafe * type/permission failure rejects the run without touching foreign * entries. Its open descriptor pins the directory object through process * launch, preventing inode reuse from making a replacement appear identical. * Exported so tests can exercise the pre-existing-directory path directly. */ export declare function prepareStageDir(keyPath: string, keyMarker: string): PreparedStageDir; /** * Result of preparing the deterministic staging directory. * * `ok` carries a pinned descriptor and its device/inode identity so the * subprocess wrapper can verify its working directory against the exact * open filesystem object. */ export type PreparedStageDir = { readonly status: "ok"; readonly dev: number; readonly ino: number; readonly fd: number; } | { readonly status: "error"; readonly error: SetupErrorResult; }; /** * Filesystem operations the ownership-bound stage cleanup uses; injectable * for tests (the stage-directory replacement race is simulated through the * injected `renameSync`). */ export interface KeyCleanupFs { lstatSync(path: string): Stats; renameSync(from: string, to: string): void; readdirSync(path: string): string[]; unlinkSync(path: string): void; rmdirSync(path: string): void; openSync(path: string, flags: number): number; fchmodSync(fd: number, mode: number): void; fstatSync(fd: number): Stats; closeSync(fd: number): void; } export declare function cleanupOwnedStage(keyPath: string, keyMarker: string, fs?: KeyCleanupFs): SetupErrorResult | null; export {}; //# sourceMappingURL=keyProvision.d.ts.map