import * as FS from 'node:fs'; import * as PATH from 'node:path'; import { spawnSync, type SpawnSyncReturns } from 'node:child_process'; import { configDir, setting } from './config.ts'; import { CliError } from './errors.ts'; // Session secrets — access and refresh tokens, OAuth DPoP keys — kept in the // system's own credential store, never in a file of ours: // // secret-service Linux and the BSDs: GNOME Keyring, KWallet (Plasma 5.97+), // KeePassXC and anything else speaking the Secret Service // D-Bus API, through `secret-tool` (libsecret) // kwallet KWallet directly, through `kwallet-query`, for KDE setups // without the Secret Service bridge // keychain the macOS login keychain, through `security` // wincred Windows Credential Manager, through PowerShell and the // Win32 credential API // file a 0600 JSON file in the config directory. Never chosen on // its own: for containers and CI, where there is no // keyring, it has to be asked for. // // The secret-store setting picks one explicitly (`atcli config set secret-store // file`); otherwise the platform's native one is used. Each secret is stored // under the service "atcli" and the account's DID, and is base64 of its JSON, // so no store ever has to quote a character it might mangle. // // Secrets go in on stdin, never on a command line, where any other user on the // machine could read them from the process table. export type StoreName = 'secret-service' | 'kwallet' | 'keychain' | 'wincred' | 'file'; export const STORE_NAMES: readonly StoreName[] = ['secret-service', 'kwallet', 'keychain', 'wincred', 'file']; export interface SecretStore { name: StoreName; get(key: string): string | undefined; set(key: string, value: string): void; delete(key: string): void; } const SERVICE = 'atcli'; let chosen: { key: string; store: SecretStore } | undefined; /** The store this machine uses. */ export function secretStore(): SecretStore { const wanted = setting('secret-store'); const key = `${configDir()}\0${wanted}`; if (chosen?.key === key) return chosen.store; let store: SecretStore; if (wanted !== 'auto') store = make(wanted as StoreName); else if (process.platform === 'darwin') store = make('keychain'); else if (process.platform === 'win32') store = make('wincred'); else if (has('secret-tool') || !has('kwallet-query')) store = make('secret-service'); else store = make('kwallet'); chosen = { key, store }; return store; } /** For tests: forget which store was chosen. */ export function resetSecretStore(): void { chosen = undefined; } function make(name: StoreName): SecretStore { switch (name) { case 'secret-service': return secretService(SERVICE); case 'kwallet': return kwallet(SERVICE); case 'keychain': return keychain(SERVICE); case 'wincred': return wincred(SERVICE); case 'file': return fileStore(PATH.join(configDir(), 'secrets.json')); } } /** Read and decode a JSON secret. */ export function readSecret(key: string): T | undefined { const raw = secretStore().get(key); if (raw === undefined) return undefined; try { return JSON.parse(Buffer.from(raw, 'base64').toString('utf-8')) as T; } catch { return undefined; } } export function writeSecret(key: string, value: unknown): void { secretStore().set(key, Buffer.from(JSON.stringify(value)).toString('base64')); } export function deleteSecret(key: string): void { secretStore().delete(key); } // ---------------------------------------------------------------- helpers --- function has(command: string): boolean { const probe = process.platform === 'win32' ? spawnSync('where', [command], { stdio: 'ignore' }) : spawnSync('sh', ['-c', `command -v ${command}`], { stdio: 'ignore' }); return probe.status === 0; } function run(command: string, args: string[], input?: string): SpawnSyncReturns { const result = spawnSync(command, args, { input: input ?? '', encoding: 'utf-8', timeout: 30_000, windowsHide: true }); if (result.error) { const missing = (result.error as NodeJS.ErrnoException).code === 'ENOENT'; throw new CliError(missing ? `the credential store needs '${command}', which is not installed. Install it, or choose another store: atcli config set secret-store <${STORE_NAMES.join('|')}>` : `${command} failed: ${result.error.message}`, 'SecretStoreUnavailable'); } return result; } function failed(store: string, result: SpawnSyncReturns, what: string): never { const detail = (result.stderr || result.stdout || '').trim(); throw new CliError(`${store}: could not ${what}${detail ? `: ${detail}` : ` (exit ${result.status})`}. ` + "Is the keyring unlocked? Where there is none (a container, CI), 'atcli config set secret-store file' keeps secrets in a 0600 file instead.", 'SecretStoreUnavailable'); } // ----------------------------------------------------------------- stores --- function secretService(service: string): SecretStore { const attributes = (key: string) => ['service', service, 'account', key]; return { name: 'secret-service', get(key) { const result = run('secret-tool', ['lookup', ...attributes(key)]); // `lookup` exits 1, saying nothing, when there is no such secret. if (result.status !== 0) { if (!result.stderr.trim()) return undefined; failed('secret-service', result, 'read a secret'); } return result.stdout.trim() || undefined; }, set(key, value) { const result = run('secret-tool', ['store', `--label=atcli: ${key}`, ...attributes(key)], value); if (result.status !== 0) failed('secret-service', result, 'store a secret'); }, delete(key) { run('secret-tool', ['clear', ...attributes(key)]); }, }; } function kwallet(service: string): SecretStore { const wallet = setting('kwallet-wallet'); return { name: 'kwallet', get(key) { const result = run('kwallet-query', ['--folder', service, '--read-password', key, wallet]); if (result.status !== 0) return undefined; return result.stdout.trim() || undefined; }, set(key, value) { const result = run('kwallet-query', ['--folder', service, '--write-password', key, wallet], value); if (result.status !== 0) failed('kwallet', result, 'store a secret'); }, // kwallet-query cannot remove an entry; an empty one reads as none. delete(key) { run('kwallet-query', ['--folder', service, '--write-password', key, wallet], ''); }, }; } function keychain(service: string): SecretStore { return { name: 'keychain', get(key) { const result = run('security', ['find-generic-password', '-s', service, '-a', key, '-w']); if (result.status === 44) return undefined; // errSecItemNotFound if (result.status !== 0) failed('keychain', result, 'read a secret'); return result.stdout.trim() || undefined; }, set(key, value) { // `security -i` reads commands from stdin, so the secret is never an // argument of a process. Values are base64, which needs no quoting. const result = run('security', ['-i'], `add-generic-password -U -s ${quote(service)} -a ${quote(key)} -l ${quote(`atcli: ${key}`)} -w ${value}\n`); if (result.status !== 0 || /error/i.test(result.stderr)) failed('keychain', result, 'store a secret'); }, delete(key) { run('security', ['delete-generic-password', '-s', service, '-a', key]); }, }; } function quote(text: string): string { return `"${text.replace(/["\\]/g, '\\$&')}"`; } // Windows Credential Manager, through the Win32 API from PowerShell. A // generic credential holds at most 2560 bytes, and an OAuth session (tokens // and a key) can be more; so a value is stored in numbered parts, `#0`, // `#1`…, with the count in the first. const WINCRED = String.raw` $ErrorActionPreference = 'Stop' Add-Type -TypeDefinition @' using System; using System.Runtime.InteropServices; public static class AtcliCred { [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] public struct CREDENTIAL { public int Flags; public int Type; public string TargetName; public string Comment; public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten; public int CredentialBlobSize; public IntPtr CredentialBlob; public int Persist; public int AttributeCount; public IntPtr Attributes; public string TargetAlias; public string UserName; } [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] public static extern bool CredReadW(string target, int type, int flags, out IntPtr credential); [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] public static extern bool CredWriteW(ref CREDENTIAL credential, int flags); [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] public static extern bool CredDeleteW(string target, int type, int flags); [DllImport("advapi32.dll")] public static extern void CredFree(IntPtr buffer); public static string Read(string target) { IntPtr p; if (!CredReadW(target, 1, 0, out p)) return null; try { var c = (CREDENTIAL)Marshal.PtrToStructure(p, typeof(CREDENTIAL)); var bytes = new byte[c.CredentialBlobSize]; Marshal.Copy(c.CredentialBlob, bytes, 0, c.CredentialBlobSize); return System.Text.Encoding.UTF8.GetString(bytes); } finally { CredFree(p); } } public static void Write(string target, string value) { var bytes = System.Text.Encoding.UTF8.GetBytes(value); var c = new CREDENTIAL(); c.Type = 1; c.TargetName = target; c.Persist = 2; c.UserName = "atcli"; c.CredentialBlobSize = bytes.Length; c.CredentialBlob = Marshal.AllocHGlobal(bytes.Length); try { Marshal.Copy(bytes, 0, c.CredentialBlob, bytes.Length); if (!CredWriteW(ref c, 0)) throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error()); } finally { Marshal.FreeHGlobal(c.CredentialBlob); } } public static void Delete(string target) { CredDeleteW(target, 1, 0); } } '@ $op, $target = $args[0], $args[1] $chunk = 2000 switch ($op) { 'get' { $head = [AtcliCred]::Read("$target#0") if ($null -eq $head) { exit 3 } $count, $value = $head -split ':', 2 for ($i = 1; $i -lt [int]$count; $i++) { $value += [AtcliCred]::Read("$target#$i") } [Console]::Out.Write($value) } 'set' { $value = [Console]::In.ReadToEnd() $parts = [Math]::Max(1, [Math]::Ceiling($value.Length / $chunk)) for ($i = 0; $i -lt $parts; $i++) { $part = $value.Substring($i * $chunk, [Math]::Min($chunk, $value.Length - $i * $chunk)) if ($i -eq 0) { $part = "$($parts):$part" } [AtcliCred]::Write("$target#$i", $part) } for ($i = $parts; $i -lt 64; $i++) { [AtcliCred]::Delete("$target#$i") } } 'delete' { for ($i = 0; $i -lt 64; $i++) { [AtcliCred]::Delete("$target#$i") } } } `; function wincred(service: string): SecretStore { const shell = has('pwsh') ? 'pwsh' : 'powershell'; const invoke = (op: string, key: string, input?: string) => run(shell, [ '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', `& { ${WINCRED} } ${op} '${`${service}:${key}`.replace(/'/g, "''")}'`, ], input); return { name: 'wincred', get(key) { const result = invoke('get', key); if (result.status === 3) return undefined; if (result.status !== 0) failed('wincred', result, 'read a secret'); return result.stdout.trim() || undefined; }, set(key, value) { const result = invoke('set', key, value); if (result.status !== 0) failed('wincred', result, 'store a secret'); }, delete(key) { invoke('delete', key); }, }; } function fileStore(file: string): SecretStore { const read = (): Record => { try { return JSON.parse(FS.readFileSync(file, 'utf-8')) as Record; } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') return {}; throw err; } }; const write = (data: Record) => { FS.mkdirSync(PATH.dirname(file), { recursive: true, mode: 0o700 }); const temp = `${file}.${process.pid}.tmp`; FS.writeFileSync(temp, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 }); FS.renameSync(temp, file); }; return { name: 'file', get: (key) => read()[key], set(key, value) { write({ ...read(), [key]: value }); }, delete(key) { const data = read(); delete data[key]; write(data); }, }; }