diff --git a/CHANGELOG.md b/CHANGELOG.md index f0b836c..7d26123 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [Unreleased] + +### Added + +- add explicit `--password-file` support to every command that unlocks a wallet, and `--mnemonic-file` to `create-wallet --import`. These POSIX inputs are mutually exclusive with their literal counterparts and require owner-only, regular, non-symlink files; they fail closed on Windows where ACLs are not validated. + ## [0.0.5] — 2026-08-29 ### Added @@ -38,4 +44,3 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed - Unshielding to a custom / ephemeral recipient (`--to` an address that is not a stored HD account) no longer fails or mis-routes funds. - diff --git a/README.md b/README.md index c3147ed..92aaaf8 100644 --- a/README.md +++ b/README.md @@ -154,12 +154,15 @@ Global behavior: | **Default privacy protocol** | Env `DEFAULT_PRIVACY_PROTOCOL` (`tornado` \| `railgun` \| `privacy-pools`). When set, `shield` / `unshield` may omit `--protocol`, and `balances` includes that protocol by default. Examples below still pass `--protocol` / `--include` explicitly. | | **Data directory** | `--dataDir ` (default `~/.kohaku-cli`). | | **Networks** | Wallets created with `--testnet` expect Sepolia (`11155111`); otherwise mainnet (`1`). RPC chain ID must match the wallet. | -| **`--non-interactive`** | Available on every command below. Skips prompts and spinners; prints **JSON** where applicable. Requires flags documented per command (`--password`, `--wallet`, amounts, `--from`, `--to` / `--next`, etc.). Use for CI, agents, and piping output. | -| **`--password`** | Wallet unlock password. In non-interactive mode, required where the wallet is encrypted. Value can be a literal string or a path to a file containing the password. | +| **`--non-interactive`** | Available on every command below. Skips prompts and spinners; prints **JSON** where applicable. Requires flags documented per command (`--password-file` or `--password`, `--wallet`, amounts, `--from`, `--to` / `--next`, etc.). Use for CI, agents, and piping output. | +| **`--password-file `** | Preferred for automation on POSIX. Reads the wallet password from a regular, non-symlink file, so the password is not exposed in process arguments. The file must be owned by the current user with mode `0400` or `0600`. Secure file inputs fail closed on Windows because ACLs are not validated. Mutually exclusive with `--password`. | +| **`--password `** | Legacy wallet unlock input. A literal string or existing file path is accepted for compatibility; prefer the unambiguous `--password-file` form in scripts. | | **`--without-tor`** | Disable Tor for non-RPC HTTP (default: Tor on for private-protocol and Pimlico-backed commands, including `transfer` / `transact-raw` / names). Or set `KOHAKU_WITHOUT_TOR=1`. Ethereum RPC stays clearnet. Review contacts with `view-network-traffic`. | | **Proving artifacts** | Railgun/Tornado keys live under `/proving-artifacts`. Pre-warm with `fetch-artifacts`. Remote base: `KOHAKU_ARTIFACTS_BASE_URL` (default `https://artifacts.0000000000.org`). Large Tor GETs: `KOHAKU_TOR_CDN_TIMEOUT_MS` (default `45000`). Debug: `KOHAKU_TOR_DEBUG=1`. | | **Public-sync cache** | Shared **Railgun Subsquid** and **Tornado saga** HTTP pages live under `/public-sync-cache` and speed up those syncs (`balances`, `shield`, `unshield`). Prefetch with `fetch-sync-cache`. Snapshot base: `KOHAKU_SYNC_CACHE_BASE_URL` (default `https://artifacts.0000000000.org/sync-cache/v1`). Snapshot is historical; live HTTP still fills anything newer. Never evicts — at `KOHAKU_PUBLIC_SYNC_CACHE_MAX_BYTES` (default 1 GiB) new pages stop being stored instead. Privacy Pools is **not** covered (its cold sync is bundled state JSON plus `eth_getLogs`, which is never HTTP-cached). Wipe with `kohaku clear-tor-cache --public-sync`. | +`--password-file` removes one trailing line ending and otherwise preserves password whitespace. When unlocking an existing wallet, it also retries the fully trimmed value for compatibility with the legacy `--password ` behavior. New wallets created with `--password-file` use the preserved value exactly. + --- ### `create-wallet ` @@ -178,7 +181,9 @@ Create a BIP-39 seed wallet encrypted on disk. The `` argument is a single | `--rpc-url ` | Required with `--import` (or `RPC_URL`) to scan used addresses. Optional for new wallets when writing `.stealth-start-block`. | | `--stealth-start-block [block]` | With `--import`: write `.stealth-start-block`. Omit the flag to record the current tip (same as a new wallet). Bare flag: Kohaku floor (mainnet `25700000`, Sepolia `11455454`). With a number: that block, rounded up to the ERC-5564 announcer deploy if lower. | | `--mnemonic ` | Mnemonic (required with `--non-interactive --import`). | -| `--password ` | Encryption password (required with `--non-interactive`). | +| `--mnemonic-file ` | Preferred POSIX import input for automation. Reads the mnemonic from an owner-only regular file without following symlinks; fails closed on Windows. Mutually exclusive with `--mnemonic`. | +| `--password ` | Encryption password required with `--non-interactive`. As before, interactive creation prompts instead of consuming this legacy flag. | +| `--password-file ` | Preferred POSIX automation input; see global behavior above. Mutually exclusive with `--password`. | | `--non-interactive` | No prompts; no mnemonic box on create. | | `--dataDir ` | Data root. | @@ -192,6 +197,8 @@ kohaku create-wallet myWallet24 --testnet --long-seed kohaku create-wallet restored --testnet --import --rpc-url "$RPC_URL" kohaku create-wallet restored --testnet --import --rpc-url "$RPC_URL" --stealth-start-block kohaku create-wallet restored --testnet --import --rpc-url "$RPC_URL" --stealth-start-block 10000000 +chmod 600 ./wallet-password ./wallet-mnemonic +kohaku create-wallet restored-safe --testnet --import --rpc-url "$RPC_URL" --password-file ./wallet-password --mnemonic-file ./wallet-mnemonic --non-interactive ``` --- @@ -853,4 +860,4 @@ Files include `public-accounts.json`, stealth storage, `rg-storage.json`, `ppv1- - **Privacy Pools note size:** Each unshield uses one note; large shields may require multiple unshields if balances are split across notes. - **Tornado notes:** Use `export-tornado-note` / `import-tornado-note` to move legacy note secrets between wallets for testing or recovery. - **Private key / seed exports:** `export-private-key`, `reveal-seed-phrase`, and `export-tornado-note` print raw secrets to stdout. Avoid terminal logs, shell history, and shared environments. -- **Agents:** Pass `--non-interactive --password … --wallet …` and parse JSON stdout; set `RPC_URL` in the environment to avoid repeating `--rpc-url`. +- **Agents:** Pass `--non-interactive --password-file … --wallet …` and parse JSON stdout. Set `RPC_URL` in the environment to avoid repeating `--rpc-url`. Secret files must be regular files, cannot be symlinks, and on POSIX must be owned by the current user with mode `0400` or `0600` (`chmod 600`). diff --git a/src/commands/balances.ts b/src/commands/balances.ts index 5a726be..99d9322 100644 --- a/src/commands/balances.ts +++ b/src/commands/balances.ts @@ -23,7 +23,7 @@ import { type StealthWalletProfile, } from "../lib/stealth/storage.js"; import { resolveRegisterSigner } from "../lib/names/ownership.js"; -import { cliOptions } from "../utils/cli-command-options"; +import { cliOptions, passwordFileOption } from "../utils/cli-command-options"; import { quietNonInteractive, runQuietSpinner, manageSpinner } from "../utils/cli-quiet"; import { cliError, cliErrorFromCaught } from "../utils/cli-errors"; import { @@ -47,6 +47,7 @@ import { readSeedKeystore } from "../utils/mnemonic"; type BalancesOpts = { wallet?: string; password?: string; + passwordFile?: string; nonInteractive?: boolean; verbose?: boolean; include?: string; @@ -493,6 +494,7 @@ export function registerBalancesCommand(program: Command): void { ) .option("--wallet ", cliOptions.walletBalancesOptional) .option("--password ", cliOptions.password) + .addOption(passwordFileOption()) .option("--non-interactive", cliOptions.nonInteractiveBalances) .option( "--verbose", @@ -562,6 +564,7 @@ export function registerBalancesCommand(program: Command): void { const password = await resolveWalletPassword({ flagPassword: opts.password, + flagPasswordFile: opts.passwordFile, nonInteractive: opts.nonInteractive, validate: (candidate) => { readSeedKeystore(candidate, walletDir); diff --git a/src/commands/createWallet.ts b/src/commands/createWallet.ts index 99d1f92..34aa907 100644 --- a/src/commands/createWallet.ts +++ b/src/commands/createWallet.ts @@ -4,7 +4,11 @@ import { password } from "@inquirer/prompts"; import chalk from "chalk"; import type { Command } from "commander"; -import { cliOptions } from "../utils/cli-command-options"; +import { + cliOptions, + mnemonicFileOption, + passwordFileOption, +} from "../utils/cli-command-options"; import { cliError, cliErrorFromCaught } from "../utils/cli-errors"; import { createWalletOnDisk, @@ -21,12 +25,15 @@ import { resolveWalletDir, } from "../utils/wallets-util"; import { normalizeValidatedMnemonic } from "../utils/mnemonic"; +import { readSecretFile } from "../utils/secret-file"; type CreateWalletOpts = { import?: boolean; nonInteractive?: boolean; password?: string; + passwordFile?: string; mnemonic?: string; + mnemonicFile?: string; rpcUrl?: string; testnet?: boolean; longSeed?: boolean; @@ -34,6 +41,14 @@ type CreateWalletOpts = { dataDir?: string; }; +export function shouldResolveCreatePasswordFromFlags(opts: { + nonInteractive?: boolean; + password?: string; + passwordFile?: string; +}): boolean { + return !!opts.nonInteractive || opts.passwordFile !== undefined; +} + function printMnemonicBox(mnemonic: string): void { const line = mnemonic.trim(); const inner = Math.max(line.length + 4, 44); @@ -89,13 +104,15 @@ export function registerCreateWalletCommand(program: Command): void { .option("--import", "Paste an existing mnemonic instead of generating one") .option( "--non-interactive", - "Agent mode: no interactive prompts (requires --password and other flags as documented)" + "Agent mode: no prompts (requires --password/--password-file and other documented flags)" ) .option( "--password ", - "Password to encrypt this wallet (required with --non-interactive; else prompted)" + "Non-interactive encryption password (legacy literal-or-file input)" ) + .addOption(passwordFileOption()) .option("--mnemonic ", "Mnemonic phrase (required with --non-interactive --import)") + .addOption(mnemonicFileOption()) .option("--rpc-url ", "RPC URL (or set RPC_URL). Optional for new wallets: a public RPC is used to record the current block if unset. Required with --import") .option("--testnet", "Use testnet chain ID (11155111) instead of mainnet (1)") .option( @@ -116,6 +133,10 @@ export function registerCreateWalletCommand(program: Command): void { cliError("--long-seed only applies when generating a new mnemonic (omit --import)."); return; } + if (!opts.import && opts.mnemonicFile !== undefined) { + cliError("--mnemonic-file only applies with --import."); + return; + } const dataDir = opts.dataDir ?? DEFAULT_DATA_DIR; let walletDir: string; @@ -134,14 +155,30 @@ export function registerCreateWalletCommand(program: Command): void { let mnemonicPhrase: string; let importRpcUrl: string | undefined; if (opts.import) { - const pasted = opts.nonInteractive - ? opts.mnemonic - : await password({ + let pasted: string | undefined; + if (opts.mnemonicFile !== undefined) { + try { + pasted = readSecretFile(opts.mnemonicFile, { + label: "Mnemonic", + allowNewlines: true, + maxBytes: 4096, + }).replace(/\s+/g, " "); + } catch (e) { + cliErrorFromCaught(e); + return; + } + } else { + pasted = opts.nonInteractive + ? opts.mnemonic + : await password({ message: "Enter your 12 or 24-word mnemonic:", mask: "*", }); + } if (opts.nonInteractive && !pasted?.trim()) { - cliError("--mnemonic is required when using --non-interactive --import."); + cliError( + "--mnemonic or --mnemonic-file is required when using --non-interactive --import." + ); return; } importRpcUrl = resolveRpcUrl(opts.rpcUrl); @@ -160,10 +197,21 @@ export function registerCreateWalletCommand(program: Command): void { } let encryptPassword: string; - if (opts.nonInteractive) { - const resolved = resolvePasswordInputPreferFile(opts.password); + if (shouldResolveCreatePasswordFromFlags(opts)) { + let resolved: string | null; + try { + resolved = resolvePasswordInputPreferFile( + opts.nonInteractive ? opts.password : undefined, + opts.passwordFile + ); + } catch (e) { + cliErrorFromCaught(e); + return; + } if (!resolved) { - cliError("--password is required when using --non-interactive."); + cliError( + "--password or --password-file is required when using --non-interactive." + ); return; } encryptPassword = resolved; diff --git a/src/commands/export-tornado-note.ts b/src/commands/export-tornado-note.ts index 203681a..16d62ea 100644 --- a/src/commands/export-tornado-note.ts +++ b/src/commands/export-tornado-note.ts @@ -7,7 +7,7 @@ import { SecretManager, TornadoCashConfigs, type TCNote } from "@kohaku-eth/torn import { withProtocolRuntime } from "../lib/protocol-runtime.js"; import { addressishToHex } from "../lib/private-notes.js"; -import { cliOptions } from "../utils/cli-command-options"; +import { cliOptions, passwordFileOption } from "../utils/cli-command-options"; import { cliError, cliErrorFromCaught } from "../utils/cli-errors"; import { logCliJson, @@ -39,6 +39,7 @@ import { type ExportTornadoNoteOpts = { wallet?: string; password?: string; + passwordFile?: string; rpcUrl?: string; token?: string; amountWei?: string; @@ -153,6 +154,7 @@ export function registerExportTornadoNoteCommand(program: Command): void { ) .option("--wallet ", cliOptions.walletPickList) .option("--password ", cliOptions.password) + .addOption(passwordFileOption()) .option("--rpc-url ", cliOptions.rpcUrl) .option("--token ", "Token address or symbol (default: eth)") .option("--amount-wei ", "Exact pool denomination in wei/base units") @@ -196,6 +198,7 @@ export function registerExportTornadoNoteCommand(program: Command): void { const password = await resolveWalletPassword({ flagPassword: opts.password, + flagPasswordFile: opts.passwordFile, nonInteractive: opts.nonInteractive, validate: (candidate) => { readSeedKeystore(candidate, walletDir); diff --git a/src/commands/exportPrivateKey.ts b/src/commands/exportPrivateKey.ts index 710e94a..b12da82 100644 --- a/src/commands/exportPrivateKey.ts +++ b/src/commands/exportPrivateKey.ts @@ -6,7 +6,7 @@ import { getAddress, isAddress } from "viem"; import { readSeedKeystore } from "../utils/mnemonic"; import { findPublicAccountByAddress, makePublicAccountsStorage } from "../utils/public-accounts"; import { DEFAULT_DATA_DIR, resolveRpcUrl } from "../utils/rpc"; -import { cliOptions } from "../utils/cli-command-options"; +import { cliOptions, passwordFileOption } from "../utils/cli-command-options"; import { cliError, cliErrorFromCaught } from "../utils/cli-errors"; import { resolveAddressOrName } from "../utils/resolve-name.js"; import { addressFromPrivateKey } from "../utils/viem-tx.js"; @@ -19,6 +19,7 @@ import { type ExportPrivateKeyOpts = { wallet?: string; password?: string; + passwordFile?: string; address?: string; index?: string; rpcUrl?: string; @@ -39,6 +40,7 @@ export function registerExportPrivateKeyCommand(program: Command): void { .description("Export the private key for a wallet public account") .option("--wallet ", cliOptions.walletPickList) .option("--password ", cliOptions.password) + .addOption(passwordFileOption()) .option("--address
", "Public account address or ENS/GNS/WNS name to export") .option("--index ", "Public account index to export") .option("--rpc-url ", `${cliOptions.rpcUrl} (required when --address is a name)`) @@ -72,6 +74,7 @@ export function registerExportPrivateKeyCommand(program: Command): void { const password = await resolveWalletPassword({ flagPassword: opts.password, + flagPasswordFile: opts.passwordFile, nonInteractive: opts.nonInteractive, validate: (candidate) => { readSeedKeystore(candidate, walletDir); diff --git a/src/commands/import-tornado-note.ts b/src/commands/import-tornado-note.ts index deb6311..4bbfce1 100644 --- a/src/commands/import-tornado-note.ts +++ b/src/commands/import-tornado-note.ts @@ -4,7 +4,7 @@ import type { Command } from "commander"; import type { ImportNoteResult } from "@kohaku-eth/tornado-cash"; import { withProtocolRuntime } from "../lib/protocol-runtime.js"; -import { cliOptions } from "../utils/cli-command-options"; +import { cliOptions, passwordFileOption } from "../utils/cli-command-options"; import { cliError, cliErrorFromCaught } from "../utils/cli-errors"; import { logCliJson, @@ -30,6 +30,7 @@ import { type ImportTornadoNoteOpts = { wallet?: string; password?: string; + passwordFile?: string; rpcUrl?: string; nonInteractive?: boolean; withoutTor?: boolean; @@ -82,6 +83,7 @@ export function registerImportTornadoNoteCommand(program: Command): void { ) .option("--wallet ", cliOptions.walletPickList) .option("--password ", cliOptions.password) + .addOption(passwordFileOption()) .option("--rpc-url ", cliOptions.rpcUrl) .option("--non-interactive", cliOptions.nonInteractiveCompact) .option("--without-tor", cliOptions.withoutTor) @@ -115,6 +117,7 @@ export function registerImportTornadoNoteCommand(program: Command): void { const password = await resolveWalletPassword({ flagPassword: opts.password, + flagPasswordFile: opts.passwordFile, nonInteractive: opts.nonInteractive, validate: (candidate) => { readSeedKeystore(candidate, walletDir); diff --git a/src/commands/nextFreshAddress.ts b/src/commands/nextFreshAddress.ts index 8d72a69..9254a8b 100644 --- a/src/commands/nextFreshAddress.ts +++ b/src/commands/nextFreshAddress.ts @@ -1,6 +1,6 @@ import type { Command } from "commander"; -import { cliOptions } from "../utils/cli-command-options"; +import { cliOptions, passwordFileOption } from "../utils/cli-command-options"; import { cliErrorFromCaught } from "../utils/cli-errors"; import { makePublicAccountsStorage } from "../utils/public-accounts"; import { DEFAULT_DATA_DIR } from "../utils/rpc"; @@ -14,6 +14,7 @@ import { readSeedKeystore } from "../utils/mnemonic"; type NextFreshAddressOpts = { wallet?: string; password?: string; + passwordFile?: string; peek?: boolean; nonInteractive?: boolean; dataDir?: string; @@ -25,6 +26,7 @@ export function registerNextFreshAddressCommand(program: Command): void { .description("Generate and persist the next public account address") .option("--wallet ", cliOptions.walletPickList) .option("--password ", cliOptions.password) + .addOption(passwordFileOption()) .option( "--peek", "Print the next fresh address without persisting it (useful for crafting payloads before --next)" @@ -50,6 +52,7 @@ export function registerNextFreshAddressCommand(program: Command): void { const password = await resolveWalletPassword({ flagPassword: opts.password, + flagPasswordFile: opts.passwordFile, nonInteractive: opts.nonInteractive, validate: (candidate) => { readSeedKeystore(candidate, walletDir); diff --git a/src/commands/revealSeedPhrase.ts b/src/commands/revealSeedPhrase.ts index bd5579d..f320259 100644 --- a/src/commands/revealSeedPhrase.ts +++ b/src/commands/revealSeedPhrase.ts @@ -4,7 +4,7 @@ import type { Command } from "commander"; import { readSeedKeystore } from "../utils/mnemonic"; import { DEFAULT_DATA_DIR } from "../utils/rpc"; -import { cliOptions } from "../utils/cli-command-options"; +import { cliOptions, passwordFileOption } from "../utils/cli-command-options"; import { cliError, cliErrorFromCaught } from "../utils/cli-errors"; import { resolveWalletDir, @@ -15,6 +15,7 @@ import { type RevealSeedPhraseOpts = { wallet?: string; password?: string; + passwordFile?: string; nonInteractive?: boolean; dataDir?: string; }; @@ -51,6 +52,7 @@ export function registerRevealSeedPhraseCommand(program: Command): void { .description("Decrypt and print the wallet BIP-39 seed phrase") .option("--wallet ", cliOptions.walletPickList) .option("--password ", cliOptions.password) + .addOption(passwordFileOption()) .option("--non-interactive", cliOptions.nonInteractiveCompact) .option("--dataDir ", cliOptions.dataDir) .action(async (opts: RevealSeedPhraseOpts) => { @@ -72,6 +74,7 @@ export function registerRevealSeedPhraseCommand(program: Command): void { const password = await resolveWalletPassword({ flagPassword: opts.password, + flagPasswordFile: opts.passwordFile, nonInteractive: opts.nonInteractive, validate: (candidate) => { readSeedKeystore(candidate, walletDir); diff --git a/src/commands/see-stealth-meta-address.ts b/src/commands/see-stealth-meta-address.ts index a36bdea..808960f 100644 --- a/src/commands/see-stealth-meta-address.ts +++ b/src/commands/see-stealth-meta-address.ts @@ -3,7 +3,7 @@ import type { Command } from "commander"; import { deriveStealthKeypair } from "../lib/stealth/keys.js"; import { readSeedKeystore } from "../utils/mnemonic"; import { DEFAULT_DATA_DIR } from "../utils/rpc"; -import { cliOptions } from "../utils/cli-command-options"; +import { cliOptions, passwordFileOption } from "../utils/cli-command-options"; import { cliErrorFromCaught } from "../utils/cli-errors"; import { expectedChainIdStringFromWalletDir, @@ -15,6 +15,7 @@ import { type SeeStealthMetaAddressOpts = { wallet?: string; password?: string; + passwordFile?: string; nonInteractive?: boolean; dataDir?: string; }; @@ -27,6 +28,7 @@ export function registerSeeStealthMetaAddressCommand(program: Command): void { ) .option("--wallet ", cliOptions.walletPickList) .option("--password ", cliOptions.password) + .addOption(passwordFileOption()) .option("--non-interactive", cliOptions.nonInteractiveCompact) .option("--dataDir ", cliOptions.dataDir) .action(async (opts: SeeStealthMetaAddressOpts) => { @@ -48,6 +50,7 @@ export function registerSeeStealthMetaAddressCommand(program: Command): void { const password = await resolveWalletPassword({ flagPassword: opts.password, + flagPasswordFile: opts.passwordFile, nonInteractive: opts.nonInteractive, validate: (candidate) => { readSeedKeystore(candidate, walletDir); diff --git a/src/commands/seeDecryptedStorage.ts b/src/commands/seeDecryptedStorage.ts index 3761dbd..fbf08ba 100644 --- a/src/commands/seeDecryptedStorage.ts +++ b/src/commands/seeDecryptedStorage.ts @@ -1,7 +1,7 @@ import { join } from "node:path"; import type { Command } from "commander"; -import { cliOptions } from "../utils/cli-command-options"; +import { cliOptions, passwordFileOption } from "../utils/cli-command-options"; import { cliError, cliErrorFromCaught } from "../utils/cli-errors"; import { DEFAULT_DATA_DIR } from "../utils/rpc"; import { @@ -25,6 +25,7 @@ const TYPE_TO_FILENAME: Record = { type SeeDecryptedStorageOpts = { wallet?: string; password?: string; + passwordFile?: string; nonInteractive?: boolean; dataDir?: string; }; @@ -57,6 +58,7 @@ export function registerSeeDecryptedStorageCommand(program: Command): void { ) .option("--wallet ", cliOptions.walletPickList) .option("--password ", cliOptions.password) + .addOption(passwordFileOption()) .option("--non-interactive", cliOptions.nonInteractiveCompact) .option("--dataDir ", cliOptions.dataDir) .action(async (typeArg: string, opts: SeeDecryptedStorageOpts) => { @@ -88,6 +90,7 @@ export function registerSeeDecryptedStorageCommand(program: Command): void { const password = await resolveWalletPassword({ flagPassword: opts.password, + flagPasswordFile: opts.passwordFile, nonInteractive: opts.nonInteractive, validate: (candidate) => { loadStore(storePath, candidate); diff --git a/src/commands/shield.ts b/src/commands/shield.ts index 0dbc2ae..55abc1d 100644 --- a/src/commands/shield.ts +++ b/src/commands/shield.ts @@ -21,7 +21,7 @@ import { type PublicAccountWithBalance, } from "../lib/shield-flow.js"; import { parseStealthIndex } from "../lib/stealth/storage.js"; -import { cliOptions } from "../utils/cli-command-options"; +import { cliOptions, passwordFileOption } from "../utils/cli-command-options"; import { logCliJson, manageSpinner, @@ -100,6 +100,7 @@ type ShieldOpts = { protocol?: string; wallet?: string; password?: string; + passwordFile?: string; from?: string; fromPriv?: boolean; token?: string; @@ -282,6 +283,7 @@ export function registerShieldCommand(program: Command): void { ) .option("--wallet ", cliOptions.walletPickList) .option("--password ", cliOptions.password) + .addOption(passwordFileOption()) .option( "--from ", "Public sender address, HD index, or stealth selector (s0)" @@ -360,6 +362,7 @@ export function registerShieldCommand(program: Command): void { const password = await resolveWalletPassword({ flagPassword: opts.password, + flagPasswordFile: opts.passwordFile, nonInteractive: opts.nonInteractive, validate: (candidate) => { readSeedKeystore(candidate, walletDir); diff --git a/src/commands/transact-raw.ts b/src/commands/transact-raw.ts index 89e9899..8d93363 100644 --- a/src/commands/transact-raw.ts +++ b/src/commands/transact-raw.ts @@ -11,7 +11,7 @@ import { resolveShieldSender, simulateTransactionOrThrow, } from "../lib/shield-flow.js"; -import { cliOptions } from "../utils/cli-command-options"; +import { cliOptions, passwordFileOption } from "../utils/cli-command-options"; import { logCliJson, quietNonInteractive, runQuietSpinner } from "../utils/cli-quiet"; import { cliError, cliErrorFromCaught } from "../utils/cli-errors"; import { @@ -49,6 +49,7 @@ import { type TransactRawOpts = { wallet?: string; password?: string; + passwordFile?: string; from?: string; fromPriv?: boolean; targets?: string; @@ -214,6 +215,7 @@ export function registerTransactRawCommand(program: Command): void { ) .option("--wallet ", cliOptions.walletPickList) .option("--password ", cliOptions.password) + .addOption(passwordFileOption()) .option("--from ", "Public sender address or public-account index") .option( "--from-priv", @@ -270,6 +272,7 @@ export function registerTransactRawCommand(program: Command): void { const password = await resolveWalletPassword({ flagPassword: opts.password, + flagPasswordFile: opts.passwordFile, nonInteractive: opts.nonInteractive, validate: (candidate) => { readSeedKeystore(candidate, walletDir); diff --git a/src/commands/transfer.ts b/src/commands/transfer.ts index a2c41ea..ea1de09 100644 --- a/src/commands/transfer.ts +++ b/src/commands/transfer.ts @@ -20,7 +20,7 @@ import { } from "eth-stealth-address-resolver"; import { prepareStealthSend } from "../lib/stealth/send.js"; import { parseStealthIndex } from "../lib/stealth/storage.js"; -import { cliOptions } from "../utils/cli-command-options"; +import { cliOptions, passwordFileOption } from "../utils/cli-command-options"; import { logCliJson, quietNonInteractive, runQuietSpinner } from "../utils/cli-quiet"; import { cliError, cliErrorFromCaught } from "../utils/cli-errors"; import { @@ -61,6 +61,7 @@ import { type TransferOpts = { wallet?: string; password?: string; + passwordFile?: string; from?: string; fromPriv?: boolean; to?: string; @@ -206,6 +207,7 @@ export function registerTransferCommand(program: Command): void { .description("Transfer ETH or ERC-20 between public accounts") .option("--wallet ", cliOptions.walletPickList) .option("--password ", cliOptions.password) + .addOption(passwordFileOption()) .option("--from ", "Public sender address, HD index, or stealth selector (s0)") .option( "--from-priv", @@ -262,6 +264,7 @@ export function registerTransferCommand(program: Command): void { const password = await resolveWalletPassword({ flagPassword: opts.password, + flagPasswordFile: opts.passwordFile, nonInteractive: opts.nonInteractive, validate: (candidate) => { readSeedKeystore(candidate, walletDir); diff --git a/src/commands/unshield.ts b/src/commands/unshield.ts index 852bba6..db9bdbe 100644 --- a/src/commands/unshield.ts +++ b/src/commands/unshield.ts @@ -14,7 +14,7 @@ import { parseUnshieldAmount, privacyPoolsRelayerFeeWei, } from "../lib/unshield-flow.js"; -import { cliOptions } from "../utils/cli-command-options"; +import { cliOptions, passwordFileOption } from "../utils/cli-command-options"; import { cliError, cliErrorFromCaught } from "../utils/cli-errors"; import { buildFeePreview, @@ -115,6 +115,7 @@ type UnshieldOpts = { protocol?: string; wallet?: string; password?: string; + passwordFile?: string; to?: string; next?: boolean; token?: string; @@ -164,6 +165,7 @@ export function registerUnshieldCommand(program: Command): void { ) .option("--wallet ", cliOptions.walletPickList) .option("--password ", cliOptions.password) + .addOption(passwordFileOption()) .option( "--to
", "Recipient: public address, HD index address, stealth selector (s0), or name (.eth/.gwei/.wei)" @@ -265,6 +267,7 @@ export function registerUnshieldCommand(program: Command): void { const password = await resolveWalletPassword({ flagPassword: opts.password, + flagPasswordFile: opts.passwordFile, nonInteractive: opts.nonInteractive, validate: (candidate) => { readSeedKeystore(candidate, walletDir); diff --git a/src/lib/names/cli.ts b/src/lib/names/cli.ts index a145208..3c69d28 100644 --- a/src/lib/names/cli.ts +++ b/src/lib/names/cli.ts @@ -1,6 +1,9 @@ import type { Command } from "commander"; -import { cliOptions } from "../../utils/cli-command-options.js"; +import { + cliOptions, + passwordFileOption, +} from "../../utils/cli-command-options.js"; import { cliError, cliErrorFromCaught } from "../../utils/cli-errors.js"; import { readSeedKeystore } from "../../utils/mnemonic.js"; import { @@ -37,6 +40,7 @@ export type NameCommandContext = { export type NameWalletOpts = { wallet?: string; password?: string; + passwordFile?: string; rpcUrl?: string; nonInteractive?: boolean; broadcast?: boolean; @@ -74,6 +78,7 @@ export async function withNameCommandContext( const password = await resolveWalletPassword({ flagPassword: opts.password, + flagPasswordFile: opts.passwordFile, nonInteractive: opts.nonInteractive, validate: (candidate) => { readSeedKeystore(candidate, walletDir); @@ -129,6 +134,7 @@ export function addNameWalletOptions(cmd: Command): Command { return cmd .option("--wallet ", cliOptions.walletPickList) .option("--password ", cliOptions.password) + .addOption(passwordFileOption()) .option("--rpc-url ", cliOptions.rpcUrl) .option( "--broadcast", diff --git a/src/utils/cli-command-options.ts b/src/utils/cli-command-options.ts index 66cb4e4..1d155e3 100644 --- a/src/utils/cli-command-options.ts +++ b/src/utils/cli-command-options.ts @@ -1,20 +1,25 @@ +import { Option } from "commander"; + /** * Shared Commander `--help` strings for options repeated across commands. * Keeps wording in one place without dictating `.option()` call order. */ export const cliOptions = { - password: "Wallet password (required with --non-interactive; else prompted)", + password: + "Wallet password (legacy literal-or-file input; prefer --password-file for automation)", + passwordFile: + "POSIX only: read wallet password from an owner-only file (conflicts with --password)", rpcUrl: "RPC URL (or set RPC_URL in env; default: http://localhost:8545)", dataDir: "Kohaku data directory (default: ~/.kohaku-cli)", walletPickList: "Wallet name (omit to choose interactively from the list)", walletBalancesOptional: "Wallet name (optional without --non-interactive; omit to pick from the list)", nonInteractiveShieldLike: - "Agent mode: JSON where applicable, no confirmations or spinners; requires --password and --wallet", + "Agent mode: JSON where applicable, no confirmations or spinners; requires --password/--password-file and --wallet", nonInteractiveBalances: - "Agent mode: JSON only, no prompts or spinners; requires --password and --wallet", + "Agent mode: JSON only, no prompts or spinners; requires --password/--password-file and --wallet", nonInteractiveCompact: - "Agent mode: no prompts or spinners; requires --password and --wallet", + "Agent mode: no prompts or spinners; requires --password/--password-file and --wallet", nonInteractiveListWallets: "Agent mode: print JSON instead of human-readable output (no prompts)", withoutTor: @@ -26,3 +31,16 @@ export const cliOptions = { stealthStartBlock: "Start ERC-5564 announcement scan at this block (decimal or 0x-hex); skips older history on first/full scan. Can back-date below the wallet `.stealth-start-block` (often the creation/import tip; down to the announcer deploy block). When omitted, uses that file, or the Kohaku import default. Does not skip the scan itself (use --skip-stealth-scan)", } as const; + +export function passwordFileOption(): Option { + return new Option("--password-file ", cliOptions.passwordFile).conflicts( + "password" + ); +} + +export function mnemonicFileOption(): Option { + return new Option( + "--mnemonic-file ", + "POSIX only: read an import mnemonic from an owner-only file (conflicts with --mnemonic)" + ).conflicts("mnemonic"); +} diff --git a/src/utils/secret-file.ts b/src/utils/secret-file.ts new file mode 100644 index 0000000..bd8d2ed --- /dev/null +++ b/src/utils/secret-file.ts @@ -0,0 +1,119 @@ +import { + closeSync, + constants, + fstatSync, + lstatSync, + openSync, + readFileSync, +} from "node:fs"; + +const DEFAULT_MAX_SECRET_BYTES = 64 * 1024; + +export type ReadSecretFileOptions = { + label: string; + allowNewlines?: boolean; + maxBytes?: number; +}; + +export function assertSecretFilePlatformSupported( + platform: NodeJS.Platform = process.platform +): void { + if (platform === "win32") { + throw new Error( + "Secure secret-file inputs are unsupported on Windows because file ACLs cannot be validated." + ); + } +} + +/** + * Read a secret from an owner-only regular file without following symlinks. + * A single trailing newline is ignored so files created with `printf` or a + * password manager's file export can be used directly. + */ +export function readSecretFile( + filePath: string, + options: ReadSecretFileOptions +): string { + const trimmedPath = filePath.trim(); + if (!trimmedPath) { + throw new Error(`${options.label} file path cannot be empty.`); + } + assertSecretFilePlatformSupported(); + + let pathStat: ReturnType; + try { + pathStat = lstatSync(trimmedPath); + } catch { + throw new Error(`Unable to securely open ${options.label.toLowerCase()} file.`); + } + if (pathStat.isSymbolicLink()) { + throw new Error(`${options.label} file cannot be a symbolic link.`); + } + if (!pathStat.isFile()) { + throw new Error(`${options.label} file must be a regular file.`); + } + + const noFollow = constants.O_NOFOLLOW ?? 0; + const nonBlock = constants.O_NONBLOCK ?? 0; + let fd: number; + try { + fd = openSync(trimmedPath, constants.O_RDONLY | noFollow | nonBlock); + } catch { + throw new Error(`Unable to securely open ${options.label.toLowerCase()} file.`); + } + + let contents: string; + try { + const stat = fstatSync(fd); + if (stat.dev !== pathStat.dev || stat.ino !== pathStat.ino) { + throw new Error(`${options.label} file changed while it was being opened.`); + } + if (!stat.isFile()) { + throw new Error(`${options.label} file must be a regular file.`); + } + + const maxBytes = options.maxBytes ?? DEFAULT_MAX_SECRET_BYTES; + if (stat.size > maxBytes) { + throw new Error( + `${options.label} file is too large (maximum ${maxBytes} bytes).` + ); + } + + const currentUid = process.getuid?.(); + if (currentUid === undefined) { + throw new Error(`${options.label} file ownership cannot be validated.`); + } + if (stat.uid !== currentUid) { + throw new Error(`${options.label} file must be owned by the current user.`); + } + const permissions = stat.mode & 0o777; + if (permissions !== 0o400 && permissions !== 0o600) { + throw new Error( + `${options.label} file permissions must be 0400 or 0600; run chmod 600 on the file.` + ); + } + + const bytes = readFileSync(fd); + if (bytes.length > maxBytes) { + throw new Error( + `${options.label} file is too large (maximum ${maxBytes} bytes).` + ); + } + contents = bytes.toString("utf-8"); + } finally { + closeSync(fd); + } + + if (contents.includes("\0")) { + throw new Error(`${options.label} file contains a NUL byte.`); + } + + const value = contents.replace(/(?:\r\n|\n)$/, ""); + if (!options.allowNewlines && /[\r\n]/.test(value)) { + throw new Error(`${options.label} file must contain exactly one line.`); + } + if (!value) { + throw new Error(`${options.label} file cannot be empty.`); + } + return value; +} diff --git a/src/utils/wallets-util.ts b/src/utils/wallets-util.ts index 8a02720..2ddf82a 100644 --- a/src/utils/wallets-util.ts +++ b/src/utils/wallets-util.ts @@ -5,6 +5,7 @@ import { password, select } from "@inquirer/prompts"; import { cliError } from "./cli-errors"; import { SEED_FILENAME } from "./mnemonic"; +import { readSecretFile } from "./secret-file"; // --- Paths: CLI wallet name → filesystem --- @@ -94,11 +95,42 @@ export function expectedChainIdStringFromWalletDir(walletDir: string): string { */ export async function resolveWalletPassword(opts: { flagPassword?: string | undefined; + flagPasswordFile?: string | undefined; nonInteractive?: boolean | undefined; /** Prompt text when asking interactively (default: "Wallet password:"). */ promptMessage?: string | undefined; validate?: ((password: string) => void | Promise) | undefined; }): Promise { + if (opts.flagPassword !== undefined && opts.flagPasswordFile !== undefined) { + throw new Error("--password and --password-file are mutually exclusive."); + } + + if (opts.flagPasswordFile !== undefined) { + const fromFile = readSecretFile(opts.flagPasswordFile, { + label: "Password", + }); + if (opts.validate) { + const candidates = [fromFile]; + const legacyTrimmed = fromFile.trim(); + if (legacyTrimmed && legacyTrimmed !== fromFile) { + candidates.push(legacyTrimmed); + } + let lastErr: unknown; + for (const candidate of candidates) { + try { + await opts.validate(candidate); + return candidate; + } catch (e) { + lastErr = e; + } + } + throw lastErr instanceof Error + ? lastErr + : new Error("Invalid wallet password."); + } + return fromFile; + } + const fromFlag = opts.flagPassword?.trim(); if (fromFlag) { const candidates: string[] = [fromFlag]; @@ -127,7 +159,9 @@ export async function resolveWalletPassword(opts: { return candidates[0]!; } if (opts.nonInteractive) { - cliError("--password is required when using --non-interactive."); + cliError( + "--password or --password-file is required when using --non-interactive." + ); return null; } for (;;) { @@ -158,8 +192,16 @@ export async function resolveWalletPassword(opts: { * treat --password as the literal password text. */ export function resolvePasswordInputPreferFile( - flagPassword: string | undefined + flagPassword: string | undefined, + flagPasswordFile?: string | undefined ): string | null { + if (flagPassword !== undefined && flagPasswordFile !== undefined) { + throw new Error("--password and --password-file are mutually exclusive."); + } + if (flagPasswordFile !== undefined) { + return readSecretFile(flagPasswordFile, { label: "Password" }); + } + const raw = flagPassword?.trim(); if (!raw) return null; diff --git a/tests/secret-file-cli.test.ts b/tests/secret-file-cli.test.ts new file mode 100644 index 0000000..733b337 --- /dev/null +++ b/tests/secret-file-cli.test.ts @@ -0,0 +1,119 @@ +import assert from "node:assert/strict"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +import { describe, it } from "node:test"; + +import { writeSeedKeystore } from "../src/utils/mnemonic.js"; +import { shouldResolveCreatePasswordFromFlags } from "../src/commands/createWallet.js"; + +const MNEMONIC = + "test test test test test test test test test test test junk"; +const CLI_PATH = fileURLToPath(new URL("../src/index.ts", import.meta.url)); +const REPO_DIR = dirname(dirname(CLI_PATH)); + +function runCli(args: string[]): ReturnType { + return spawnSync(process.execPath, ["--import", "tsx", CLI_PATH, ...args], { + cwd: REPO_DIR, + encoding: "utf-8", + env: { ...process.env, NO_COLOR: "1" }, + }); +} + +function outputText(output: string | Buffer | null): string { + if (output === null) return ""; + return typeof output === "string" ? output : output.toString("utf-8"); +} + +function combinedOutput(result: ReturnType): string { + return `${outputText(result.stdout)}${outputText(result.stderr)}`; +} + +describe("secret-file CLI wiring", () => { + it("preserves interactive create-wallet handling for the legacy password flag", () => { + assert.equal( + shouldResolveCreatePasswordFromFlags({ password: "legacy-literal" }), + false + ); + assert.equal( + shouldResolveCreatePasswordFromFlags({ passwordFile: "/secure/password" }), + true + ); + assert.equal( + shouldResolveCreatePasswordFromFlags({ + nonInteractive: true, + password: "legacy-literal", + }), + true + ); + }); + + it("rejects conflicting create/import inputs without printing their values", () => { + const mnemonicValue = "mnemonic-value-must-not-appear"; + const mnemonicPath = "/tmp/mnemonic-path-must-not-appear"; + const result = runCli([ + "create-wallet", + "smoke", + "--import", + "--mnemonic", + mnemonicValue, + "--mnemonic-file", + mnemonicPath, + "--password", + "unused", + "--non-interactive", + ]); + const output = combinedOutput(result); + assert.equal(result.status, 1); + assert.match(output, /cannot be used with option '--mnemonic/); + assert.doesNotMatch(output, new RegExp(mnemonicValue)); + assert.doesNotMatch(output, new RegExp(mnemonicPath)); + }); + + it("rejects a missing create/import mnemonic file without printing its path", { skip: process.platform === "win32" }, () => { + const mnemonicPath = "/tmp/missing-mnemonic-path-must-not-appear"; + const result = runCli([ + "create-wallet", + "smoke", + "--import", + "--mnemonic-file", + mnemonicPath, + "--password", + "unused", + "--non-interactive", + ]); + const output = combinedOutput(result); + assert.equal(result.status, 1); + assert.match(output, /Unable to securely open mnemonic file/); + assert.doesNotMatch(output, new RegExp(mnemonicPath)); + }); + + it("unlocks an existing wallet command through --password-file", { skip: process.platform === "win32" }, () => { + const dataDir = mkdtempSync(join(tmpdir(), "kohaku-secret-cli-")); + const walletDir = join(dataDir, "smoke"); + const passwordPath = join(dataDir, "password"); + try { + mkdirSync(walletDir, { recursive: true }); + writeSeedKeystore(MNEMONIC, "file-password", walletDir); + writeFileSync(passwordPath, "file-password\n", { mode: 0o600 }); + chmodSync(passwordPath, 0o600); + + const result = runCli([ + "reveal-seed-phrase", + "--wallet", + "smoke", + "--password-file", + passwordPath, + "--non-interactive", + "--dataDir", + dataDir, + ]); + assert.equal(result.status, 0, combinedOutput(result)); + assert.equal(outputText(result.stdout).trim(), MNEMONIC); + } finally { + rmSync(dataDir, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/secret-file.test.ts b/tests/secret-file.test.ts new file mode 100644 index 0000000..912d7a1 --- /dev/null +++ b/tests/secret-file.test.ts @@ -0,0 +1,222 @@ +import assert from "node:assert/strict"; +import { + chmodSync, + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it } from "node:test"; + +import { + assertSecretFilePlatformSupported, + readSecretFile, +} from "../src/utils/secret-file.js"; +import { + resolvePasswordInputPreferFile, + resolveWalletPassword, +} from "../src/utils/wallets-util.js"; + +function withSecretFile( + contents: string, + run: (filePath: string, dir: string) => void +): void { + const dir = mkdtempSync(join(tmpdir(), "kohaku-secret-")); + const filePath = join(dir, "secret"); + try { + writeFileSync(filePath, contents, { mode: 0o600 }); + chmodSync(filePath, 0o600); + run(filePath, dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +describe("readSecretFile", { skip: process.platform === "win32" }, () => { + it("reads an owner-only file and removes one trailing newline", () => { + withSecretFile("correct horse battery staple\n", (filePath) => { + assert.equal( + readSecretFile(filePath, { label: "Password" }), + "correct horse battery staple" + ); + }); + }); + + it("accepts a CRLF line ending", () => { + withSecretFile("secret\r\n", (filePath) => { + assert.equal(readSecretFile(filePath, { label: "Password" }), "secret"); + }); + }); + + it("preserves whitespace that is part of a password", () => { + withSecretFile(" leading and trailing ", (filePath) => { + assert.equal( + readSecretFile(filePath, { label: "Password" }), + " leading and trailing " + ); + }); + }); + + it("rejects empty, multiline, oversized, and NUL-containing files", () => { + for (const [contents, expected] of [ + ["", /cannot be empty/], + ["one\ntwo\n", /exactly one line/], + ["a\0b", /NUL byte/], + ] as const) { + withSecretFile(contents, (filePath) => { + assert.throws( + () => readSecretFile(filePath, { label: "Password" }), + expected + ); + }); + } + + withSecretFile("12345", (filePath) => { + assert.throws( + () => + readSecretFile(filePath, { label: "Password", maxBytes: 4 }), + /too large/ + ); + }); + }); + + it("requires mode 0400 or 0600 on POSIX", { skip: process.platform === "win32" }, () => { + withSecretFile("secret", (filePath) => { + for (const mode of [0o644, 0o700]) { + chmodSync(filePath, mode); + assert.throws( + () => readSecretFile(filePath, { label: "Password" }), + /permissions must be 0400 or 0600/ + ); + } + chmodSync(filePath, 0o400); + assert.equal(readSecretFile(filePath, { label: "Password" }), "secret"); + }); + }); + + it("does not follow symlinks", { skip: process.platform === "win32" }, () => { + withSecretFile("secret", (filePath, dir) => { + const linkPath = join(dir, "secret-link"); + symlinkSync(filePath, linkPath); + assert.throws( + () => readSecretFile(linkPath, { label: "Password" }), + /cannot be a symbolic link/ + ); + }); + }); + + it("rejects a missing path without including it in the error", () => { + const missingPath = "/tmp/kohaku-missing-secret-path-marker"; + assert.throws( + () => readSecretFile(missingPath, { label: "Password" }), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.match(error.message, /Unable to securely open password file/); + assert.doesNotMatch(error.message, new RegExp(missingPath)); + return true; + } + ); + }); + + it("rejects a directory before opening it", () => { + const dir = mkdtempSync(join(tmpdir(), "kohaku-secret-dir-")); + const nestedDir = join(dir, "not-a-file"); + try { + mkdirSync(nestedDir); + assert.throws( + () => readSecretFile(nestedDir, { label: "Password" }), + /must be a regular file/ + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rejects a FIFO before opening it", { skip: process.platform === "win32" }, () => { + const dir = mkdtempSync(join(tmpdir(), "kohaku-secret-fifo-")); + const fifoPath = join(dir, "secret-fifo"); + try { + const made = spawnSync("mkfifo", [fifoPath], { encoding: "utf-8" }); + assert.equal(made.status, 0, made.stderr); + assert.throws( + () => readSecretFile(fifoPath, { label: "Password" }), + /must be a regular file/ + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("does not include secret contents or paths in validation errors", () => { + const secretValue = "do-not-print-this-value"; + withSecretFile(`${secretValue}\nsecond-line`, (filePath) => { + let message = ""; + try { + readSecretFile(filePath, { label: "Password" }); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + assert.ok(message); + assert.doesNotMatch(message, new RegExp(secretValue)); + assert.doesNotMatch(message, new RegExp(filePath)); + }); + }); +}); + +describe("assertSecretFilePlatformSupported", () => { + it("fails closed when Windows ACLs cannot be validated", () => { + assert.throws( + () => assertSecretFilePlatformSupported("win32"), + /unsupported on Windows/ + ); + }); +}); + +describe( + "resolvePasswordInputPreferFile", + { skip: process.platform === "win32" }, + () => { + it("supports explicit file input without changing legacy literal input", () => { + withSecretFile("from-file\n", (filePath) => { + assert.equal(resolvePasswordInputPreferFile(undefined, filePath), "from-file"); + assert.equal(resolvePasswordInputPreferFile("literal"), "literal"); + }); + }); + + it("rejects ambiguous simultaneous inputs", () => { + assert.throws( + () => resolvePasswordInputPreferFile("literal", "/tmp/password"), + /mutually exclusive/ + ); + }); + } +); + +describe("resolveWalletPassword", { skip: process.platform === "win32" }, () => { + it("retries legacy-trimmed file contents when unlocking an existing wallet", async () => { + const dir = mkdtempSync(join(tmpdir(), "kohaku-secret-")); + const filePath = join(dir, "password"); + try { + writeFileSync(filePath, " legacy-password \n", { mode: 0o600 }); + chmodSync(filePath, 0o600); + const attempts: string[] = []; + const resolved = await resolveWalletPassword({ + flagPasswordFile: filePath, + validate: (candidate) => { + attempts.push(candidate); + if (candidate !== "legacy-password") { + throw new Error("wrong password"); + } + }, + }); + assert.equal(resolved, "legacy-password"); + assert.deepEqual(attempts, [" legacy-password ", "legacy-password"]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +});