[READ-ONLY] Mirror of https://github.com/just-cameron/tangled-cli. Automation-first CLI for Tangled.org
0

Configure Feed

Select the types of activity you want to include in your feed.

Secure macOS Keychain credential storage.

Route writes through a non-logging PTY without argv secrets, chunk long encoded values, and redact malformed credential failures so OAuth login cannot expose user input.

👾 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta Code <noreply@letta.com>

+592 -82
+8 -6
README.md
··· 46 46 tang auth logout # confirms before deleting the session 47 47 ``` 48 48 49 - On macOS, `tang` accesses its Keychain entries through Apple's stable 50 - `/usr/bin/security` helper. This prevents Homebrew or other Node upgrades from 51 - changing the executable identity attached to the credentials and causing a new 52 - Keychain prompt on every command. Existing credentials created by an older 53 - version may ask once whether `security` can access the item; enter your macOS 54 - login password and choose **Always Allow** to complete the migration. 49 + On macOS, `tang` stores OAuth credentials in the login Keychain and accesses 50 + them through Apple's stable `/usr/bin/security` helper. The normal login flow 51 + does not ask you to enter any Keychain item data: provide your handle, approve 52 + the browser login, and `tang` stores the resulting session automatically. If a 53 + legacy item needs authorization, macOS may show its standard system dialog; 54 + enter a Mac login password only in that recognizable dialog. A terminal prompt 55 + that says `password data for new item` is never an instruction to enter an 56 + account or Mac password. 55 57 56 58 Legacy PDS app passwords remain available: 57 59
+4 -2
src/lib/api-client.ts
··· 6 6 deleteOAuthSession, 7 7 deleteSession, 8 8 getCurrentSessionMetadata, 9 + InvalidCredentialDataError, 9 10 KeychainAccessError, 10 11 loadSession, 11 12 saveCurrentSessionMetadata, ··· 127 128 128 129 return true; 129 130 } catch (error) { 130 - if (error instanceof KeychainAccessError) { 131 - // Don't clear credentials — keychain may just be temporarily locked 131 + if (error instanceof KeychainAccessError || error instanceof InvalidCredentialDataError) { 132 + // Preserve metadata so the caller can report and remediate the exact 133 + // credential-storage failure instead of treating it as logged out. 132 134 throw error; 133 135 } 134 136 // Session resume failed (network error, expired refresh token, etc.)
+13
src/lib/errors.ts
··· 1 + export class KeychainAccessError extends Error { 2 + constructor(message: string) { 3 + super(message); 4 + this.name = 'KeychainAccessError'; 5 + } 6 + } 7 + 8 + export class InvalidCredentialDataError extends Error { 9 + constructor() { 10 + super('Stored authentication data is invalid.'); 11 + this.name = 'InvalidCredentialDataError'; 12 + } 13 + }
+314 -45
src/lib/keychain.ts
··· 1 1 import { spawn } from 'node:child_process'; 2 + import { createHash, randomBytes } from 'node:crypto'; 3 + import { Writable } from 'node:stream'; 2 4 import { AsyncEntry } from '@napi-rs/keyring'; 5 + import { InvalidCredentialDataError } from './errors.js'; 3 6 4 7 const MACOS_SECURITY_PATH = '/usr/bin/security'; 8 + const MACOS_EXPECT_PATH = '/usr/bin/expect'; 9 + const EXPECT_SCRIPT_FD = '/dev/fd/3'; 10 + const MACOS_VALUE_PREFIX = 'tangled-cli-keychain-v1:'; 11 + const MACOS_CHUNK_MANIFEST_PREFIX = 'tangled-cli-keychain-v2:'; 12 + const MACOS_CHUNK_SERVICE_SUFFIX = ':tangled-cli-chunks-v2'; 13 + const MACOS_MAX_PROMPT_VALUE_LENGTH = 96; 14 + const MACOS_MAX_CHUNKS = 4096; 5 15 const ITEM_NOT_FOUND_STATUS = 44; 6 16 const DUPLICATE_ITEM_STATUS = 45; 7 17 18 + const SECURITY_PASSWORD_SCRIPT = String.raw` 19 + log_user 0 20 + set timeout 10 21 + fconfigure stdin -encoding utf-8 -translation lf 22 + if {[gets stdin password_hex] < 0} { 23 + exit 70 24 + } 25 + if {![regexp {^[0-9a-f]*$} $password_hex]} { 26 + set password_hex "" 27 + exit 70 28 + } 29 + set password [binary format H* $password_hex] 30 + set password_hex "" 31 + set env(LC_ALL) C 32 + spawn -noecho /usr/bin/security {*}$argv 33 + expect { 34 + "password data for new item:" { 35 + send -- "$password\r" 36 + exp_continue 37 + } 38 + "retype password for new item:" { 39 + send -- "$password\r" 40 + exp_continue 41 + } 42 + timeout { 43 + set password "" 44 + exit 124 45 + } 46 + eof {} 47 + } 48 + set password "" 49 + set result [wait] 50 + if {[lindex $result 2] != 0} { 51 + exit 1 52 + } 53 + exit [lindex $result 3] 54 + `; 55 + 8 56 interface CommandResult { 9 57 code: number; 10 58 stdout: string; 11 59 stderr: string; 12 60 } 13 61 14 - type SecurityRunner = (args: string[], input?: string) => Promise<CommandResult>; 62 + type SecurityRunner = (args: string[]) => Promise<CommandResult>; 63 + type SecurityPasswordWriter = (args: string[], password: string) => Promise<CommandResult>; 64 + 65 + interface SecurityPasswordInvocation { 66 + executable: string; 67 + args: string[]; 68 + passwordInput: string; 69 + scriptInput: string; 70 + } 71 + 72 + interface ChunkManifest { 73 + generation: string; 74 + count: number; 75 + } 15 76 16 77 export interface KeychainStore { 17 78 setPassword(service: string, account: string, password: string): Promise<void>; ··· 19 80 deletePassword(service: string, account: string): Promise<boolean>; 20 81 } 21 82 22 - function runSecurityCommand(args: string[], input?: string): Promise<CommandResult> { 83 + function runSecurityCommand(args: string[]): Promise<CommandResult> { 23 84 return new Promise((resolve, reject) => { 24 85 const child = spawn(MACOS_SECURITY_PATH, args, { 25 86 stdio: ['pipe', 'pipe', 'pipe'], ··· 40 101 resolve({ code: code ?? 1, stdout, stderr }); 41 102 }); 42 103 43 - child.stdin.end(input); 104 + child.stdin.end(); 105 + }); 106 + } 107 + 108 + /** 109 + * Build the macOS password-write process without ever placing the password in 110 + * argv. Apple's `security ... -w` reads from its controlling terminal rather 111 + * than stdin, so a small, non-logging Expect program supplies that prompt over 112 + * a private pseudo-terminal. The program and hex-encoded password travel on 113 + * separate pipes. 114 + */ 115 + export function createMacOsSecurityPasswordInvocation( 116 + securityArgs: string[], 117 + password: string 118 + ): SecurityPasswordInvocation { 119 + if (!/^[\x20-\x7e]+$/.test(password)) { 120 + throw new Error('The macOS Keychain helper requires an ASCII storage envelope'); 121 + } 122 + 123 + return { 124 + executable: MACOS_EXPECT_PATH, 125 + args: ['-N', '-n', '-f', EXPECT_SCRIPT_FD, '--', ...securityArgs], 126 + passwordInput: `${Buffer.from(password, 'utf8').toString('hex')}\n`, 127 + scriptInput: SECURITY_PASSWORD_SCRIPT, 128 + }; 129 + } 130 + 131 + function runSecurityPasswordCommand(args: string[], password: string): Promise<CommandResult> { 132 + const invocation = createMacOsSecurityPasswordInvocation(args, password); 133 + 134 + return new Promise((resolve, reject) => { 135 + const child = spawn(invocation.executable, invocation.args, { 136 + stdio: ['pipe', 'pipe', 'pipe', 'pipe'], 137 + }); 138 + let stdout = ''; 139 + let stderr = ''; 140 + const scriptPipe = child.stdio[3]; 141 + 142 + if (!(scriptPipe instanceof Writable)) { 143 + child.kill(); 144 + reject(new Error('Failed to open the secure Keychain helper pipe')); 145 + return; 146 + } 147 + 148 + child.stdout.setEncoding('utf8'); 149 + child.stderr.setEncoding('utf8'); 150 + child.stdout.on('data', (chunk: string) => { 151 + stdout += chunk; 152 + }); 153 + child.stderr.on('data', (chunk: string) => { 154 + stderr += chunk; 155 + }); 156 + child.once('error', reject); 157 + child.once('close', (code) => { 158 + resolve({ code: code ?? 1, stdout, stderr }); 159 + }); 160 + 161 + child.stdin.once('error', reject); 162 + scriptPipe.once('error', reject); 163 + child.stdin.end(invocation.passwordInput); 164 + scriptPipe.end(invocation.scriptInput); 44 165 }); 45 166 } 46 167 47 168 function commandError(action: string, result: CommandResult): Error { 48 - const detail = result.stderr.trim() || result.stdout.trim() || `exit status ${result.code}`; 49 - return new Error(`macOS Keychain ${action} failed: ${detail}`); 169 + // Command output can contain credential data now or on a future macOS 170 + // version. Authentication errors expose only a fixed action and status. 171 + return new Error(`macOS Keychain ${action} failed: exit status ${result.code}`); 172 + } 173 + 174 + function passwordCommandError(action: string, result: CommandResult): Error { 175 + // The helper has handled the credential, so none of its captured output is 176 + // safe to surface even if a future platform version unexpectedly echoes it. 177 + return new Error(`macOS Keychain ${action} failed: exit status ${result.code}`); 178 + } 179 + 180 + function encodeMacOsKeychainValue(value: string): string { 181 + return `${MACOS_VALUE_PREFIX}${Buffer.from(value, 'utf8').toString('base64url')}`; 182 + } 183 + 184 + function decodeBase64Value(encoded: string): string { 185 + if (!/^[A-Za-z0-9_-]*$/.test(encoded)) { 186 + throw new InvalidCredentialDataError(); 187 + } 188 + 189 + const bytes = Buffer.from(encoded, 'base64url'); 190 + if (bytes.toString('base64url') !== encoded) { 191 + throw new InvalidCredentialDataError(); 192 + } 193 + 194 + try { 195 + return new TextDecoder('utf-8', { fatal: true }).decode(bytes); 196 + } catch { 197 + throw new InvalidCredentialDataError(); 198 + } 199 + } 200 + 201 + function decodeMacOsKeychainValue(value: string): string { 202 + if (!value.startsWith(MACOS_VALUE_PREFIX)) return value; 203 + return decodeBase64Value(value.slice(MACOS_VALUE_PREFIX.length)); 204 + } 205 + 206 + function parseChunkManifest(value: string): ChunkManifest | null { 207 + if (!value.startsWith(MACOS_CHUNK_MANIFEST_PREFIX)) return null; 208 + 209 + const match = /^tangled-cli-keychain-v2:([0-9a-f]{16}):([1-9][0-9]*)$/.exec(value); 210 + const count = match ? Number.parseInt(match[2], 10) : 0; 211 + if (!match || !Number.isSafeInteger(count) || count > MACOS_MAX_CHUNKS) { 212 + throw new InvalidCredentialDataError(); 213 + } 214 + 215 + return { generation: match[1], count }; 216 + } 217 + 218 + function recoverableChunkManifest(value: string | null): ChunkManifest | null { 219 + if (value === null) return null; 220 + try { 221 + return parseChunkManifest(value); 222 + } catch (error) { 223 + // A corrupted manifest must not prevent logout or replacement. Its chunk 224 + // names are unrecoverable, but the primary item can still be removed. 225 + if (error instanceof InvalidCredentialDataError) return null; 226 + throw error; 227 + } 228 + } 229 + 230 + function chunkService(service: string): string { 231 + return `${service}${MACOS_CHUNK_SERVICE_SUFFIX}`; 50 232 } 51 233 52 - function passwordInput(password: string): string { 53 - // `security ... -w` without a value reads and confirms the secret on stdin. 54 - // This keeps session tokens out of argv and process listings. 55 - return `${password}\n${password}\n`; 234 + function chunkAccount(service: string, account: string, generation: string, index: number): string { 235 + const owner = createHash('sha256').update(service).update('\0').update(account).digest('hex'); 236 + return `${owner}:${generation}:${index}`; 56 237 } 57 238 58 239 function withoutTrailingNewline(value: string): string { ··· 70 251 * keychain and out of command-line arguments. 71 252 */ 72 253 export function createMacOsKeychainStore( 73 - runSecurity: SecurityRunner = runSecurityCommand 254 + runSecurity: SecurityRunner = runSecurityCommand, 255 + writeSecurityPassword: SecurityPasswordWriter = runSecurityPasswordCommand 74 256 ): KeychainStore { 257 + async function readItem(service: string, account: string): Promise<string | null> { 258 + const result = await runSecurity(['find-generic-password', '-a', account, '-s', service, '-w']); 259 + if (result.code === ITEM_NOT_FOUND_STATUS) return null; 260 + if (result.code !== 0) throw commandError('read', result); 261 + return withoutTrailingNewline(result.stdout); 262 + } 263 + 264 + async function writeItem(service: string, account: string, value: string): Promise<void> { 265 + if (value.length > MACOS_MAX_PROMPT_VALUE_LENGTH) { 266 + throw new Error('macOS Keychain write failed: internal chunk is too large'); 267 + } 268 + 269 + const createArgs = [ 270 + 'add-generic-password', 271 + '-a', 272 + account, 273 + '-s', 274 + service, 275 + '-T', 276 + MACOS_SECURITY_PATH, 277 + '-w', 278 + ]; 279 + const created = await writeSecurityPassword(createArgs, value); 280 + if (created.code === 0) return; 281 + 282 + const duplicate = 283 + created.code === DUPLICATE_ITEM_STATUS || created.stderr.includes('already exists'); 284 + if (!duplicate) throw passwordCommandError('write', created); 285 + 286 + // Do not pass -T while updating. Existing items retain their access list, 287 + // including the stable helper after the one-time legacy-item authorization. 288 + const updateArgs = ['add-generic-password', '-U', '-a', account, '-s', service, '-w']; 289 + const updated = await writeSecurityPassword(updateArgs, value); 290 + if (updated.code !== 0) throw passwordCommandError('update', updated); 291 + } 292 + 293 + async function deleteItem(service: string, account: string): Promise<boolean> { 294 + const result = await runSecurity(['delete-generic-password', '-a', account, '-s', service]); 295 + if (result.code === ITEM_NOT_FOUND_STATUS) return false; 296 + if (result.code !== 0) throw commandError('delete', result); 297 + return true; 298 + } 299 + 300 + async function deleteChunks( 301 + service: string, 302 + account: string, 303 + manifest: ChunkManifest | null 304 + ): Promise<void> { 305 + if (!manifest) return; 306 + const serviceName = chunkService(service); 307 + for (let index = 0; index < manifest.count; index += 1) { 308 + await deleteItem(serviceName, chunkAccount(service, account, manifest.generation, index)); 309 + } 310 + } 311 + 75 312 return { 76 313 async setPassword(service, account, password) { 77 - const createArgs = [ 78 - 'add-generic-password', 79 - '-a', 80 - account, 81 - '-s', 82 - service, 83 - '-T', 84 - MACOS_SECURITY_PATH, 85 - '-w', 86 - ]; 87 - const created = await runSecurity(createArgs, passwordInput(password)); 88 - if (created.code === 0) return; 314 + // The interactive `security` prompt hexifies non-ASCII input, cannot 315 + // represent embedded newlines, and truncates long values. Encode the 316 + // credential and split it into short Keychain items. The original item 317 + // contains either a short envelope or a manifest committed after every 318 + // chunk; unprefixed legacy values remain readable for migration. 319 + const previousValue = await readItem(service, account); 320 + const previousManifest = recoverableChunkManifest(previousValue); 321 + const encodedValue = encodeMacOsKeychainValue(password); 322 + 323 + if (encodedValue.length <= MACOS_MAX_PROMPT_VALUE_LENGTH) { 324 + await writeItem(service, account, encodedValue); 325 + await deleteChunks(service, account, previousManifest); 326 + return; 327 + } 328 + 329 + const encoded = encodedValue.slice(MACOS_VALUE_PREFIX.length); 330 + const chunks = encoded.match(new RegExp(`.{1,${MACOS_MAX_PROMPT_VALUE_LENGTH}}`, 'g')) ?? []; 331 + if (chunks.length === 0 || chunks.length > MACOS_MAX_CHUNKS) { 332 + throw new Error('macOS Keychain write failed: credential is too large'); 333 + } 334 + 335 + const generation = randomBytes(8).toString('hex'); 336 + const serviceName = chunkService(service); 337 + const manifest = `${MACOS_CHUNK_MANIFEST_PREFIX}${generation}:${chunks.length}`; 338 + let chunksWritten = 0; 89 339 90 - const duplicate = 91 - created.code === DUPLICATE_ITEM_STATUS || created.stderr.includes('already exists'); 92 - if (!duplicate) throw commandError('write', created); 340 + try { 341 + for (const [index, chunk] of chunks.entries()) { 342 + await writeItem(serviceName, chunkAccount(service, account, generation, index), chunk); 343 + chunksWritten += 1; 344 + } 345 + await writeItem(service, account, manifest); 346 + } catch (error) { 347 + await deleteChunks(service, account, { 348 + generation, 349 + count: chunksWritten, 350 + }).catch(() => undefined); 351 + throw error; 352 + } 93 353 94 - // Do not pass -T while updating. Existing items retain their access list, 95 - // including the stable helper after the one-time legacy-item authorization. 96 - const updateArgs = ['add-generic-password', '-U', '-a', account, '-s', service, '-w']; 97 - const updated = await runSecurity(updateArgs, passwordInput(password)); 98 - if (updated.code !== 0) throw commandError('update', updated); 354 + await deleteChunks(service, account, previousManifest); 99 355 }, 100 356 101 357 async getPassword(service, account) { 102 - const result = await runSecurity([ 103 - 'find-generic-password', 104 - '-a', 105 - account, 106 - '-s', 107 - service, 108 - '-w', 109 - ]); 110 - if (result.code === ITEM_NOT_FOUND_STATUS) return null; 111 - if (result.code !== 0) throw commandError('read', result); 112 - return withoutTrailingNewline(result.stdout); 358 + const storedValue = await readItem(service, account); 359 + if (storedValue === null) return null; 360 + 361 + const manifest = parseChunkManifest(storedValue); 362 + if (!manifest) return decodeMacOsKeychainValue(storedValue); 363 + 364 + const serviceName = chunkService(service); 365 + let encoded = ''; 366 + for (let index = 0; index < manifest.count; index += 1) { 367 + const chunk = await readItem( 368 + serviceName, 369 + chunkAccount(service, account, manifest.generation, index) 370 + ); 371 + if (chunk === null || !/^[A-Za-z0-9_-]+$/.test(chunk)) { 372 + throw new InvalidCredentialDataError(); 373 + } 374 + encoded += chunk; 375 + } 376 + 377 + return decodeBase64Value(encoded); 113 378 }, 114 379 115 380 async deletePassword(service, account) { 116 - const result = await runSecurity(['delete-generic-password', '-a', account, '-s', service]); 117 - if (result.code === ITEM_NOT_FOUND_STATUS) return false; 118 - if (result.code !== 0) throw commandError('delete', result); 119 - return true; 381 + const storedValue = await readItem(service, account); 382 + if (storedValue === null) return false; 383 + const manifest = recoverableChunkManifest(storedValue); 384 + // Delete chunks first so an interrupted cleanup can be retried while the 385 + // manifest still names them. A corrupted manifest falls back to deleting 386 + // the primary item so it can never lock the user out of logout. 387 + await deleteChunks(service, account, manifest); 388 + return await deleteItem(service, account); 120 389 }, 121 390 }; 122 391 }
+20 -9
src/lib/session.ts
··· 3 3 import { join } from 'node:path'; 4 4 import type { AtpSessionData } from '@atproto/api'; 5 5 import type { NodeSavedSession, NodeSavedState } from '@atproto/oauth-client-node'; 6 + import { InvalidCredentialDataError, KeychainAccessError } from './errors.js'; 6 7 import { deleteKeychainSecret, loadKeychainSecret, saveKeychainSecret } from './keychain.js'; 8 + 9 + export { InvalidCredentialDataError, KeychainAccessError } from './errors.js'; 7 10 8 11 const SERVICE_NAME = 'tangled-cli'; 9 12 const OAUTH_SESSION_SERVICE_NAME = 'tangled-cli-oauth-session'; 10 13 const OAUTH_STATE_SERVICE_NAME = 'tangled-cli-oauth-state'; 11 14 const SESSION_METADATA_PATH = join(homedir(), '.config', 'tangled', 'session.json'); 12 - 13 - export class KeychainAccessError extends Error { 14 - constructor(message: string) { 15 - super(message); 16 - this.name = 'KeychainAccessError'; 17 - } 18 - } 19 15 20 16 export interface SessionMetadata { 21 17 handle: string; ··· 33 29 async function loadKeychainJson<T>(service: string, accountId: string): Promise<T | null> { 34 30 const serialized = await loadKeychainSecret(service, accountId); 35 31 if (!serialized) return null; 36 - return JSON.parse(serialized) as T; 32 + try { 33 + return JSON.parse(serialized) as T; 34 + } catch { 35 + // JSON.parse includes the source text in its error on some runtimes. That 36 + // source is a credential and must never flow into a user-visible message. 37 + throw new InvalidCredentialDataError(); 38 + } 37 39 } 38 40 39 41 /** ··· 63 65 try { 64 66 return await loadKeychainJson<AtpSessionData>(SERVICE_NAME, accountId); 65 67 } catch (error) { 68 + if (error instanceof InvalidCredentialDataError) throw error; 66 69 throw new KeychainAccessError( 67 70 `Cannot access keychain: ${error instanceof Error ? error.message : 'Unknown error'}` 68 71 ); ··· 83 86 try { 84 87 return await loadKeychainJson<NodeSavedSession>(OAUTH_SESSION_SERVICE_NAME, sub); 85 88 } catch (error) { 89 + if (error instanceof InvalidCredentialDataError) throw error; 86 90 throw new KeychainAccessError( 87 91 `Cannot access keychain: ${error instanceof Error ? error.message : 'Unknown error'}` 88 92 ); ··· 104 108 } 105 109 106 110 export async function loadOAuthState(key: string): Promise<NodeSavedState | null> { 107 - return await loadKeychainJson<NodeSavedState>(OAUTH_STATE_SERVICE_NAME, key); 111 + try { 112 + return await loadKeychainJson<NodeSavedState>(OAUTH_STATE_SERVICE_NAME, key); 113 + } catch (error) { 114 + if (error instanceof InvalidCredentialDataError) throw error; 115 + throw new KeychainAccessError( 116 + `Cannot access keychain: ${error instanceof Error ? error.message : 'Unknown error'}` 117 + ); 118 + } 108 119 } 109 120 110 121 export async function deleteOAuthState(key: string): Promise<boolean> {
+6 -1
src/utils/auth-helpers.ts
··· 1 1 import { execSync } from 'node:child_process'; 2 2 import type { TangledApiClient } from '../lib/api-client.js'; 3 - import { KeychainAccessError } from '../lib/session.js'; 3 + import { InvalidCredentialDataError, KeychainAccessError } from '../lib/session.js'; 4 4 5 5 /** 6 6 * Validate that the client is authenticated and has an active session ··· 47 47 process.exit(1); 48 48 } 49 49 } catch (error) { 50 + if (error instanceof InvalidCredentialDataError) { 51 + console.error('✗ Stored authentication data is invalid.'); 52 + console.error(' Run "tang auth logout --yes", then "tang auth login".'); 53 + process.exit(1); 54 + } 50 55 if (error instanceof KeychainAccessError) { 51 56 const unlocked = tryUnlockKeychain(); 52 57 if (unlocked) {
+10 -1
tests/lib/api-client.test.ts
··· 2 2 import { beforeEach, describe, expect, it, vi } from 'vitest'; 3 3 import { TangledApiClient } from '../../src/lib/api-client.js'; 4 4 import * as sessionModule from '../../src/lib/session.js'; 5 - import { KeychainAccessError } from '../../src/lib/session.js'; 5 + import { InvalidCredentialDataError, KeychainAccessError } from '../../src/lib/session.js'; 6 6 import { mockSessionData, mockSessionMetadata } from '../helpers/mock-data.js'; 7 7 8 8 // Mock @atproto/api ··· 165 165 ); 166 166 167 167 await expect(client.resumeSession()).rejects.toThrow(KeychainAccessError); 168 + expect(vi.mocked(sessionModule.clearCurrentSessionMetadata)).not.toHaveBeenCalled(); 169 + }); 170 + 171 + it('should rethrow invalid credential data without clearing metadata', async () => { 172 + vi.mocked(sessionModule.getCurrentSessionMetadata).mockRejectedValueOnce( 173 + new InvalidCredentialDataError() 174 + ); 175 + 176 + await expect(client.resumeSession()).rejects.toThrow(InvalidCredentialDataError); 168 177 expect(vi.mocked(sessionModule.clearCurrentSessionMetadata)).not.toHaveBeenCalled(); 169 178 }); 170 179 });
+167 -17
tests/lib/keychain.test.ts
··· 1 1 import { describe, expect, it, vi } from 'vitest'; 2 - import { createMacOsKeychainStore } from '../../src/lib/keychain.js'; 2 + import { InvalidCredentialDataError } from '../../src/lib/errors.js'; 3 + import { 4 + createMacOsKeychainStore, 5 + createMacOsSecurityPasswordInvocation, 6 + } from '../../src/lib/keychain.js'; 3 7 4 8 describe('macOS Keychain store', () => { 5 9 it('creates credentials through the stable security helper without putting secrets in argv', async () => { 6 - const run = vi.fn().mockResolvedValue({ code: 0, stdout: '', stderr: '' }); 7 - const store = createMacOsKeychainStore(run); 10 + const run = vi.fn().mockResolvedValue({ code: 44, stdout: '', stderr: 'not found' }); 11 + const writePassword = vi.fn().mockResolvedValue({ code: 0, stdout: '', stderr: '' }); 12 + const store = createMacOsKeychainStore(run, writePassword); 8 13 9 14 await store.setPassword('tangled-cli-oauth-session', 'did:plc:test', 'secret-value'); 10 15 11 16 expect(run).toHaveBeenCalledOnce(); 12 - const [args, input] = run.mock.calls[0]; 17 + expect(writePassword).toHaveBeenCalledOnce(); 18 + const [args, password] = writePassword.mock.calls[0]; 13 19 expect(args).toEqual([ 14 20 'add-generic-password', 15 21 '-a', ··· 21 27 '-w', 22 28 ]); 23 29 expect(args).not.toContain('secret-value'); 24 - expect(input).toBe('secret-value\nsecret-value\n'); 30 + expect(password).toBe('tangled-cli-keychain-v1:c2VjcmV0LXZhbHVl'); 25 31 }); 26 32 27 - it('updates existing credentials without replacing their stable access list', async () => { 33 + it('isolates password bytes from both process arguments and the Expect program', () => { 34 + const secret = 'safe-ascii-$[]'; 35 + const securityArgs = ['add-generic-password', '-a', 'account', '-s', 'service', '-w']; 36 + 37 + const invocation = createMacOsSecurityPasswordInvocation(securityArgs, secret); 38 + 39 + expect(invocation.executable).toBe('/usr/bin/expect'); 40 + expect(invocation.args).toEqual(['-N', '-n', '-f', '/dev/fd/3', '--', ...securityArgs]); 41 + expect(invocation.args.join(' ')).not.toContain(secret); 42 + expect(invocation.scriptInput).not.toContain(secret); 43 + expect(invocation.passwordInput).toBe(`${Buffer.from(secret, 'utf8').toString('hex')}\n`); 44 + }); 45 + 46 + it('round-trips Unicode and line breaks through an ASCII storage envelope', async () => { 47 + const secret = 'line one\nline two $[] é ✓'; 48 + let storedPassword = ''; 49 + let hasStoredPassword = false; 50 + const writePassword = vi.fn().mockImplementation(async (_args, password) => { 51 + storedPassword = password; 52 + hasStoredPassword = true; 53 + return { code: 0, stdout: '', stderr: '' }; 54 + }); 28 55 const run = vi 29 56 .fn() 57 + .mockImplementation(async () => 58 + hasStoredPassword 59 + ? { code: 0, stdout: `${storedPassword}\n`, stderr: '' } 60 + : { code: 44, stdout: '', stderr: 'not found' } 61 + ); 62 + const store = createMacOsKeychainStore(run, writePassword); 63 + 64 + await store.setPassword('service', 'account', secret); 65 + 66 + expect(storedPassword).toMatch(/^tangled-cli-keychain-v1:[A-Za-z0-9_-]+$/); 67 + expect(storedPassword).not.toContain(secret); 68 + await expect(store.getPassword('service', 'account')).resolves.toBe(secret); 69 + }); 70 + 71 + it('splits long credentials into bounded Keychain items and cleans them up', async () => { 72 + const secret = `${'long Unicode credential é ✓\n'.repeat(80)}done`; 73 + const items = new Map<string, string>(); 74 + const itemKey = (args: string[]) => { 75 + const account = args[args.indexOf('-a') + 1]; 76 + const service = args[args.indexOf('-s') + 1]; 77 + return `${service}\0${account}`; 78 + }; 79 + const run = vi.fn().mockImplementation(async (args: string[]) => { 80 + const key = itemKey(args); 81 + if (args[0] === 'find-generic-password') { 82 + const value = items.get(key); 83 + return value === undefined 84 + ? { code: 44, stdout: '', stderr: 'not found' } 85 + : { code: 0, stdout: `${value}\n`, stderr: '' }; 86 + } 87 + if (args[0] === 'delete-generic-password') { 88 + return items.delete(key) 89 + ? { code: 0, stdout: '', stderr: '' } 90 + : { code: 44, stdout: '', stderr: 'not found' }; 91 + } 92 + throw new Error(`Unexpected security operation: ${args[0]}`); 93 + }); 94 + const writePassword = vi.fn().mockImplementation(async (args: string[], password: string) => { 95 + const key = itemKey(args); 96 + if (items.has(key) && !args.includes('-U')) { 97 + return { code: 45, stdout: '', stderr: 'item already exists' }; 98 + } 99 + items.set(key, password); 100 + return { code: 0, stdout: '', stderr: '' }; 101 + }); 102 + const store = createMacOsKeychainStore(run, writePassword); 103 + 104 + await store.setPassword('service', 'account', secret); 105 + 106 + expect(items.size).toBeGreaterThan(2); 107 + expect(writePassword.mock.calls.every(([, value]) => value.length <= 96)).toBe(true); 108 + expect(writePassword.mock.calls.every(([args]) => !args.join(' ').includes(secret))).toBe(true); 109 + await expect(store.getPassword('service', 'account')).resolves.toBe(secret); 110 + 111 + await store.setPassword('service', 'account', 'replacement'); 112 + 113 + expect(items.size).toBe(1); 114 + await expect(store.getPassword('service', 'account')).resolves.toBe('replacement'); 115 + await expect(store.deletePassword('service', 'account')).resolves.toBe(true); 116 + expect(items.size).toBe(0); 117 + }); 118 + 119 + it('updates existing credentials without replacing their stable access list', async () => { 120 + const run = vi.fn().mockResolvedValue({ code: 44, stdout: '', stderr: 'not found' }); 121 + const writePassword = vi 122 + .fn() 30 123 .mockResolvedValueOnce({ code: 45, stdout: '', stderr: 'item already exists' }) 31 124 .mockResolvedValueOnce({ code: 0, stdout: '', stderr: '' }); 32 - const store = createMacOsKeychainStore(run); 125 + const store = createMacOsKeychainStore(run, writePassword); 33 126 34 127 await store.setPassword('service', 'account', 'updated-secret'); 35 128 36 - expect(run).toHaveBeenCalledTimes(2); 37 - expect(run.mock.calls[1][0]).toEqual([ 129 + expect(writePassword).toHaveBeenCalledTimes(2); 130 + expect(writePassword.mock.calls[1][0]).toEqual([ 38 131 'add-generic-password', 39 132 '-U', 40 133 '-a', ··· 43 136 'service', 44 137 '-w', 45 138 ]); 46 - expect(run.mock.calls[1][0]).not.toContain('-T'); 47 - expect(run.mock.calls[1][1]).toBe('updated-secret\nupdated-secret\n'); 139 + expect(writePassword.mock.calls[1][0]).not.toContain('-T'); 140 + expect(writePassword.mock.calls[1][1]).toBe('tangled-cli-keychain-v1:dXBkYXRlZC1zZWNyZXQ'); 48 141 }); 49 142 50 143 it('reads a credential and removes the security command newline', async () => { 51 144 const run = vi.fn().mockResolvedValue({ code: 0, stdout: 'stored-secret\n', stderr: '' }); 52 - const store = createMacOsKeychainStore(run); 145 + const store = createMacOsKeychainStore(run, vi.fn()); 53 146 54 147 await expect(store.getPassword('service', 'account')).resolves.toBe('stored-secret'); 55 148 expect(run).toHaveBeenCalledWith([ ··· 64 157 65 158 it('returns null when a credential does not exist', async () => { 66 159 const run = vi.fn().mockResolvedValue({ code: 44, stdout: '', stderr: 'not found' }); 67 - const store = createMacOsKeychainStore(run); 160 + const store = createMacOsKeychainStore(run, vi.fn()); 68 161 69 162 await expect(store.getPassword('service', 'account')).resolves.toBeNull(); 70 163 }); 71 164 165 + it('rejects invalid envelopes and missing credential chunks without exposing data', async () => { 166 + const invalidEnvelope = vi.fn().mockResolvedValue({ 167 + code: 0, 168 + stdout: 'tangled-cli-keychain-v1:not%base64\n', 169 + stderr: '', 170 + }); 171 + await expect( 172 + createMacOsKeychainStore(invalidEnvelope, vi.fn()).getPassword('service', 'account') 173 + ).rejects.toThrow(InvalidCredentialDataError); 174 + 175 + const missingChunk = vi 176 + .fn() 177 + .mockResolvedValueOnce({ 178 + code: 0, 179 + stdout: 'tangled-cli-keychain-v2:0123456789abcdef:1\n', 180 + stderr: '', 181 + }) 182 + .mockResolvedValueOnce({ code: 44, stdout: '', stderr: 'not found' }); 183 + await expect( 184 + createMacOsKeychainStore(missingChunk, vi.fn()).getPassword('service', 'account') 185 + ).rejects.toThrow(InvalidCredentialDataError); 186 + }); 187 + 188 + it('can replace and delete a credential with a corrupted chunk manifest', async () => { 189 + const corrupted = 'tangled-cli-keychain-v2:not-a-valid-manifest\n'; 190 + const setRun = vi.fn().mockResolvedValue({ code: 0, stdout: corrupted, stderr: '' }); 191 + const writePassword = vi 192 + .fn() 193 + .mockResolvedValueOnce({ code: 45, stdout: '', stderr: 'item already exists' }) 194 + .mockResolvedValueOnce({ code: 0, stdout: '', stderr: '' }); 195 + const setStore = createMacOsKeychainStore(setRun, writePassword); 196 + 197 + await expect( 198 + setStore.setPassword('service', 'account', 'replacement') 199 + ).resolves.toBeUndefined(); 200 + 201 + const deleteRun = vi 202 + .fn() 203 + .mockResolvedValueOnce({ code: 0, stdout: corrupted, stderr: '' }) 204 + .mockResolvedValueOnce({ code: 0, stdout: '', stderr: '' }); 205 + const deleteStore = createMacOsKeychainStore(deleteRun, vi.fn()); 206 + await expect(deleteStore.deletePassword('service', 'account')).resolves.toBe(true); 207 + }); 208 + 72 209 it('reports whether a credential was deleted', async () => { 73 210 const run = vi 74 211 .fn() 212 + .mockResolvedValueOnce({ code: 0, stdout: 'stored-secret\n', stderr: '' }) 75 213 .mockResolvedValueOnce({ code: 0, stdout: '', stderr: '' }) 76 214 .mockResolvedValueOnce({ code: 44, stdout: '', stderr: 'not found' }); 77 - const store = createMacOsKeychainStore(run); 215 + const store = createMacOsKeychainStore(run, vi.fn()); 78 216 79 217 await expect(store.deletePassword('service', 'account')).resolves.toBe(true); 80 218 await expect(store.deletePassword('service', 'account')).resolves.toBe(false); 81 219 }); 82 220 83 221 it('surfaces Keychain command failures without exposing the secret', async () => { 84 - const run = vi.fn().mockResolvedValue({ code: 1, stdout: '', stderr: 'keychain locked' }); 85 - const store = createMacOsKeychainStore(run); 222 + const run = vi.fn().mockResolvedValue({ code: 44, stdout: '', stderr: 'not found' }); 223 + const writePassword = vi 224 + .fn() 225 + .mockResolvedValue({ code: 1, stdout: 'do-not-print', stderr: 'do-not-print' }); 226 + const store = createMacOsKeychainStore(run, writePassword); 86 227 87 228 await expect(store.setPassword('service', 'account', 'do-not-print')).rejects.toThrow( 88 - 'macOS Keychain write failed: keychain locked' 229 + 'macOS Keychain write failed: exit status 1' 230 + ); 231 + }); 232 + 233 + it('never uses command stdout in a Keychain read error', async () => { 234 + const run = vi.fn().mockResolvedValue({ code: 1, stdout: 'do-not-print', stderr: '' }); 235 + const store = createMacOsKeychainStore(run, vi.fn()); 236 + 237 + await expect(store.getPassword('service', 'account')).rejects.toThrow( 238 + 'macOS Keychain read failed: exit status 1' 89 239 ); 90 240 }); 91 241 });
+35
tests/lib/session.test.ts
··· 4 4 clearCurrentSessionMetadata, 5 5 deleteSession, 6 6 getCurrentSessionMetadata, 7 + InvalidCredentialDataError, 8 + loadOAuthSession, 9 + loadOAuthState, 7 10 loadSession, 8 11 saveCurrentSessionMetadata, 9 12 saveSession, ··· 112 115 it('should return null when session not found', async () => { 113 116 const result = await loadSession('did:plc:notfound'); 114 117 expect(result).toBeNull(); 118 + }); 119 + 120 + it('never includes malformed credential bytes in a parse error', async () => { 121 + const exposedPassword = 'this-must-never-appear-in-an-error'; 122 + mockKeyringStorage.set('tangled-cli-oauth-state:bad-state', exposedPassword); 123 + 124 + let thrown: unknown; 125 + try { 126 + await loadOAuthState('bad-state'); 127 + } catch (error) { 128 + thrown = error; 129 + } 130 + 131 + expect(thrown).toBeInstanceOf(InvalidCredentialDataError); 132 + expect((thrown as Error).message).toBe('Stored authentication data is invalid.'); 133 + expect((thrown as Error).message).not.toContain(exposedPassword); 134 + }); 135 + 136 + it('redacts malformed OAuth session credential bytes', async () => { 137 + const exposedPassword = 'oauth-session-secret-must-not-appear'; 138 + mockKeyringStorage.set('tangled-cli-oauth-session:bad-sub', exposedPassword); 139 + 140 + let thrown: unknown; 141 + try { 142 + await loadOAuthSession('bad-sub'); 143 + } catch (error) { 144 + thrown = error; 145 + } 146 + 147 + expect(thrown).toBeInstanceOf(InvalidCredentialDataError); 148 + expect((thrown as Error).message).toBe('Stored authentication data is invalid.'); 149 + expect((thrown as Error).message).not.toContain(exposedPassword); 115 150 }); 116 151 }); 117 152
+15 -1
tests/utils/auth-helpers.test.ts
··· 1 1 import { execSync } from 'node:child_process'; 2 2 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 3 3 import type { TangledApiClient } from '../../src/lib/api-client.js'; 4 - import { KeychainAccessError } from '../../src/lib/session.js'; 4 + import { InvalidCredentialDataError, KeychainAccessError } from '../../src/lib/session.js'; 5 5 import { ensureAuthenticated, requireAuth } from '../../src/utils/auth-helpers.js'; 6 6 7 7 vi.mock('node:child_process', () => ({ ··· 131 131 await expect(ensureAuthenticated(mockClient)).rejects.toThrow('process.exit called'); 132 132 expect(mockConsoleError).toHaveBeenCalledWith( 133 133 '✗ Cannot access keychain. Please unlock your Mac keychain and try again.' 134 + ); 135 + expect(mockExit).toHaveBeenCalledWith(1); 136 + }); 137 + 138 + it('does not ask for a Keychain password when stored credential data is malformed', async () => { 139 + const mockClient = { 140 + resumeSession: vi.fn().mockRejectedValue(new InvalidCredentialDataError()), 141 + } as unknown as TangledApiClient; 142 + 143 + await expect(ensureAuthenticated(mockClient)).rejects.toThrow('process.exit called'); 144 + expect(execSync).not.toHaveBeenCalled(); 145 + expect(mockConsoleError).toHaveBeenCalledWith('✗ Stored authentication data is invalid.'); 146 + expect(mockConsoleError).toHaveBeenCalledWith( 147 + ' Run "tang auth logout --yes", then "tang auth login".' 134 148 ); 135 149 expect(mockExit).toHaveBeenCalledWith(1); 136 150 });