From 411c0b628c3ccc60189f781016e09435b88f8242 Mon Sep 17 00:00:00 2001 From: Benjamin Jesuiter Date: Fri, 26 Jun 2026 12:51:59 +0200 Subject: [PATCH 01/26] fix: allow deno varlock ipc clients --- .../swift/Sources/VarlockEnclave/PeerIdentity.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/PeerIdentity.swift b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/PeerIdentity.swift index 6ffe090d9..429354bc8 100644 --- a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/PeerIdentity.swift +++ b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/PeerIdentity.swift @@ -30,6 +30,7 @@ private let allowedBinaryNames: Set = [ "varlock", // SEA CLI binary "node", // Node.js (varlock TS client) "bun", // Bun runtime (varlock TS client) + "deno", // Deno runtime (varlock TS client) ] /// Verify that a peer process is an allowed Varlock client. From 514b8392879885f7e538b5672cb2cac9397b22e4 Mon Sep 17 00:00:00 2001 From: Benjamin Jesuiter Date: Fri, 26 Jun 2026 13:07:01 +0200 Subject: [PATCH 02/26] test: add deno compatibility smoke tests --- smoke-tests/package.json | 9 ++- smoke-tests/tests/deno-compat.test.ts | 87 +++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 smoke-tests/tests/deno-compat.test.ts diff --git a/smoke-tests/package.json b/smoke-tests/package.json index 8d489ab09..ae826e923 100644 --- a/smoke-tests/package.json +++ b/smoke-tests/package.json @@ -6,7 +6,8 @@ "description": "Smoke test suite for varlock", "scripts": { "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "test:deno-compat": "vitest run tests/deno-compat.test.ts" }, "devDependencies": { "@types/node": "^22.0.0", @@ -17,7 +18,11 @@ "pnpm": { "overrides": { "@next/env": "link:../packages/integrations/nextjs" - } + }, + "onlyBuiltDependencies": [ + "esbuild", + "sharp" + ] }, "packageManager": "pnpm@10.30.2" } diff --git a/smoke-tests/tests/deno-compat.test.ts b/smoke-tests/tests/deno-compat.test.ts new file mode 100644 index 000000000..6de07193f --- /dev/null +++ b/smoke-tests/tests/deno-compat.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from 'vitest'; +import { spawnSync, execSync } from 'node:child_process'; +import { join } from 'node:path'; +import { VARLOCK_CLI } from '../helpers/run-varlock.js'; + +const SMOKE_TESTS_DIR = join(import.meta.dirname, '..'); + +function hasDeno(): boolean { + try { + execSync('deno --version', { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +function runDenoVarlock(args: Array, options?: { + cwd?: string; + env?: Record; +}) { + const cwd = options?.cwd ? join(SMOKE_TESTS_DIR, options.cwd) : SMOKE_TESTS_DIR; + const env = { ...process.env, ...options?.env }; + const result = spawnSync('deno', ['run', '-A', VARLOCK_CLI, ...args], { + cwd, + env, + encoding: 'utf-8', + }); + + return { + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + exitCode: result.status ?? 1, + output: (result.stdout ?? '') + (result.stderr ?? ''), + }; +} + +const denoAvailable = hasDeno(); + +describe.skipIf(!denoAvailable)('Deno compatibility', () => { + test('runs the CLI help through deno run -A', () => { + const result = runDenoVarlock(['--help']); + + expect(result.exitCode).toBe(0); + expect(result.output).toContain('varlock'); + expect(result.output).toContain('load'); + }); + + test('loads a basic env graph as JSON', () => { + const result = runDenoVarlock(['load', '--format', 'json'], { + cwd: 'smoke-test-basic', + }); + + expect(result.exitCode).toBe(0); + const vars = JSON.parse(result.stdout); + expect(vars.NODE_ENV).toBe('test'); + expect(vars.PUBLIC_VAR).toBe('public-value'); + expect(vars.SECRET_TOKEN).toBe('super-secret-token-12345'); + }); + + test('prints a single env var', () => { + const result = runDenoVarlock(['printenv', 'PUBLIC_VAR'], { + cwd: 'smoke-test-basic', + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout.trim()).toBe('public-value'); + }); + + test('runs a child command with loaded env vars', () => { + const result = runDenoVarlock(['run', '--', 'deno', 'eval', 'console.log(Deno.env.get("PUBLIC_VAR"))'], { + cwd: 'smoke-test-basic', + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout.trim()).toBe('public-value'); + expect(result.output).not.toContain('super-secret-token-12345'); + }); + + test('respects package.json loadPath config', () => { + const result = runDenoVarlock(['printenv', 'PKG_JSON_VAR'], { + cwd: 'smoke-test-pkg-json-config', + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout.trim()).toBe('hello-from-pkg-json-config'); + }); +}); From c01358f32a954a4dd42f9622190734cdf9ec4c3e Mon Sep 17 00:00:00 2001 From: Benjamin Jesuiter Date: Fri, 26 Jun 2026 13:24:37 +0200 Subject: [PATCH 03/26] test: add gated deno smoke tests --- smoke-tests/package.json | 4 +- smoke-tests/tests/deno-compat.test.ts | 10 +- smoke-tests/tests/keychain-deno.test.ts | 121 ++++++++++++++++++++++++ smoke-tests/tests/keychain.test.ts | 93 ++++++++++++++++++ 4 files changed, 225 insertions(+), 3 deletions(-) create mode 100644 smoke-tests/tests/keychain-deno.test.ts create mode 100644 smoke-tests/tests/keychain.test.ts diff --git a/smoke-tests/package.json b/smoke-tests/package.json index ae826e923..d6ff5acb1 100644 --- a/smoke-tests/package.json +++ b/smoke-tests/package.json @@ -7,7 +7,9 @@ "scripts": { "test": "vitest run", "test:watch": "vitest", - "test:deno-compat": "vitest run tests/deno-compat.test.ts" + "test:deno-compat": "VARLOCK_RUN_DENO_COMPAT=1 vitest run tests/deno-compat.test.ts", + "test:keychain": "VARLOCK_RUN_KEYCHAIN_SMOKE=1 vitest run tests/keychain.test.ts tests/keychain-deno.test.ts", + "test:keychain:deno": "VARLOCK_RUN_KEYCHAIN_SMOKE=1 vitest run tests/keychain-deno.test.ts" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/smoke-tests/tests/deno-compat.test.ts b/smoke-tests/tests/deno-compat.test.ts index 6de07193f..1b4380a70 100644 --- a/smoke-tests/tests/deno-compat.test.ts +++ b/smoke-tests/tests/deno-compat.test.ts @@ -1,9 +1,12 @@ -import { describe, expect, test } from 'vitest'; +import { + beforeAll, describe, expect, test, +} from 'vitest'; import { spawnSync, execSync } from 'node:child_process'; import { join } from 'node:path'; import { VARLOCK_CLI } from '../helpers/run-varlock.js'; const SMOKE_TESTS_DIR = join(import.meta.dirname, '..'); +const RUN_DENO_COMPAT = process.env.VARLOCK_RUN_DENO_COMPAT === '1'; function hasDeno(): boolean { try { @@ -36,7 +39,10 @@ function runDenoVarlock(args: Array, options?: { const denoAvailable = hasDeno(); -describe.skipIf(!denoAvailable)('Deno compatibility', () => { +describe.skipIf(!RUN_DENO_COMPAT)('Deno compatibility', () => { + beforeAll(() => { + expect(denoAvailable, 'Deno compatibility tests require the `deno` executable').toBe(true); + }); test('runs the CLI help through deno run -A', () => { const result = runDenoVarlock(['--help']); diff --git a/smoke-tests/tests/keychain-deno.test.ts b/smoke-tests/tests/keychain-deno.test.ts new file mode 100644 index 000000000..a8c89d023 --- /dev/null +++ b/smoke-tests/tests/keychain-deno.test.ts @@ -0,0 +1,121 @@ +import { + afterEach, describe, expect, test, +} from 'vitest'; +import { execSync, spawnSync } from 'node:child_process'; +import { + mkdtempSync, rmSync, writeFileSync, +} from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { VARLOCK_CLI } from '../helpers/run-varlock.js'; + +const RUN_KEYCHAIN_SMOKE = process.env.VARLOCK_RUN_KEYCHAIN_SMOKE === '1'; +const SERVICE = 'varlock-smoke-test-deno'; +const createdAccounts: Array = []; + +function hasDeno(): boolean { + try { + execSync('deno --version', { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +function uniqueId() { + return `${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2)}`; +} + +function security(args: Array) { + return spawnSync('security', args, { encoding: 'utf-8' }); +} + +function runDenoVarlock(args: Array, options?: { + cwd?: string; + env?: Record; +}) { + const env = { ...process.env, ...options?.env }; + const result = spawnSync('deno', ['run', '-A', VARLOCK_CLI, ...args], { + cwd: options?.cwd, + env, + encoding: 'utf-8', + }); + + return { + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + exitCode: result.status ?? 1, + output: (result.stdout ?? '') + (result.stderr ?? ''), + }; +} + +function createKeychainItem(account: string, value: string) { + const result = security([ + 'add-generic-password', + '-U', + '-s', + SERVICE, + '-a', + account, + '-w', + value, + '-T', + '', + ]); + expect(result.status, result.stderr || result.stdout).toBe(0); + createdAccounts.push(account); +} + +function createKeychainFixture(account: string) { + const dir = mkdtempSync(join(tmpdir(), 'varlock-keychain-deno-smoke-')); + writeFileSync(join(dir, '.env.schema'), [ + '# @defaultSensitive=false', + '# ---', + '', + '# @sensitive', + `SECRET_FROM_KEYCHAIN=keychain(service="${SERVICE}", account="${account}")`, + '', + ].join('\n')); + return dir; +} + +afterEach(() => { + for (const account of createdAccounts.splice(0)) { + security(['delete-generic-password', '-s', SERVICE, '-a', account]); + } +}); + +describe.skipIf(!RUN_KEYCHAIN_SMOKE || process.platform !== 'darwin' || !hasDeno())('Deno macOS Keychain smoke tests', () => { + test('lists, fixes access for, and resolves a keychain item through deno run -A', () => { + const account = `deno:${uniqueId()}:SECRET_FROM_KEYCHAIN`; + const secret = `keychain-deno-smoke-secret-${uniqueId()}`; + createKeychainItem(account, secret); + const fixtureDir = createKeychainFixture(account); + + try { + const listResult = runDenoVarlock(['keychain', 'list', account]); + expect(listResult.exitCode, listResult.output).toBe(0); + expect(listResult.output).toContain(SERVICE); + expect(listResult.output).toContain(account); + + const fixAccessResult = runDenoVarlock([ + 'keychain', + 'fix-access', + '--service', + SERVICE, + '--account', + account, + ]); + expect(fixAccessResult.exitCode, fixAccessResult.output).toBe(0); + + const loadResult = runDenoVarlock(['load', '--format', 'json', '--skip-cache'], { + cwd: fixtureDir, + }); + expect(loadResult.exitCode, loadResult.output).toBe(0); + const vars = JSON.parse(loadResult.stdout); + expect(vars.SECRET_FROM_KEYCHAIN).toBe(secret); + } finally { + rmSync(fixtureDir, { recursive: true, force: true }); + } + }); +}); diff --git a/smoke-tests/tests/keychain.test.ts b/smoke-tests/tests/keychain.test.ts new file mode 100644 index 000000000..d93359e27 --- /dev/null +++ b/smoke-tests/tests/keychain.test.ts @@ -0,0 +1,93 @@ +import { + afterEach, describe, expect, test, +} from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { + mkdtempSync, rmSync, writeFileSync, +} from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { runVarlock } from '../helpers/run-varlock.js'; + +const RUN_KEYCHAIN_SMOKE = process.env.VARLOCK_RUN_KEYCHAIN_SMOKE === '1'; +const SERVICE = 'varlock-smoke-test'; +const createdAccounts: Array = []; + +function uniqueId() { + return `${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2)}`; +} + +function security(args: Array) { + return spawnSync('security', args, { encoding: 'utf-8' }); +} + +function createKeychainItem(account: string, value: string) { + const result = security([ + 'add-generic-password', + '-U', + '-s', + SERVICE, + '-a', + account, + '-w', + value, + '-T', + '', + ]); + expect(result.status, result.stderr || result.stdout).toBe(0); + createdAccounts.push(account); +} + +function createKeychainFixture(account: string) { + const dir = mkdtempSync(join(tmpdir(), 'varlock-keychain-smoke-')); + writeFileSync(join(dir, '.env.schema'), [ + '# @defaultSensitive=false', + '# ---', + '', + '# @sensitive', + `SECRET_FROM_KEYCHAIN=keychain(service="${SERVICE}", account="${account}")`, + '', + ].join('\n')); + return dir; +} + +afterEach(() => { + for (const account of createdAccounts.splice(0)) { + security(['delete-generic-password', '-s', SERVICE, '-a', account]); + } +}); + +describe.skipIf(!RUN_KEYCHAIN_SMOKE || process.platform !== 'darwin')('macOS Keychain smoke tests', () => { + test('lists, fixes access for, and resolves a keychain item', () => { + const account = `node:${uniqueId()}:SECRET_FROM_KEYCHAIN`; + const secret = `keychain-smoke-secret-${uniqueId()}`; + createKeychainItem(account, secret); + const fixtureDir = createKeychainFixture(account); + + try { + const listResult = runVarlock(['keychain', 'list', account]); + expect(listResult.exitCode, listResult.output).toBe(0); + expect(listResult.output).toContain(SERVICE); + expect(listResult.output).toContain(account); + + const fixAccessResult = runVarlock([ + 'keychain', + 'fix-access', + '--service', + SERVICE, + '--account', + account, + ]); + expect(fixAccessResult.exitCode, fixAccessResult.output).toBe(0); + + const loadResult = runVarlock(['load', '--format', 'json', '--skip-cache'], { + cwd: fixtureDir, + }); + expect(loadResult.exitCode, loadResult.output).toBe(0); + const vars = JSON.parse(loadResult.stdout); + expect(vars.SECRET_FROM_KEYCHAIN).toBe(secret); + } finally { + rmSync(fixtureDir, { recursive: true, force: true }); + } + }); +}); From bca2b3367ce6f20154398bb22faf3716fe12279c Mon Sep 17 00:00:00 2001 From: Benjamin Jesuiter Date: Fri, 26 Jun 2026 13:27:47 +0200 Subject: [PATCH 04/26] test: split deno keychain smoke script --- smoke-tests/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/smoke-tests/package.json b/smoke-tests/package.json index d6ff5acb1..f048cf5c8 100644 --- a/smoke-tests/package.json +++ b/smoke-tests/package.json @@ -8,7 +8,7 @@ "test": "vitest run", "test:watch": "vitest", "test:deno-compat": "VARLOCK_RUN_DENO_COMPAT=1 vitest run tests/deno-compat.test.ts", - "test:keychain": "VARLOCK_RUN_KEYCHAIN_SMOKE=1 vitest run tests/keychain.test.ts tests/keychain-deno.test.ts", + "test:keychain": "VARLOCK_RUN_KEYCHAIN_SMOKE=1 vitest run tests/keychain.test.ts", "test:keychain:deno": "VARLOCK_RUN_KEYCHAIN_SMOKE=1 vitest run tests/keychain-deno.test.ts" }, "devDependencies": { From 2db3a1f4876e6a8d4db6554b63c4866e194e75b4 Mon Sep 17 00:00:00 2001 From: Benjamin Jesuiter Date: Fri, 26 Jun 2026 13:48:06 +0200 Subject: [PATCH 05/26] test: add gated keychain smoke test --- smoke-tests/package.json | 3 +- smoke-tests/smoke-test-keychain/.gitignore | 1 + smoke-tests/tests/keychain-deno.test.ts | 121 --------------------- smoke-tests/tests/keychain.test.ts | 70 ++++++------ 4 files changed, 41 insertions(+), 154 deletions(-) create mode 100644 smoke-tests/smoke-test-keychain/.gitignore delete mode 100644 smoke-tests/tests/keychain-deno.test.ts diff --git a/smoke-tests/package.json b/smoke-tests/package.json index f048cf5c8..a4512f09a 100644 --- a/smoke-tests/package.json +++ b/smoke-tests/package.json @@ -8,8 +8,7 @@ "test": "vitest run", "test:watch": "vitest", "test:deno-compat": "VARLOCK_RUN_DENO_COMPAT=1 vitest run tests/deno-compat.test.ts", - "test:keychain": "VARLOCK_RUN_KEYCHAIN_SMOKE=1 vitest run tests/keychain.test.ts", - "test:keychain:deno": "VARLOCK_RUN_KEYCHAIN_SMOKE=1 vitest run tests/keychain-deno.test.ts" + "test:keychain": "VARLOCK_RUN_KEYCHAIN_SMOKE=1 vitest run tests/keychain.test.ts" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/smoke-tests/smoke-test-keychain/.gitignore b/smoke-tests/smoke-test-keychain/.gitignore new file mode 100644 index 000000000..41683502d --- /dev/null +++ b/smoke-tests/smoke-test-keychain/.gitignore @@ -0,0 +1 @@ +.env.schema diff --git a/smoke-tests/tests/keychain-deno.test.ts b/smoke-tests/tests/keychain-deno.test.ts deleted file mode 100644 index a8c89d023..000000000 --- a/smoke-tests/tests/keychain-deno.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { - afterEach, describe, expect, test, -} from 'vitest'; -import { execSync, spawnSync } from 'node:child_process'; -import { - mkdtempSync, rmSync, writeFileSync, -} from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { VARLOCK_CLI } from '../helpers/run-varlock.js'; - -const RUN_KEYCHAIN_SMOKE = process.env.VARLOCK_RUN_KEYCHAIN_SMOKE === '1'; -const SERVICE = 'varlock-smoke-test-deno'; -const createdAccounts: Array = []; - -function hasDeno(): boolean { - try { - execSync('deno --version', { stdio: 'ignore' }); - return true; - } catch { - return false; - } -} - -function uniqueId() { - return `${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2)}`; -} - -function security(args: Array) { - return spawnSync('security', args, { encoding: 'utf-8' }); -} - -function runDenoVarlock(args: Array, options?: { - cwd?: string; - env?: Record; -}) { - const env = { ...process.env, ...options?.env }; - const result = spawnSync('deno', ['run', '-A', VARLOCK_CLI, ...args], { - cwd: options?.cwd, - env, - encoding: 'utf-8', - }); - - return { - stdout: result.stdout ?? '', - stderr: result.stderr ?? '', - exitCode: result.status ?? 1, - output: (result.stdout ?? '') + (result.stderr ?? ''), - }; -} - -function createKeychainItem(account: string, value: string) { - const result = security([ - 'add-generic-password', - '-U', - '-s', - SERVICE, - '-a', - account, - '-w', - value, - '-T', - '', - ]); - expect(result.status, result.stderr || result.stdout).toBe(0); - createdAccounts.push(account); -} - -function createKeychainFixture(account: string) { - const dir = mkdtempSync(join(tmpdir(), 'varlock-keychain-deno-smoke-')); - writeFileSync(join(dir, '.env.schema'), [ - '# @defaultSensitive=false', - '# ---', - '', - '# @sensitive', - `SECRET_FROM_KEYCHAIN=keychain(service="${SERVICE}", account="${account}")`, - '', - ].join('\n')); - return dir; -} - -afterEach(() => { - for (const account of createdAccounts.splice(0)) { - security(['delete-generic-password', '-s', SERVICE, '-a', account]); - } -}); - -describe.skipIf(!RUN_KEYCHAIN_SMOKE || process.platform !== 'darwin' || !hasDeno())('Deno macOS Keychain smoke tests', () => { - test('lists, fixes access for, and resolves a keychain item through deno run -A', () => { - const account = `deno:${uniqueId()}:SECRET_FROM_KEYCHAIN`; - const secret = `keychain-deno-smoke-secret-${uniqueId()}`; - createKeychainItem(account, secret); - const fixtureDir = createKeychainFixture(account); - - try { - const listResult = runDenoVarlock(['keychain', 'list', account]); - expect(listResult.exitCode, listResult.output).toBe(0); - expect(listResult.output).toContain(SERVICE); - expect(listResult.output).toContain(account); - - const fixAccessResult = runDenoVarlock([ - 'keychain', - 'fix-access', - '--service', - SERVICE, - '--account', - account, - ]); - expect(fixAccessResult.exitCode, fixAccessResult.output).toBe(0); - - const loadResult = runDenoVarlock(['load', '--format', 'json', '--skip-cache'], { - cwd: fixtureDir, - }); - expect(loadResult.exitCode, loadResult.output).toBe(0); - const vars = JSON.parse(loadResult.stdout); - expect(vars.SECRET_FROM_KEYCHAIN).toBe(secret); - } finally { - rmSync(fixtureDir, { recursive: true, force: true }); - } - }); -}); diff --git a/smoke-tests/tests/keychain.test.ts b/smoke-tests/tests/keychain.test.ts index d93359e27..3a545d6b2 100644 --- a/smoke-tests/tests/keychain.test.ts +++ b/smoke-tests/tests/keychain.test.ts @@ -2,15 +2,14 @@ import { afterEach, describe, expect, test, } from 'vitest'; import { spawnSync } from 'node:child_process'; -import { - mkdtempSync, rmSync, writeFileSync, -} from 'node:fs'; +import { writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import { tmpdir } from 'node:os'; import { runVarlock } from '../helpers/run-varlock.js'; const RUN_KEYCHAIN_SMOKE = process.env.VARLOCK_RUN_KEYCHAIN_SMOKE === '1'; const SERVICE = 'varlock-smoke-test'; +const FIXTURE_DIR = 'smoke-test-keychain'; +const FIXTURE_SCHEMA_PATH = join(import.meta.dirname, '..', FIXTURE_DIR, '.env.schema'); const createdAccounts: Array = []; function uniqueId() { @@ -38,9 +37,8 @@ function createKeychainItem(account: string, value: string) { createdAccounts.push(account); } -function createKeychainFixture(account: string) { - const dir = mkdtempSync(join(tmpdir(), 'varlock-keychain-smoke-')); - writeFileSync(join(dir, '.env.schema'), [ +function writeKeychainFixture(account: string) { + writeFileSync(FIXTURE_SCHEMA_PATH, [ '# @defaultSensitive=false', '# ---', '', @@ -48,46 +46,56 @@ function createKeychainFixture(account: string) { `SECRET_FROM_KEYCHAIN=keychain(service="${SERVICE}", account="${account}")`, '', ].join('\n')); - return dir; +} + +function clearKeychainFixture() { + writeFileSync(FIXTURE_SCHEMA_PATH, ''); } afterEach(() => { for (const account of createdAccounts.splice(0)) { security(['delete-generic-password', '-s', SERVICE, '-a', account]); } + clearKeychainFixture(); }); describe.skipIf(!RUN_KEYCHAIN_SMOKE || process.platform !== 'darwin')('macOS Keychain smoke tests', () => { - test('lists, fixes access for, and resolves a keychain item', () => { + test('lists, resolves when already allowed, or fixes access before resolving a keychain item', () => { const account = `node:${uniqueId()}:SECRET_FROM_KEYCHAIN`; const secret = `keychain-smoke-secret-${uniqueId()}`; createKeychainItem(account, secret); - const fixtureDir = createKeychainFixture(account); + writeKeychainFixture(account); - try { - const listResult = runVarlock(['keychain', 'list', account]); - expect(listResult.exitCode, listResult.output).toBe(0); - expect(listResult.output).toContain(SERVICE); - expect(listResult.output).toContain(account); + const listResult = runVarlock(['keychain', 'list', account]); + expect(listResult.exitCode, listResult.output).toBe(0); + expect(listResult.output).toContain(SERVICE); + expect(listResult.output).toContain(account); - const fixAccessResult = runVarlock([ - 'keychain', - 'fix-access', - '--service', - SERVICE, - '--account', - account, - ]); - expect(fixAccessResult.exitCode, fixAccessResult.output).toBe(0); + const initialLoadResult = runVarlock(['load', '--format', 'json', '--skip-cache'], { + cwd: FIXTURE_DIR, + }); - const loadResult = runVarlock(['load', '--format', 'json', '--skip-cache'], { - cwd: fixtureDir, - }); - expect(loadResult.exitCode, loadResult.output).toBe(0); - const vars = JSON.parse(loadResult.stdout); + if (initialLoadResult.exitCode === 0) { + const vars = JSON.parse(initialLoadResult.stdout); expect(vars.SECRET_FROM_KEYCHAIN).toBe(secret); - } finally { - rmSync(fixtureDir, { recursive: true, force: true }); + return; } + + const fixAccessResult = runVarlock([ + 'keychain', + 'fix-access', + '--service', + SERVICE, + '--account', + account, + ]); + expect(fixAccessResult.exitCode, fixAccessResult.output).toBe(0); + + const loadAfterFixResult = runVarlock(['load', '--format', 'json', '--skip-cache'], { + cwd: FIXTURE_DIR, + }); + expect(loadAfterFixResult.exitCode, loadAfterFixResult.output).toBe(0); + const vars = JSON.parse(loadAfterFixResult.stdout); + expect(vars.SECRET_FROM_KEYCHAIN).toBe(secret); }); }); From 6bbe6aa94026272313800285ae8b25234aef3641 Mon Sep 17 00:00:00 2001 From: Benjamin Jesuiter Date: Fri, 26 Jun 2026 15:48:05 +0200 Subject: [PATCH 06/26] test: add keychain set smoke test --- smoke-tests/package.json | 2 +- .../smoke-test-keychain-fix/.gitignore | 3 + .../smoke-test-keychain-import/.gitignore | 3 + .../smoke-test-keychain-set/.env.schema | 5 ++ smoke-tests/smoke-test-keychain/.gitignore | 1 - ...{keychain.test.ts => keychain-fix.test.ts} | 2 +- smoke-tests/tests/keychain-import.test.ts | 5 ++ smoke-tests/tests/keychain-set.test.ts | 88 +++++++++++++++++++ 8 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 smoke-tests/smoke-test-keychain-fix/.gitignore create mode 100644 smoke-tests/smoke-test-keychain-import/.gitignore create mode 100644 smoke-tests/smoke-test-keychain-set/.env.schema delete mode 100644 smoke-tests/smoke-test-keychain/.gitignore rename smoke-tests/tests/{keychain.test.ts => keychain-fix.test.ts} (98%) create mode 100644 smoke-tests/tests/keychain-import.test.ts create mode 100644 smoke-tests/tests/keychain-set.test.ts diff --git a/smoke-tests/package.json b/smoke-tests/package.json index a4512f09a..5efeca898 100644 --- a/smoke-tests/package.json +++ b/smoke-tests/package.json @@ -8,7 +8,7 @@ "test": "vitest run", "test:watch": "vitest", "test:deno-compat": "VARLOCK_RUN_DENO_COMPAT=1 vitest run tests/deno-compat.test.ts", - "test:keychain": "VARLOCK_RUN_KEYCHAIN_SMOKE=1 vitest run tests/keychain.test.ts" + "test:keychain": "VARLOCK_RUN_KEYCHAIN_SMOKE=1 vitest run tests/keychain-fix.test.ts tests/keychain-set.test.ts tests/keychain-import.test.ts" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/smoke-tests/smoke-test-keychain-fix/.gitignore b/smoke-tests/smoke-test-keychain-fix/.gitignore new file mode 100644 index 000000000..9ec174bc4 --- /dev/null +++ b/smoke-tests/smoke-test-keychain-fix/.gitignore @@ -0,0 +1,3 @@ +.env.schema +.env* +!.gitignore diff --git a/smoke-tests/smoke-test-keychain-import/.gitignore b/smoke-tests/smoke-test-keychain-import/.gitignore new file mode 100644 index 000000000..9ec174bc4 --- /dev/null +++ b/smoke-tests/smoke-test-keychain-import/.gitignore @@ -0,0 +1,3 @@ +.env.schema +.env* +!.gitignore diff --git a/smoke-tests/smoke-test-keychain-set/.env.schema b/smoke-tests/smoke-test-keychain-set/.env.schema new file mode 100644 index 000000000..8cab47984 --- /dev/null +++ b/smoke-tests/smoke-test-keychain-set/.env.schema @@ -0,0 +1,5 @@ +# @defaultSensitive=false +# --- + +# @sensitive +SECRET_FROM_KEYCHAIN=keychain(service="varlock-smoke-test-set", account="set:SECRET_FROM_KEYCHAIN") diff --git a/smoke-tests/smoke-test-keychain/.gitignore b/smoke-tests/smoke-test-keychain/.gitignore deleted file mode 100644 index 41683502d..000000000 --- a/smoke-tests/smoke-test-keychain/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.env.schema diff --git a/smoke-tests/tests/keychain.test.ts b/smoke-tests/tests/keychain-fix.test.ts similarity index 98% rename from smoke-tests/tests/keychain.test.ts rename to smoke-tests/tests/keychain-fix.test.ts index 3a545d6b2..97730954c 100644 --- a/smoke-tests/tests/keychain.test.ts +++ b/smoke-tests/tests/keychain-fix.test.ts @@ -8,7 +8,7 @@ import { runVarlock } from '../helpers/run-varlock.js'; const RUN_KEYCHAIN_SMOKE = process.env.VARLOCK_RUN_KEYCHAIN_SMOKE === '1'; const SERVICE = 'varlock-smoke-test'; -const FIXTURE_DIR = 'smoke-test-keychain'; +const FIXTURE_DIR = 'smoke-test-keychain-fix'; const FIXTURE_SCHEMA_PATH = join(import.meta.dirname, '..', FIXTURE_DIR, '.env.schema'); const createdAccounts: Array = []; diff --git a/smoke-tests/tests/keychain-import.test.ts b/smoke-tests/tests/keychain-import.test.ts new file mode 100644 index 000000000..66bc3b771 --- /dev/null +++ b/smoke-tests/tests/keychain-import.test.ts @@ -0,0 +1,5 @@ +import { describe, test } from 'vitest'; + +describe.skip('macOS Keychain import smoke tests', () => { + test('placeholder for varlock keychain import flow', () => {}); +}); diff --git a/smoke-tests/tests/keychain-set.test.ts b/smoke-tests/tests/keychain-set.test.ts new file mode 100644 index 000000000..7c9ae34e8 --- /dev/null +++ b/smoke-tests/tests/keychain-set.test.ts @@ -0,0 +1,88 @@ +import { + afterEach, describe, expect, test, +} from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { join } from 'node:path'; +import { VARLOCK_CLI } from '../helpers/run-varlock.js'; + +const RUN_KEYCHAIN_SMOKE = process.env.VARLOCK_RUN_KEYCHAIN_SMOKE === '1'; +const SERVICE = 'varlock-smoke-test-set'; +const FIXTURE_DIR = 'smoke-test-keychain-set'; +const SMOKE_TESTS_DIR = join(import.meta.dirname, '..'); +const FIXTURE_PATH = join(SMOKE_TESTS_DIR, FIXTURE_DIR); +const ACCOUNT = 'set:SECRET_FROM_KEYCHAIN'; +const createdAccounts: Array = []; + +function uniqueId() { + return `${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2)}`; +} + +function security(args: Array) { + return spawnSync('security', args, { encoding: 'utf-8' }); +} + +function runVarlockWithInput(args: Array, input: string) { + const result = spawnSync(process.execPath, [VARLOCK_CLI, ...args], { + cwd: FIXTURE_PATH, + env: { ...process.env }, + input, + encoding: 'utf-8', + }); + + return { + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + exitCode: result.status ?? 1, + output: (result.stdout ?? '') + (result.stderr ?? ''), + }; +} + +function runVarlock(args: Array) { + return runVarlockWithInput(args, ''); +} + +afterEach(() => { + for (const account of createdAccounts.splice(0)) { + security(['delete-generic-password', '-s', SERVICE, '-a', account]); + } +}); + +describe.skipIf(!RUN_KEYCHAIN_SMOKE || process.platform !== 'darwin')('macOS Keychain set smoke tests', () => { + test('stores stdin secret and reads it through the persistent keychain ref', () => { + const account = ACCOUNT; + const secret = `keychain-set-smoke-secret-${uniqueId()}`; + createdAccounts.push(account); + + const beforeSetResult = runVarlock(['printenv', 'SECRET_FROM_KEYCHAIN', '--skip-cache']); + expect(beforeSetResult.exitCode, beforeSetResult.output).not.toBe(0); + + const setResult = runVarlockWithInput([ + 'keychain', + 'set', + 'SECRET_FROM_KEYCHAIN', + '--service', + SERVICE, + '--account', + account, + '--force', + ], `${secret}\n`); + expect(setResult.exitCode, setResult.output).toBe(0); + expect(setResult.output).toContain('Stored SECRET_FROM_KEYCHAIN in macOS Keychain.'); + expect(setResult.output).not.toContain(secret); + + const listResult = runVarlock(['keychain', 'list', account]); + expect(listResult.exitCode, listResult.output).toBe(0); + expect(listResult.output).toContain(SERVICE); + expect(listResult.output).toContain(account); + expect(listResult.output).not.toContain(secret); + + const printenvResult = runVarlock(['printenv', 'SECRET_FROM_KEYCHAIN', '--skip-cache']); + expect(printenvResult.exitCode, printenvResult.output).toBe(0); + expect(printenvResult.stdout.trim()).toBe(secret); + + const loadResult = runVarlock(['load', '--format', 'json', '--skip-cache']); + expect(loadResult.exitCode, loadResult.output).toBe(0); + const vars = JSON.parse(loadResult.stdout); + expect(vars.SECRET_FROM_KEYCHAIN).toBe(secret); + }); +}); From 08d591551ed0823a5f00d17551f838d14ed16590 Mon Sep 17 00:00:00 2001 From: Benjamin Jesuiter Date: Fri, 26 Jun 2026 16:38:55 +0200 Subject: [PATCH 07/26] test: add keychain import smoke test --- smoke-tests/smoke-test-keychain-import/.env | 2 + .../smoke-test-keychain-import/.env.schema | 8 ++ .../smoke-test-keychain-import/.gitignore | 3 - smoke-tests/tests/keychain-import.test.ts | 94 ++++++++++++++++++- 4 files changed, 101 insertions(+), 6 deletions(-) create mode 100644 smoke-tests/smoke-test-keychain-import/.env create mode 100644 smoke-tests/smoke-test-keychain-import/.env.schema delete mode 100644 smoke-tests/smoke-test-keychain-import/.gitignore diff --git a/smoke-tests/smoke-test-keychain-import/.env b/smoke-tests/smoke-test-keychain-import/.env new file mode 100644 index 000000000..b121afa29 --- /dev/null +++ b/smoke-tests/smoke-test-keychain-import/.env @@ -0,0 +1,2 @@ +OLD_API_KEY=old-api-key-from-dotenv +OLD_DATABASE_URL=postgres://old-user:old-pass@localhost:5432/old-db diff --git a/smoke-tests/smoke-test-keychain-import/.env.schema b/smoke-tests/smoke-test-keychain-import/.env.schema new file mode 100644 index 000000000..3528669eb --- /dev/null +++ b/smoke-tests/smoke-test-keychain-import/.env.schema @@ -0,0 +1,8 @@ +# @defaultSensitive=false +# --- + +# @sensitive +OLD_API_KEY=keychain(service="varlock-smoke-test-import", account="smoke-test-keychain-import:local:OLD_API_KEY") + +# @sensitive +OLD_DATABASE_URL=keychain(service="varlock-smoke-test-import", account="smoke-test-keychain-import:local:OLD_DATABASE_URL") diff --git a/smoke-tests/smoke-test-keychain-import/.gitignore b/smoke-tests/smoke-test-keychain-import/.gitignore deleted file mode 100644 index 9ec174bc4..000000000 --- a/smoke-tests/smoke-test-keychain-import/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -.env.schema -.env* -!.gitignore diff --git a/smoke-tests/tests/keychain-import.test.ts b/smoke-tests/tests/keychain-import.test.ts index 66bc3b771..5446afab6 100644 --- a/smoke-tests/tests/keychain-import.test.ts +++ b/smoke-tests/tests/keychain-import.test.ts @@ -1,5 +1,93 @@ -import { describe, test } from 'vitest'; +import { + afterEach, beforeEach, describe, expect, test, +} from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { + existsSync, readFileSync, unlinkSync, writeFileSync, +} from 'node:fs'; +import { join } from 'node:path'; +import { runVarlock } from '../helpers/run-varlock.js'; -describe.skip('macOS Keychain import smoke tests', () => { - test('placeholder for varlock keychain import flow', () => {}); +const RUN_KEYCHAIN_SMOKE = process.env.VARLOCK_RUN_KEYCHAIN_SMOKE === '1'; +const SERVICE = 'varlock-smoke-test-import'; +const PROFILE = 'local'; +const PROJECT = 'smoke-test-keychain-import'; +const FIXTURE_DIR = PROJECT; +const SMOKE_TESTS_DIR = join(import.meta.dirname, '..'); +const FIXTURE_PATH = join(SMOKE_TESTS_DIR, FIXTURE_DIR); +const ENV_PATH = join(FIXTURE_PATH, '.env'); +const ORIGINAL_ENV = [ + 'OLD_API_KEY=old-api-key-from-dotenv', + 'OLD_DATABASE_URL=postgres://old-user:old-pass@localhost:5432/old-db', + '', +].join('\n'); +const IMPORTED_SECRETS = { + OLD_API_KEY: 'old-api-key-from-dotenv', + OLD_DATABASE_URL: 'postgres://old-user:old-pass@localhost:5432/old-db', +}; +const IMPORTED_ACCOUNTS = Object.keys(IMPORTED_SECRETS).map((key) => `${PROJECT}:${PROFILE}:${key}`); + +function security(args: Array) { + return spawnSync('security', args, { encoding: 'utf-8' }); +} + +function deleteImportedSecrets() { + for (const account of IMPORTED_ACCOUNTS) { + security(['delete-generic-password', '-s', SERVICE, '-a', account]); + } +} + +beforeEach(() => { + deleteImportedSecrets(); + writeFileSync(ENV_PATH, ORIGINAL_ENV); +}); + +afterEach(() => { + writeFileSync(ENV_PATH, ORIGINAL_ENV); + deleteImportedSecrets(); +}); + +describe.skipIf(!RUN_KEYCHAIN_SMOKE || process.platform !== 'darwin')('macOS Keychain import smoke tests', () => { + test('imports sensitive plaintext env values and reads them back from keychain refs', () => { + const importResult = runVarlock([ + 'keychain', + 'import', + '.env', + '--service', + SERVICE, + '--profile', + PROFILE, + '--project', + PROJECT, + ], { cwd: FIXTURE_DIR }); + expect(importResult.exitCode, importResult.output).toBe(0); + expect(importResult.output).toContain('Imported OLD_API_KEY'); + expect(importResult.output).toContain('Imported OLD_DATABASE_URL'); + expect(importResult.output).toContain('Imported 2 sensitive values into macOS Keychain.'); + expect(importResult.output).not.toContain(IMPORTED_SECRETS.OLD_API_KEY); + expect(importResult.output).not.toContain(IMPORTED_SECRETS.OLD_DATABASE_URL); + + const importedEnv = readFileSync(ENV_PATH, 'utf-8'); + expect(importedEnv).toContain(`OLD_API_KEY=keychain(service="${SERVICE}", account="${PROJECT}:${PROFILE}:OLD_API_KEY")`); + expect(importedEnv).toContain(`OLD_DATABASE_URL=keychain(service="${SERVICE}", account="${PROJECT}:${PROFILE}:OLD_DATABASE_URL")`); + expect(importedEnv).not.toContain(IMPORTED_SECRETS.OLD_API_KEY); + expect(importedEnv).not.toContain(IMPORTED_SECRETS.OLD_DATABASE_URL); + + unlinkSync(ENV_PATH); + expect(existsSync(ENV_PATH)).toBe(false); + + const apiKeyResult = runVarlock(['printenv', 'OLD_API_KEY', '--skip-cache'], { cwd: FIXTURE_DIR }); + expect(apiKeyResult.exitCode, apiKeyResult.output).toBe(0); + expect(apiKeyResult.stdout.trim()).toBe(IMPORTED_SECRETS.OLD_API_KEY); + + const databaseUrlResult = runVarlock(['printenv', 'OLD_DATABASE_URL', '--skip-cache'], { cwd: FIXTURE_DIR }); + expect(databaseUrlResult.exitCode, databaseUrlResult.output).toBe(0); + expect(databaseUrlResult.stdout.trim()).toBe(IMPORTED_SECRETS.OLD_DATABASE_URL); + + const loadResult = runVarlock(['load', '--format', 'json', '--skip-cache'], { cwd: FIXTURE_DIR }); + expect(loadResult.exitCode, loadResult.output).toBe(0); + const vars = JSON.parse(loadResult.stdout); + expect(vars.OLD_API_KEY).toBe(IMPORTED_SECRETS.OLD_API_KEY); + expect(vars.OLD_DATABASE_URL).toBe(IMPORTED_SECRETS.OLD_DATABASE_URL); + }); }); From 295e45522e3e2f8388e80cc71a88c81c857ee94f Mon Sep 17 00:00:00 2001 From: Benjamin Jesuiter Date: Fri, 26 Jun 2026 16:49:59 +0200 Subject: [PATCH 08/26] test: add keychain fix-access smoke test --- .../smoke-test-keychain-fix/.env.schema | 8 ++ .../smoke-test-keychain-fix/.gitignore | 3 - smoke-tests/tests/keychain-fix.test.ts | 97 ++++++++----------- 3 files changed, 47 insertions(+), 61 deletions(-) create mode 100644 smoke-tests/smoke-test-keychain-fix/.env.schema delete mode 100644 smoke-tests/smoke-test-keychain-fix/.gitignore diff --git a/smoke-tests/smoke-test-keychain-fix/.env.schema b/smoke-tests/smoke-test-keychain-fix/.env.schema new file mode 100644 index 000000000..92dfa86dd --- /dev/null +++ b/smoke-tests/smoke-test-keychain-fix/.env.schema @@ -0,0 +1,8 @@ +# @defaultSensitive=false +# --- + +# @sensitive +FIXED_API_KEY=keychain(service="varlock-smoke-test-fix", account="smoke-test-keychain-fix:local:FIXED_API_KEY") + +# @sensitive +FIXED_DATABASE_URL=keychain(service="varlock-smoke-test-fix", account="smoke-test-keychain-fix:local:FIXED_DATABASE_URL") diff --git a/smoke-tests/smoke-test-keychain-fix/.gitignore b/smoke-tests/smoke-test-keychain-fix/.gitignore deleted file mode 100644 index 9ec174bc4..000000000 --- a/smoke-tests/smoke-test-keychain-fix/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -.env.schema -.env* -!.gitignore diff --git a/smoke-tests/tests/keychain-fix.test.ts b/smoke-tests/tests/keychain-fix.test.ts index 97730954c..a6a6c44f9 100644 --- a/smoke-tests/tests/keychain-fix.test.ts +++ b/smoke-tests/tests/keychain-fix.test.ts @@ -1,25 +1,31 @@ import { - afterEach, describe, expect, test, + afterEach, beforeEach, describe, expect, test, } from 'vitest'; import { spawnSync } from 'node:child_process'; -import { writeFileSync } from 'node:fs'; -import { join } from 'node:path'; import { runVarlock } from '../helpers/run-varlock.js'; const RUN_KEYCHAIN_SMOKE = process.env.VARLOCK_RUN_KEYCHAIN_SMOKE === '1'; -const SERVICE = 'varlock-smoke-test'; -const FIXTURE_DIR = 'smoke-test-keychain-fix'; -const FIXTURE_SCHEMA_PATH = join(import.meta.dirname, '..', FIXTURE_DIR, '.env.schema'); -const createdAccounts: Array = []; - -function uniqueId() { - return `${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2)}`; -} +const SERVICE = 'varlock-smoke-test-fix'; +const PROFILE = 'local'; +const PROJECT = 'smoke-test-keychain-fix'; +const FIXTURE_DIR = PROJECT; +const FIXTURE_SCHEMA = '.env.schema'; +const FIXED_SECRETS = { + FIXED_API_KEY: 'fixed-api-key-from-keychain', + FIXED_DATABASE_URL: 'postgres://fixed-user:fixed-pass@localhost:5432/fixed-db', +}; +const FIXED_ACCOUNTS = Object.keys(FIXED_SECRETS).map((key) => `${PROJECT}:${PROFILE}:${key}`); function security(args: Array) { return spawnSync('security', args, { encoding: 'utf-8' }); } +function deleteFixedSecrets() { + for (const account of FIXED_ACCOUNTS) { + security(['delete-generic-password', '-s', SERVICE, '-a', account]); + } +} + function createKeychainItem(account: string, value: string) { const result = security([ 'add-generic-password', @@ -34,68 +40,43 @@ function createKeychainItem(account: string, value: string) { '', ]); expect(result.status, result.stderr || result.stdout).toBe(0); - createdAccounts.push(account); -} - -function writeKeychainFixture(account: string) { - writeFileSync(FIXTURE_SCHEMA_PATH, [ - '# @defaultSensitive=false', - '# ---', - '', - '# @sensitive', - `SECRET_FROM_KEYCHAIN=keychain(service="${SERVICE}", account="${account}")`, - '', - ].join('\n')); -} - -function clearKeychainFixture() { - writeFileSync(FIXTURE_SCHEMA_PATH, ''); } -afterEach(() => { - for (const account of createdAccounts.splice(0)) { - security(['delete-generic-password', '-s', SERVICE, '-a', account]); +beforeEach(() => { + deleteFixedSecrets(); + for (const [key, value] of Object.entries(FIXED_SECRETS)) { + createKeychainItem(`${PROJECT}:${PROFILE}:${key}`, value); } - clearKeychainFixture(); }); -describe.skipIf(!RUN_KEYCHAIN_SMOKE || process.platform !== 'darwin')('macOS Keychain smoke tests', () => { - test('lists, resolves when already allowed, or fixes access before resolving a keychain item', () => { - const account = `node:${uniqueId()}:SECRET_FROM_KEYCHAIN`; - const secret = `keychain-smoke-secret-${uniqueId()}`; - createKeychainItem(account, secret); - writeKeychainFixture(account); - - const listResult = runVarlock(['keychain', 'list', account]); - expect(listResult.exitCode, listResult.output).toBe(0); - expect(listResult.output).toContain(SERVICE); - expect(listResult.output).toContain(account); - - const initialLoadResult = runVarlock(['load', '--format', 'json', '--skip-cache'], { - cwd: FIXTURE_DIR, - }); - - if (initialLoadResult.exitCode === 0) { - const vars = JSON.parse(initialLoadResult.stdout); - expect(vars.SECRET_FROM_KEYCHAIN).toBe(secret); - return; - } +afterEach(() => { + deleteFixedSecrets(); +}); +describe.skipIf(!RUN_KEYCHAIN_SMOKE || process.platform !== 'darwin')('macOS Keychain fix-access smoke tests', () => { + test('fixes access for keychain refs in the fixture schema and reads the secrets', () => { const fixAccessResult = runVarlock([ 'keychain', 'fix-access', - '--service', - SERVICE, - '--account', - account, - ]); + '--path', + FIXTURE_SCHEMA, + ], { cwd: FIXTURE_DIR }); expect(fixAccessResult.exitCode, fixAccessResult.output).toBe(0); + const apiKeyResult = runVarlock(['printenv', 'FIXED_API_KEY', '--skip-cache'], { cwd: FIXTURE_DIR }); + expect(apiKeyResult.exitCode, apiKeyResult.output).toBe(0); + expect(apiKeyResult.stdout.trim()).toBe(FIXED_SECRETS.FIXED_API_KEY); + + const databaseUrlResult = runVarlock(['printenv', 'FIXED_DATABASE_URL', '--skip-cache'], { cwd: FIXTURE_DIR }); + expect(databaseUrlResult.exitCode, databaseUrlResult.output).toBe(0); + expect(databaseUrlResult.stdout.trim()).toBe(FIXED_SECRETS.FIXED_DATABASE_URL); + const loadAfterFixResult = runVarlock(['load', '--format', 'json', '--skip-cache'], { cwd: FIXTURE_DIR, }); expect(loadAfterFixResult.exitCode, loadAfterFixResult.output).toBe(0); const vars = JSON.parse(loadAfterFixResult.stdout); - expect(vars.SECRET_FROM_KEYCHAIN).toBe(secret); + expect(vars.FIXED_API_KEY).toBe(FIXED_SECRETS.FIXED_API_KEY); + expect(vars.FIXED_DATABASE_URL).toBe(FIXED_SECRETS.FIXED_DATABASE_URL); }); }); From 2cd43650c454414db7681a89d6819eece405722c Mon Sep 17 00:00:00 2001 From: Benjamin Jesuiter Date: Fri, 26 Jun 2026 17:09:58 +0200 Subject: [PATCH 09/26] fix: batch keychain access fixes --- .bumpy/keychain-fix-access-batch.md | 5 ++ .../KeychainLegacy/KeychainLegacy.swift | 16 ++++++ .../VarlockEnclave/KeychainManager.swift | 35 ++++++++++++ .../swift/Sources/VarlockEnclave/main.swift | 54 +++++++++++++++++++ .../src/cli/commands/keychain.command.ts | 31 +++++++---- .../src/lib/local-encrypt/daemon-client.ts | 17 +++++- .../varlock/src/lib/local-encrypt/types.ts | 7 ++- 7 files changed, 154 insertions(+), 11 deletions(-) create mode 100644 .bumpy/keychain-fix-access-batch.md diff --git a/.bumpy/keychain-fix-access-batch.md b/.bumpy/keychain-fix-access-batch.md new file mode 100644 index 000000000..9b91d7bf1 --- /dev/null +++ b/.bumpy/keychain-fix-access-batch.md @@ -0,0 +1,5 @@ +--- +varlock: patch +--- + +Reduce macOS Keychain fix-access password prompts when fixing multiple refs. diff --git a/packages/encryption-binary-swift/swift/Sources/KeychainLegacy/KeychainLegacy.swift b/packages/encryption-binary-swift/swift/Sources/KeychainLegacy/KeychainLegacy.swift index 2a70361cf..4ffedff43 100644 --- a/packages/encryption-binary-swift/swift/Sources/KeychainLegacy/KeychainLegacy.swift +++ b/packages/encryption-binary-swift/swift/Sources/KeychainLegacy/KeychainLegacy.swift @@ -52,4 +52,20 @@ public enum LegacyKeychain { let status = SecKeychainOpen(path, &keychain) return (status, keychain) } + + public static func keychainCopyDefault() -> (OSStatus, SecKeychain?) { + var keychain: SecKeychain? + let status = SecKeychainCopyDefault(&keychain) + return (status, keychain) + } + + public static func keychainGetStatus(_ keychain: SecKeychain) -> (OSStatus, SecKeychainStatus) { + var keychainStatus = SecKeychainStatus() + let status = SecKeychainGetStatus(keychain, &keychainStatus) + return (status, keychainStatus) + } + + public static func keychainUnlock(_ keychain: SecKeychain) -> OSStatus { + return SecKeychainUnlock(keychain, 0, nil, false) + } } diff --git a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/KeychainManager.swift b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/KeychainManager.swift index dd0506fc7..a03d78cc0 100644 --- a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/KeychainManager.swift +++ b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/KeychainManager.swift @@ -409,6 +409,41 @@ final class KeychainManager { /// This uses the legacy Keychain API which supports per-application access control. /// Returns true if the ACL was modified, false if no change was needed. /// macOS will prompt the user for authentication to authorize the change. + static func unlockForAccessFix(keychainName: String? = nil) throws { + let keychain: SecKeychain? + if let keychainName = keychainName { + keychain = resolveKeychain(named: keychainName) + if keychain == nil { + throw KeychainError.keychainNotFound(keychainName) + } + } else { + let (status, defaultKeychain) = LegacyKeychain.keychainCopyDefault() + if status != errSecSuccess { + throw KeychainError.unhandledError(status) + } + keychain = defaultKeychain + } + + guard let keychain else { + throw KeychainError.itemNotFound + } + + let (statusResult, keychainStatus) = LegacyKeychain.keychainGetStatus(keychain) + if statusResult != errSecSuccess { + throw KeychainError.unhandledError(statusResult) + } + + let unlockedStatus = SecKeychainStatus(1) + if (keychainStatus & unlockedStatus) != 0 { + return + } + + let unlockStatus = LegacyKeychain.keychainUnlock(keychain) + if unlockStatus != errSecSuccess { + throw KeychainError.unhandledError(unlockStatus) + } + } + static func addToACL(service: String, account: String? = nil, keychainName: String? = nil, appPath: String) throws -> Bool { // We need the item reference for ACL manipulation let itemRef = try getItemRef(service: service, account: account, keychainName: keychainName) diff --git a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/main.swift b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/main.swift index 3cf10dc67..61c3dbd6c 100644 --- a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/main.swift +++ b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/main.swift @@ -27,6 +27,13 @@ func jsonSuccess(_ result: [String: Any]) -> Never { _exit(0) } +func keychainErrorMessage(_ error: Error) -> String { + if let keychainError = error as? KeychainError { + return keychainError.localizedDescription + } + return error.localizedDescription +} + func keychainErrorResponse(_ error: Error) -> [String: Any] { if let keychainError = error as? KeychainError { return [ @@ -386,6 +393,7 @@ case "daemon": let appPath = Bundle.main.executablePath ?? ProcessInfo.processInfo.arguments[0] do { + try KeychainManager.unlockForAccessFix(keychainName: keychainName) let modified = try KeychainManager.addToACL( service: service, account: account, @@ -397,6 +405,52 @@ case "daemon": return keychainErrorResponse(error) } + case "keychain-fix-access-batch": + guard let payload = message["payload"] as? [String: Any] else { + return ["error": "Missing payload"] + } + guard let items = payload["items"] as? [[String: Any]] else { + return ["error": "Missing items"] + } + let appPath = Bundle.main.executablePath ?? ProcessInfo.processInfo.arguments[0] + let keychainNames = Set(items.map { ($0["keychain"] as? String) ?? "" }) + + do { + for keychainName in keychainNames { + try KeychainManager.unlockForAccessFix(keychainName: keychainName.isEmpty ? nil : keychainName) + } + } catch { + return keychainErrorResponse(error) + } + + var results: [[String: Any]] = [] + for item in items { + guard let service = item["service"] as? String else { + results.append(["modified": false, "error": "Missing service"]) + continue + } + let account = item["account"] as? String + let keychainName = item["keychain"] as? String + do { + let modified = try KeychainManager.addToACL( + service: service, + account: account, + keychainName: keychainName, + appPath: appPath + ) + var result: [String: Any] = ["service": service, "modified": modified] + if let account = account { result["account"] = account } + if let keychainName = keychainName { result["keychain"] = keychainName } + results.append(result) + } catch { + var result: [String: Any] = ["service": service, "modified": false, "error": keychainErrorMessage(error)] + if let account = account { result["account"] = account } + if let keychainName = keychainName { result["keychain"] = keychainName } + results.append(result) + } + } + return ["result": ["results": results]] + case "keychain-set": guard let payload = message["payload"] as? [String: Any] else { return ["error": "Missing payload"] diff --git a/packages/varlock/src/cli/commands/keychain.command.ts b/packages/varlock/src/cli/commands/keychain.command.ts index 4445b5c1b..d48125ed4 100644 --- a/packages/varlock/src/cli/commands/keychain.command.ts +++ b/packages/varlock/src/cli/commands/keychain.command.ts @@ -211,18 +211,31 @@ async function fixAccessForRefs(refs: Array) { let unchanged = 0; let failed = 0; - for (const ref of refs) { - try { - const result = await client.keychainFixAccess(ref); - if (result.modified) updated++; + try { + const result = await client.keychainFixAccessBatch(refs); + for (let i = 0; i < refs.length; i++) { + const ref = refs[i]!; + const item = result.results[i]; + if (!item) { + failed++; + const label = ref.key ? `${ref.key}: ` : ''; + console.error(ansis.red(` ${label}No result returned`)); + continue; + } + if (item.error) { + failed++; + const label = ref.key ? `${ref.key}: ` : ''; + console.error(ansis.red(` ${label}${item.error}`)); + continue; + } + if (item.modified) updated++; else unchanged++; const label = ref.key ? `${ref.key} ` : ''; - console.log(` ${label}${result.modified ? 'updated' : 'already allowed'}`); - } catch (err) { - failed++; - const label = ref.key ? `${ref.key}: ` : ''; - console.error(ansis.red(` ${label}${err instanceof Error ? err.message : err}`)); + console.log(` ${label}${item.modified ? 'updated' : 'already allowed'}`); } + } catch (err) { + failed = refs.length; + console.error(ansis.red(` ${err instanceof Error ? err.message : err}`)); } console.log(`\nChecked ${refs.length} item${refs.length === 1 ? '' : 's'}: ${updated} updated, ${unchanged} already allowed, ${failed} failed.`); diff --git a/packages/varlock/src/lib/local-encrypt/daemon-client.ts b/packages/varlock/src/lib/local-encrypt/daemon-client.ts index 449b86235..b96ab4090 100644 --- a/packages/varlock/src/lib/local-encrypt/daemon-client.ts +++ b/packages/varlock/src/lib/local-encrypt/daemon-client.ts @@ -24,7 +24,7 @@ import { getUserVarlockDir } from '../user-config-dir'; import { resolveNativeBinary } from './binary-resolver'; import { isWSL } from './wsl-detect'; import type { - KeychainFixAccessResult, KeychainItemMeta, KeychainItemRef, KeychainSetResult, + KeychainFixAccessBatchResult, KeychainFixAccessResult, KeychainItemMeta, KeychainItemRef, KeychainSetResult, } from './types'; /** Timeout for daemon IPC messages that don't involve user interaction */ @@ -402,6 +402,21 @@ export class DaemonClient { }); } + async keychainFixAccessBatch(opts: Array<{ + service: string; + account?: string; + keychain?: string; + }>): Promise { + return this.withRetry(async () => { + await this.ensureConnected(); + const result = await this.sendMessage({ + action: 'keychain-fix-access-batch', + payload: { items: opts }, + }, INTERACTIVE_TIMEOUT_MS); + return result as KeychainFixAccessBatchResult; + }); + } + async keychainSet(opts: { service: string; account?: string; diff --git a/packages/varlock/src/lib/local-encrypt/types.ts b/packages/varlock/src/lib/local-encrypt/types.ts index 1811008e0..26b5b8e65 100644 --- a/packages/varlock/src/lib/local-encrypt/types.ts +++ b/packages/varlock/src/lib/local-encrypt/types.ts @@ -25,7 +25,7 @@ export interface BackendInfo { export interface DaemonMessage { id: string; action: 'decrypt' | 'encrypt' | 'prompt-secret' | 'ping' | 'invalidate-session' - | 'keychain-get' | 'keychain-search' | 'keychain-pick' | 'keychain-fix-access' | 'keychain-set'; + | 'keychain-get' | 'keychain-search' | 'keychain-pick' | 'keychain-fix-access' | 'keychain-fix-access-batch' | 'keychain-set'; payload?: Record; } @@ -59,6 +59,11 @@ export interface KeychainFixAccessResult { modified: boolean; } +/** Result from adding VarlockEnclave to multiple keychain item access lists */ +export interface KeychainFixAccessBatchResult { + results: Array; +} + /** Result from creating or updating a keychain item */ export interface KeychainSetResult { updated: boolean; From 69fb0f9f7c314cac9ae62ad69a66262e0d41ca0f Mon Sep 17 00:00:00 2001 From: Benjamin Jesuiter Date: Fri, 26 Jun 2026 17:24:09 +0200 Subject: [PATCH 10/26] fix: read keychain secrets via security cli --- .bumpy/keychain-security-cli-reads.md | 5 ++ .../VarlockEnclave/KeychainManager.swift | 64 +++++++++++++++++-- 2 files changed, 63 insertions(+), 6 deletions(-) create mode 100644 .bumpy/keychain-security-cli-reads.md diff --git a/.bumpy/keychain-security-cli-reads.md b/.bumpy/keychain-security-cli-reads.md new file mode 100644 index 000000000..12f38c1f4 --- /dev/null +++ b/.bumpy/keychain-security-cli-reads.md @@ -0,0 +1,5 @@ +--- +varlock: patch +--- + +Reduce macOS Keychain password prompts by reading keychain secrets through the security CLI. diff --git a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/KeychainManager.swift b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/KeychainManager.swift index a03d78cc0..6ba95fcaf 100644 --- a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/KeychainManager.swift +++ b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/KeychainManager.swift @@ -250,6 +250,12 @@ final class KeychainManager { } private static func getItemOfClass(_ itemClass: CFString, service: String?, account: String?, keychainName: String?) throws -> String { + if itemClass == kSecClassGenericPassword, + let service = service, + let value = try? getGenericPasswordViaSecurityCLI(service: service, account: account, keychainName: keychainName) { + return value + } + // When account is nil and service is set, check for ambiguity if account == nil, let service = service { var countQuery: [CFString: Any] = [ @@ -318,6 +324,44 @@ final class KeychainManager { } } + private static func getGenericPasswordViaSecurityCLI(service: String, account: String?, keychainName: String?) throws -> String { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/security") + + var arguments = ["find-generic-password", "-s", service, "-w"] + if let account = account { + arguments.append(contentsOf: ["-a", account]) + } + if let keychainName = keychainName, let keychainPath = resolveKeychainPath(named: keychainName) { + arguments.append(keychainPath) + } + process.arguments = arguments + + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + + try process.run() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + let errorData = stderr.fileHandleForReading.readDataToEndOfFile() + let errorOutput = String(data: errorData, encoding: .utf8) ?? "" + if errorOutput.contains("could not be found") || errorOutput.contains("specified item could not be found") { + throw KeychainError.itemNotFound + } + throw KeychainError.unhandledError(OSStatus(process.terminationStatus)) + } + + let data = stdout.fileHandleForReading.readDataToEndOfFile() + guard var value = String(data: data, encoding: .utf8) else { + throw KeychainError.unexpectedData + } + value = value.replacingOccurrences(of: "\r?\n$", with: "", options: .regularExpression) + return value + } + // MARK: - Add Item /// Create or update a generic password item. @@ -583,6 +627,19 @@ final class KeychainManager { /// Resolve a human-friendly keychain name to a SecKeychain reference. /// Supports: "Login", "System", or a full/partial path. private static func resolveKeychain(named name: String) -> SecKeychain? { + guard let path = resolveKeychainPath(named: name) else { + return nil + } + + let (status, keychain) = LegacyKeychain.keychainOpen(path: path) + guard status == errSecSuccess, let kc = keychain else { + return nil + } + + return kc + } + + private static func resolveKeychainPath(named name: String) -> String? { let lowered = name.lowercased() // Well-known keychains @@ -603,12 +660,7 @@ final class KeychainManager { return nil } - let (status, keychain) = LegacyKeychain.keychainOpen(path: path) - guard status == errSecSuccess, let kc = keychain else { - return nil - } - - return kc + return path } /// Extract keychain file path from item attributes (if available). From 0555ca69d05a5185de05f30464d0be6bf69e637c Mon Sep 17 00:00:00 2001 From: Benjamin Jesuiter Date: Sun, 28 Jun 2026 00:31:15 +0200 Subject: [PATCH 11/26] fix: make keychain fix-access take ownership --- .bumpy/keychain-fix-access-take-ownership.md | 5 + .../VarlockEnclave/KeychainManager.swift | 106 ++++++++---------- 2 files changed, 50 insertions(+), 61 deletions(-) create mode 100644 .bumpy/keychain-fix-access-take-ownership.md diff --git a/.bumpy/keychain-fix-access-take-ownership.md b/.bumpy/keychain-fix-access-take-ownership.md new file mode 100644 index 000000000..5a6e125f9 --- /dev/null +++ b/.bumpy/keychain-fix-access-take-ownership.md @@ -0,0 +1,5 @@ +--- +varlock: patch +--- + +Make macOS Keychain fix-access take ownership of items so future reads stop prompting. diff --git a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/KeychainManager.swift b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/KeychainManager.swift index 6ba95fcaf..bbf86dd38 100644 --- a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/KeychainManager.swift +++ b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/KeychainManager.swift @@ -250,12 +250,6 @@ final class KeychainManager { } private static func getItemOfClass(_ itemClass: CFString, service: String?, account: String?, keychainName: String?) throws -> String { - if itemClass == kSecClassGenericPassword, - let service = service, - let value = try? getGenericPasswordViaSecurityCLI(service: service, account: account, keychainName: keychainName) { - return value - } - // When account is nil and service is set, check for ambiguity if account == nil, let service = service { var countQuery: [CFString: Any] = [ @@ -318,8 +312,18 @@ final class KeychainManager { case errSecItemNotFound: throw KeychainError.itemNotFound case errSecAuthFailed, errSecInteractionNotAllowed: + if itemClass == kSecClassGenericPassword, + let service = service, + let value = try? getGenericPasswordViaSecurityCLI(service: service, account: account, keychainName: keychainName) { + return value + } throw KeychainError.accessDenied("Authentication failed or interaction not allowed") default: + if itemClass == kSecClassGenericPassword, + let service = service, + let value = try? getGenericPasswordViaSecurityCLI(service: service, account: account, keychainName: keychainName) { + return value + } throw KeychainError.unhandledError(status) } } @@ -489,72 +493,52 @@ final class KeychainManager { } static func addToACL(service: String, account: String? = nil, keychainName: String? = nil, appPath: String) throws -> Bool { - // We need the item reference for ACL manipulation - let itemRef = try getItemRef(service: service, account: account, keychainName: keychainName) - - // Get current access object (uses legacy API wrappers from KeychainLegacyACL.swift) - let (accessStatus, access) = LegacyKeychain.itemCopyAccess(itemRef) - guard accessStatus == errSecSuccess, let currentAccess = access else { - if accessStatus == errSecNoAccessForItem { - throw KeychainError.accessDenied("Cannot read ACL for this item — it may be managed by the system") - } - throw KeychainError.unhandledError(accessStatus) - } - - // Get all ACL entries - let (aclListStatus, aclListRef) = LegacyKeychain.accessCopyACLList(currentAccess) - guard aclListStatus == errSecSuccess, let aclList = aclListRef as? [SecACL] else { - throw KeychainError.accessDenied("Cannot read ACL list") - } + return try takeOwnership(service: service, account: account, keychainName: keychainName) + } - // Create trusted application for our binary - let (trustStatus, trustedApp) = LegacyKeychain.trustedApplicationCreate(path: appPath) - guard trustStatus == errSecSuccess, let newTrustedApp = trustedApp else { - throw KeychainError.unhandledError(trustStatus) + /// Make VarlockEnclave the owner of an existing keychain item by reading the + /// current value, deleting the original item, and recreating it through our + /// normal write path. This is more reliable than legacy ACL editing on modern + /// macOS, where an updated ACL can still keep prompting on later reads. + static func takeOwnership(service: String, account: String? = nil, keychainName: String? = nil) throws -> Bool { + guard let account = account, !account.isEmpty else { + throw KeychainError.accessDenied("Cannot take ownership without an explicit account") } - var modified = false - - // Find ACL entries that control decryption/reading and add our app - for acl in aclList { - let (contentsStatus, appList, description, promptSelector) = LegacyKeychain.aclCopyContents(acl) - guard contentsStatus == errSecSuccess else { continue } + let value = try getGenericPasswordViaSecurityCLI(service: service, account: account, keychainName: keychainName) + try deleteItem(service: service, account: account, keychainName: keychainName) + _ = try setGenericPassword(service: service, account: account, value: value, update: false) + return true + } - // nil appList means "allow all apps" — no change needed - guard let currentApps = appList as? [SecTrustedApplication] else { continue } + private static func deleteItem(service: String, account: String, keychainName: String?) throws { + var deleted = false + for itemClass in [kSecClassGenericPassword, kSecClassInternetPassword] { + let serviceAttribute = itemClass == kSecClassGenericPassword ? kSecAttrService : kSecAttrServer + var query: [CFString: Any] = [ + kSecClass: itemClass, + serviceAttribute: service, + kSecAttrAccount: account, + ] - // Check if our app is already in the list - var alreadyPresent = false - for app in currentApps { - let (dataStatus, appData) = LegacyKeychain.trustedApplicationCopyData(app) - if dataStatus == errSecSuccess, - let data = appData as Data?, - let path = String(data: data, encoding: .utf8), - path == appPath { - alreadyPresent = true - break - } + if let keychainName = keychainName, let keychainRef = resolveKeychain(named: keychainName) { + query[kSecMatchSearchList] = [keychainRef] } - if !alreadyPresent { - var updatedApps = currentApps - updatedApps.append(newTrustedApp) - let updateStatus = LegacyKeychain.aclSetContents(acl, apps: updatedApps as CFArray, description: description ?? "" as CFString, prompt: promptSelector) - if updateStatus == errSecSuccess { - modified = true - } + let status = SecItemDelete(query as CFDictionary) + switch status { + case errSecSuccess: + deleted = true + case errSecItemNotFound: + continue + default: + throw KeychainError.unhandledError(status) } } - if modified { - // Apply the modified access object back to the item - let setStatus = LegacyKeychain.itemSetAccess(itemRef, currentAccess) - if setStatus != errSecSuccess { - throw KeychainError.unhandledError(setStatus) - } + if !deleted { + throw KeychainError.itemNotFound } - - return modified } // MARK: - Private Helpers From 6dbf37a9a1f7894fdd3db43bda95073fb28c8363 Mon Sep 17 00:00:00 2001 From: Benjamin Jesuiter Date: Fri, 26 Jun 2026 11:33:19 +0200 Subject: [PATCH 12/26] docs: use generic keychain profile examples --- .../src/content/docs/plugins/macos-keychain.mdx | 14 +++++++------- .../varlock/src/cli/commands/keychain.command.ts | 10 +++++----- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/varlock-website/src/content/docs/plugins/macos-keychain.mdx b/packages/varlock-website/src/content/docs/plugins/macos-keychain.mdx index 8295d8e92..a4656b25c 100644 --- a/packages/varlock-website/src/content/docs/plugins/macos-keychain.mdx +++ b/packages/varlock-website/src/content/docs/plugins/macos-keychain.mdx @@ -44,13 +44,13 @@ ITEM=keychain(prompt) # Opens a native picker dialog If you already have sensitive plaintext values in a local `.env` file, import them into macOS Keychain and replace the plaintext with stable `keychain(...)` references. The file to import is the first argument: ```sh -varlock keychain import .env --profile jb +varlock keychain import .env --profile myenv ``` By default this **edits the file in place** — each sensitive plaintext value is replaced by its `keychain(...)` ref, so the secret no longer lives on disk. Comments and non-sensitive values are left untouched. To write the refs to a *different* file instead and leave the source as-is, pass `--write-to`: ```sh -varlock keychain import .env --profile jb --write-to .env.jb +varlock keychain import .env --profile myenv --write-to .env.myenv ``` Import requires an existing `.env.schema` file for the input env file so Varlock knows which input variables are secrets and which are not. It only imports variables marked `@sensitive` in that schema and never prints secret values. Re-running is safe: values already converted to `keychain(...)` refs are skipped. By default, Varlock refuses to overwrite an existing Keychain item (or, with `--write-to`, an existing ref in the target file); pass `--force` to overwrite. @@ -60,7 +60,7 @@ The file you name must be one Varlock loads as part of your env setup — it res Generated refs use `service="varlock"` and account names like `::`. The project defaults to the current directory name and can be overridden: ```sh -varlock keychain import .env --profile jb --project my-app +varlock keychain import .env --profile myenv --project my-app ``` ## Set one secret manually @@ -68,13 +68,13 @@ varlock keychain import .env --profile jb --project my-app To store one secret without putting the value in shell history, run `set` and enter the value at the masked prompt: ```sh -varlock keychain set API_KEY --profile jb --write-to .env.jb +varlock keychain set API_KEY --profile myenv --write-to .env.myenv ``` This stores the item under `service="varlock"` with account `::API_KEY`, then writes the matching `keychain(...)` ref when `--write-to` is provided. If you need to paste a multi-line secret, pipe it through stdin instead of passing it as a command-line argument: ```sh -cat secret.txt | varlock keychain set PRIVATE_KEY --profile jb --write-to .env.jb +cat secret.txt | varlock keychain set PRIVATE_KEY --profile myenv --write-to .env.myenv ``` By default, `set` refuses to overwrite an existing Keychain item or env ref. Pass `--force` to replace both. @@ -84,7 +84,7 @@ By default, `set` refuses to overwrite an existing Keychain item or env ref. Pas If VarlockEnclave cannot read an existing Keychain item, grant access without using `/usr/bin/security` directly: ```sh -varlock keychain fix-access --account "my-app:jb:API_KEY" +varlock keychain fix-access --account "my-app:myenv:API_KEY" ``` `--service` defaults to `varlock`, but can be overridden for legacy or manually-created Keychain items: @@ -96,7 +96,7 @@ varlock keychain fix-access --service "com.company.api" --account "admin" You can also fix every explicit `keychain(...)` ref in an env file: ```sh -varlock keychain fix-access --path .env.jb +varlock keychain fix-access --path .env.myenv ``` ## List Keychain items diff --git a/packages/varlock/src/cli/commands/keychain.command.ts b/packages/varlock/src/cli/commands/keychain.command.ts index d48125ed4..6057dbddd 100644 --- a/packages/varlock/src/cli/commands/keychain.command.ts +++ b/packages/varlock/src/cli/commands/keychain.command.ts @@ -652,11 +652,11 @@ export const commandSpec = define({ examples: ` Examples: varlock keychain list - varlock keychain fix-access --account "my-project:jb:API_KEY" - varlock keychain fix-access --path .env.jb - varlock keychain import .env --profile jb # migrate .env in place - varlock keychain import .env --profile jb --write-to .env.jb - varlock keychain set API_KEY --profile jb --write-to .env.jb + varlock keychain fix-access --account "my-project:myenv:API_KEY" + varlock keychain fix-access --path .env.myenv + varlock keychain import .env --profile myenv # migrate .env in place + varlock keychain import .env --profile myenv --write-to .env.myenv + varlock keychain set API_KEY --profile myenv --write-to .env.myenv `.trim(), }); From 7bb7d455a3b304d632680852fa98dbe8deab2482 Mon Sep 17 00:00:00 2001 From: Benjamin Jesuiter Date: Sun, 28 Jun 2026 00:55:07 +0200 Subject: [PATCH 13/26] fix: preserve keychain fix-access targets Autoreview found regressions in the KeychainManager ownership rewrite for fix-access. The ownership path read and deleted items from an explicit keychain, then recreated them in the default keychain. It also rejected valid service-only refs, could delete matching internet-password items while rewriting a generic password, and used a lossy security CLI fallback that could strip trailing newlines from secrets. Batch fix-access also unlocked all keychains before processing any item, so one stale or unavailable keychain could abort the whole batch instead of failing only that ref. Fix this by preserving keychainName on generic-password writes, resolving account-less refs only when unambiguous, reading exact secret data through SecItemCopyMatching for ownership, deleting only the targeted generic-password item, falling back to legacy ACL handling for unsupported cases, and moving batch unlock into per-item handling. Validation: bun run lint:fix; bun run --filter varlock build:binary; autoreview --mode local --engine codex reported no accepted/actionable findings. --- .../VarlockEnclave/KeychainManager.swift | 172 ++++++++++++++---- .../swift/Sources/VarlockEnclave/main.swift | 10 +- 2 files changed, 142 insertions(+), 40 deletions(-) diff --git a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/KeychainManager.swift b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/KeychainManager.swift index bbf86dd38..c7e226c83 100644 --- a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/KeychainManager.swift +++ b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/KeychainManager.swift @@ -370,16 +370,29 @@ final class KeychainManager { /// Create or update a generic password item. /// Returns true when an existing item was updated, false when a new item was created. - static func setGenericPassword(service: String, account: String, value: String, update: Bool = false) throws -> Bool { + static func setGenericPassword(service: String, account: String, value: String, update: Bool = false, keychainName: String? = nil) throws -> Bool { guard let valueData = value.data(using: .utf8) else { throw KeychainError.unexpectedData } - let lookup: [CFString: Any] = [ + let keychainRef: SecKeychain? + if let keychainName = keychainName { + guard let resolvedKeychain = resolveKeychain(named: keychainName) else { + throw KeychainError.keychainNotFound(keychainName) + } + keychainRef = resolvedKeychain + } else { + keychainRef = nil + } + + var lookup: [CFString: Any] = [ kSecClass: kSecClassGenericPassword, kSecAttrService: service, kSecAttrAccount: account, ] + if let keychainRef = keychainRef { + lookup[kSecMatchSearchList] = [keychainRef] + } if update { let attrs: [CFString: Any] = [ @@ -400,6 +413,10 @@ final class KeychainManager { var addQuery = lookup addQuery[kSecAttrLabel] = account.isEmpty ? service : account addQuery[kSecValueData] = valueData + if let keychainRef = keychainRef { + addQuery[kSecUseKeychain] = keychainRef + addQuery.removeValue(forKey: kSecMatchSearchList) + } let status = SecItemAdd(addQuery as CFDictionary, nil) switch status { @@ -493,7 +510,72 @@ final class KeychainManager { } static func addToACL(service: String, account: String? = nil, keychainName: String? = nil, appPath: String) throws -> Bool { - return try takeOwnership(service: service, account: account, keychainName: keychainName) + do { + return try takeOwnership(service: service, account: account, keychainName: keychainName) + } catch KeychainError.itemNotFound { + return try addToLegacyACL(service: service, account: account, keychainName: keychainName, appPath: appPath) + } catch KeychainError.accessDenied { + return try addToLegacyACL(service: service, account: account, keychainName: keychainName, appPath: appPath) + } + } + + private static func addToLegacyACL(service: String, account: String? = nil, keychainName: String? = nil, appPath: String) throws -> Bool { + let itemRef = try getItemRef(service: service, account: account, keychainName: keychainName) + + let (accessStatus, access) = LegacyKeychain.itemCopyAccess(itemRef) + guard accessStatus == errSecSuccess, let currentAccess = access else { + if accessStatus == errSecNoAccessForItem { + throw KeychainError.accessDenied("Cannot read ACL for this item — it may be managed by the system") + } + throw KeychainError.unhandledError(accessStatus) + } + + let (aclListStatus, aclListRef) = LegacyKeychain.accessCopyACLList(currentAccess) + guard aclListStatus == errSecSuccess, let aclList = aclListRef as? [SecACL] else { + throw KeychainError.accessDenied("Cannot read ACL list") + } + + let (trustStatus, trustedApp) = LegacyKeychain.trustedApplicationCreate(path: appPath) + guard trustStatus == errSecSuccess, let newTrustedApp = trustedApp else { + throw KeychainError.unhandledError(trustStatus) + } + + var modified = false + for acl in aclList { + let (contentsStatus, appList, description, promptSelector) = LegacyKeychain.aclCopyContents(acl) + guard contentsStatus == errSecSuccess else { continue } + guard let currentApps = appList as? [SecTrustedApplication] else { continue } + + var alreadyPresent = false + for app in currentApps { + let (dataStatus, appData) = LegacyKeychain.trustedApplicationCopyData(app) + if dataStatus == errSecSuccess, + let data = appData as Data?, + let path = String(data: data, encoding: .utf8), + path == appPath { + alreadyPresent = true + break + } + } + + if !alreadyPresent { + var updatedApps = currentApps + updatedApps.append(newTrustedApp) + let updateStatus = LegacyKeychain.aclSetContents(acl, apps: updatedApps as CFArray, description: description ?? "" as CFString, prompt: promptSelector) + if updateStatus == errSecSuccess { + modified = true + } + } + } + + if modified { + let setStatus = LegacyKeychain.itemSetAccess(itemRef, currentAccess) + if setStatus != errSecSuccess { + throw KeychainError.unhandledError(setStatus) + } + } + + return modified } /// Make VarlockEnclave the owner of an existing keychain item by reading the @@ -501,43 +583,71 @@ final class KeychainManager { /// normal write path. This is more reliable than legacy ACL editing on modern /// macOS, where an updated ACL can still keep prompting on later reads. static func takeOwnership(service: String, account: String? = nil, keychainName: String? = nil) throws -> Bool { - guard let account = account, !account.isEmpty else { - throw KeychainError.accessDenied("Cannot take ownership without an explicit account") - } - - let value = try getGenericPasswordViaSecurityCLI(service: service, account: account, keychainName: keychainName) - try deleteItem(service: service, account: account, keychainName: keychainName) - _ = try setGenericPassword(service: service, account: account, value: value, update: false) + let (resolvedAccount, value) = try getGenericPasswordForOwnership(service: service, account: account, keychainName: keychainName) + try deleteGenericPassword(service: service, account: resolvedAccount, keychainName: keychainName) + _ = try setGenericPassword(service: service, account: resolvedAccount, value: value, update: false, keychainName: keychainName) return true } - private static func deleteItem(service: String, account: String, keychainName: String?) throws { - var deleted = false - for itemClass in [kSecClassGenericPassword, kSecClassInternetPassword] { - let serviceAttribute = itemClass == kSecClassGenericPassword ? kSecAttrService : kSecAttrServer - var query: [CFString: Any] = [ - kSecClass: itemClass, - serviceAttribute: service, - kSecAttrAccount: account, - ] + private static func getGenericPasswordForOwnership(service: String, account: String?, keychainName: String?) throws -> (String, String) { + var query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecReturnAttributes: true, + kSecReturnData: true, + kSecMatchLimit: kSecMatchLimitAll, + ] - if let keychainName = keychainName, let keychainRef = resolveKeychain(named: keychainName) { - query[kSecMatchSearchList] = [keychainRef] - } + if let account = account { + query[kSecAttrAccount] = account + } + if let keychainName = keychainName, let keychainRef = resolveKeychain(named: keychainName) { + query[kSecMatchSearchList] = [keychainRef] + } - let status = SecItemDelete(query as CFDictionary) - switch status { - case errSecSuccess: - deleted = true - case errSecItemNotFound: - continue - default: - throw KeychainError.unhandledError(status) + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + switch status { + case errSecSuccess: + guard let items = result as? [[String: Any]], let item = items.first else { + throw KeychainError.itemNotFound } + if account == nil, items.count > 1 { + let accounts = items.compactMap { $0[kSecAttrAccount as String] as? String } + throw KeychainError.ambiguousMatch(service: service, accounts: accounts) + } + guard let data = item[kSecValueData as String] as? Data, let value = String(data: data, encoding: .utf8) else { + throw KeychainError.unexpectedData + } + return ((item[kSecAttrAccount as String] as? String) ?? "", value) + case errSecItemNotFound: + throw KeychainError.itemNotFound + case errSecAuthFailed, errSecInteractionNotAllowed: + throw KeychainError.accessDenied("Authentication failed or interaction not allowed") + default: + throw KeychainError.unhandledError(status) + } + } + + private static func deleteGenericPassword(service: String, account: String, keychainName: String?) throws { + var query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: account, + ] + + if let keychainName = keychainName, let keychainRef = resolveKeychain(named: keychainName) { + query[kSecMatchSearchList] = [keychainRef] } - if !deleted { + let status = SecItemDelete(query as CFDictionary) + switch status { + case errSecSuccess: + return + case errSecItemNotFound: throw KeychainError.itemNotFound + default: + throw KeychainError.unhandledError(status) } } diff --git a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/main.swift b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/main.swift index 61c3dbd6c..67a77d726 100644 --- a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/main.swift +++ b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/main.swift @@ -413,15 +413,6 @@ case "daemon": return ["error": "Missing items"] } let appPath = Bundle.main.executablePath ?? ProcessInfo.processInfo.arguments[0] - let keychainNames = Set(items.map { ($0["keychain"] as? String) ?? "" }) - - do { - for keychainName in keychainNames { - try KeychainManager.unlockForAccessFix(keychainName: keychainName.isEmpty ? nil : keychainName) - } - } catch { - return keychainErrorResponse(error) - } var results: [[String: Any]] = [] for item in items { @@ -432,6 +423,7 @@ case "daemon": let account = item["account"] as? String let keychainName = item["keychain"] as? String do { + try KeychainManager.unlockForAccessFix(keychainName: keychainName) let modified = try KeychainManager.addToACL( service: service, account: account, From b052bd16f8a5a5d5d75b3cf86b0102ed5644f2e9 Mon Sep 17 00:00:00 2001 From: Benjamin Jesuiter Date: Sun, 28 Jun 2026 00:57:28 +0200 Subject: [PATCH 14/26] test: run smoke suite through deno Add Deno execution support to the shared smoke-test runner so the existing smoke tests can be reused under deno run -A instead of maintaining a separate deno-only test file. Also teach varlock completion generation how to emit shell completion scripts when invoked through Deno, and route keychain-set through the shared runner so it participates in the Deno smoke path. --- packages/varlock/package.json | 1 + packages/varlock/src/cli/cli-executable.ts | 25 ++++++ smoke-tests/helpers/run-varlock.ts | 13 ++- smoke-tests/package.json | 5 +- smoke-tests/tests/deno-compat.test.ts | 93 ---------------------- smoke-tests/tests/keychain-set.test.ts | 19 +---- 6 files changed, 43 insertions(+), 113 deletions(-) delete mode 100644 smoke-tests/tests/deno-compat.test.ts diff --git a/packages/varlock/package.json b/packages/varlock/package.json index cb1826670..2c7c07ebb 100644 --- a/packages/varlock/package.json +++ b/packages/varlock/package.json @@ -140,6 +140,7 @@ "@clack/prompts": "^1.0.0", "@env-spec/parser": "workspace:*", "@env-spec/utils": "workspace:*", + "@bomb.sh/tab": "^0.0.15", "@gunshi/plugin-completion": "^0.35.1", "@gunshi/plugin-i18n": "^0.35.1", "@sindresorhus/is": "catalog:", diff --git a/packages/varlock/src/cli/cli-executable.ts b/packages/varlock/src/cli/cli-executable.ts index 0be206b20..d363d6096 100644 --- a/packages/varlock/src/cli/cli-executable.ts +++ b/packages/varlock/src/cli/cli-executable.ts @@ -1,5 +1,6 @@ import { cli, type Command } from 'gunshi'; import completion from '@gunshi/plugin-completion'; +import { script as completionScript } from '@bomb.sh/tab'; import { gracefulExit } from 'exit-hook'; import { VARLOCK_BANNER_COLOR } from '../lib/ascii-art'; @@ -36,6 +37,25 @@ import { commandSpec as keychainCommandSpec } from './commands/keychain.command' let versionId = packageJson.version; if (__VARLOCK_BUILD_TYPE__ !== 'release') versionId += `-${__VARLOCK_BUILD_TYPE__}`; +function shellQuote(value: string) { + if (value.length === 0) return "''"; + if (/^[\w%+,./:=@-]+$/.test(value)) return value; + return `'${value.replaceAll("'", "'\\''")}'`; +} + +function escapeDoubleQuotedShellString(value: string) { + return value.replaceAll(/[$`"\\]/g, '\\$&'); +} + +function getDenoCompletionExec() { + return escapeDoubleQuotedShellString([ + shellQuote(process.execPath), + 'run', + '-A', + shellQuote(process.argv[1] ?? ''), + ].join(' ')); +} + // TODO: this is not splitting the bundle correctly to actually lazy load the command fns function buildLazyCommand( commandSpec: Command, @@ -91,6 +111,11 @@ subCommands.set('keychain', buildLazyCommand(keychainCommandSpec, async () => aw if (args[0] === 'help') args = ['--help']; const isCompletionInvoke = args[0] === 'complete'; + const isDenoRuntime = typeof (globalThis as typeof globalThis & { Deno?: unknown }).Deno !== 'undefined'; + if (isDenoRuntime && isCompletionInvoke && ['zsh', 'bash', 'fish', 'powershell'].includes(args[1] ?? '')) { + completionScript(args[1], 'varlock', getDenoCompletionExec()); + gracefulExit(); + } // track standalone installs via homebrew/curl if (__VARLOCK_SEA_BUILD__) { diff --git a/smoke-tests/helpers/run-varlock.ts b/smoke-tests/helpers/run-varlock.ts index df5f9e84c..c96e6deae 100644 --- a/smoke-tests/helpers/run-varlock.ts +++ b/smoke-tests/helpers/run-varlock.ts @@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process'; import { join } from 'node:path'; const SMOKE_TESTS_DIR = join(import.meta.dirname, '..'); +const RUN_DENO_COMPAT = process.env.VARLOCK_RUN_DENO_COMPAT === '1'; // Invoke the *installed* varlock CLI from smoke-tests/node_modules rather than the source tree. // `varlock` is a direct dependency of smoke-tests, so pnpm always links it at node_modules/varlock: // in CI it's the packed .tgz (which bundles bin/ + dist/), locally it's the workspace link to @@ -12,15 +13,25 @@ const SMOKE_TESTS_DIR = join(import.meta.dirname, '..'); // than the un-built source tree. export const VARLOCK_CLI = join(SMOKE_TESTS_DIR, 'node_modules', 'varlock', 'bin', 'cli.js'); +export function getVarlockCommand(args: Array) { + if (RUN_DENO_COMPAT) { + return { command: 'deno', args: ['run', '-A', VARLOCK_CLI, ...args] }; + } + return { command: process.execPath, args: [VARLOCK_CLI, ...args] }; +} + export function runVarlock(args: Array, options?: { cwd?: string; env?: Record; + input?: string; }) { const cwd = options?.cwd ? join(SMOKE_TESTS_DIR, options.cwd) : SMOKE_TESTS_DIR; const env = { ...process.env, ...options?.env }; - const result = spawnSync(process.execPath, [VARLOCK_CLI, ...args], { + const command = getVarlockCommand(args); + const result = spawnSync(command.command, command.args, { cwd, env, + input: options?.input, encoding: 'utf-8', }); diff --git a/smoke-tests/package.json b/smoke-tests/package.json index 5efeca898..aed6ae6b5 100644 --- a/smoke-tests/package.json +++ b/smoke-tests/package.json @@ -7,8 +7,9 @@ "scripts": { "test": "vitest run", "test:watch": "vitest", - "test:deno-compat": "VARLOCK_RUN_DENO_COMPAT=1 vitest run tests/deno-compat.test.ts", - "test:keychain": "VARLOCK_RUN_KEYCHAIN_SMOKE=1 vitest run tests/keychain-fix.test.ts tests/keychain-set.test.ts tests/keychain-import.test.ts" + "test:deno-compat": "VARLOCK_RUN_DENO_COMPAT=1 vitest run", + "test:keychain": "VARLOCK_RUN_KEYCHAIN_SMOKE=1 vitest run tests/keychain-fix.test.ts tests/keychain-set.test.ts tests/keychain-import.test.ts", + "test:keychain-deno": "VARLOCK_RUN_DENO_COMPAT=1 VARLOCK_RUN_KEYCHAIN_SMOKE=1 vitest run tests/keychain-fix.test.ts tests/keychain-set.test.ts tests/keychain-import.test.ts" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/smoke-tests/tests/deno-compat.test.ts b/smoke-tests/tests/deno-compat.test.ts deleted file mode 100644 index 1b4380a70..000000000 --- a/smoke-tests/tests/deno-compat.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { - beforeAll, describe, expect, test, -} from 'vitest'; -import { spawnSync, execSync } from 'node:child_process'; -import { join } from 'node:path'; -import { VARLOCK_CLI } from '../helpers/run-varlock.js'; - -const SMOKE_TESTS_DIR = join(import.meta.dirname, '..'); -const RUN_DENO_COMPAT = process.env.VARLOCK_RUN_DENO_COMPAT === '1'; - -function hasDeno(): boolean { - try { - execSync('deno --version', { stdio: 'ignore' }); - return true; - } catch { - return false; - } -} - -function runDenoVarlock(args: Array, options?: { - cwd?: string; - env?: Record; -}) { - const cwd = options?.cwd ? join(SMOKE_TESTS_DIR, options.cwd) : SMOKE_TESTS_DIR; - const env = { ...process.env, ...options?.env }; - const result = spawnSync('deno', ['run', '-A', VARLOCK_CLI, ...args], { - cwd, - env, - encoding: 'utf-8', - }); - - return { - stdout: result.stdout ?? '', - stderr: result.stderr ?? '', - exitCode: result.status ?? 1, - output: (result.stdout ?? '') + (result.stderr ?? ''), - }; -} - -const denoAvailable = hasDeno(); - -describe.skipIf(!RUN_DENO_COMPAT)('Deno compatibility', () => { - beforeAll(() => { - expect(denoAvailable, 'Deno compatibility tests require the `deno` executable').toBe(true); - }); - test('runs the CLI help through deno run -A', () => { - const result = runDenoVarlock(['--help']); - - expect(result.exitCode).toBe(0); - expect(result.output).toContain('varlock'); - expect(result.output).toContain('load'); - }); - - test('loads a basic env graph as JSON', () => { - const result = runDenoVarlock(['load', '--format', 'json'], { - cwd: 'smoke-test-basic', - }); - - expect(result.exitCode).toBe(0); - const vars = JSON.parse(result.stdout); - expect(vars.NODE_ENV).toBe('test'); - expect(vars.PUBLIC_VAR).toBe('public-value'); - expect(vars.SECRET_TOKEN).toBe('super-secret-token-12345'); - }); - - test('prints a single env var', () => { - const result = runDenoVarlock(['printenv', 'PUBLIC_VAR'], { - cwd: 'smoke-test-basic', - }); - - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe('public-value'); - }); - - test('runs a child command with loaded env vars', () => { - const result = runDenoVarlock(['run', '--', 'deno', 'eval', 'console.log(Deno.env.get("PUBLIC_VAR"))'], { - cwd: 'smoke-test-basic', - }); - - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe('public-value'); - expect(result.output).not.toContain('super-secret-token-12345'); - }); - - test('respects package.json loadPath config', () => { - const result = runDenoVarlock(['printenv', 'PKG_JSON_VAR'], { - cwd: 'smoke-test-pkg-json-config', - }); - - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe('hello-from-pkg-json-config'); - }); -}); diff --git a/smoke-tests/tests/keychain-set.test.ts b/smoke-tests/tests/keychain-set.test.ts index 7c9ae34e8..51fc7f760 100644 --- a/smoke-tests/tests/keychain-set.test.ts +++ b/smoke-tests/tests/keychain-set.test.ts @@ -2,14 +2,11 @@ import { afterEach, describe, expect, test, } from 'vitest'; import { spawnSync } from 'node:child_process'; -import { join } from 'node:path'; -import { VARLOCK_CLI } from '../helpers/run-varlock.js'; +import { runVarlock as runVarlockHelper } from '../helpers/run-varlock.js'; const RUN_KEYCHAIN_SMOKE = process.env.VARLOCK_RUN_KEYCHAIN_SMOKE === '1'; const SERVICE = 'varlock-smoke-test-set'; const FIXTURE_DIR = 'smoke-test-keychain-set'; -const SMOKE_TESTS_DIR = join(import.meta.dirname, '..'); -const FIXTURE_PATH = join(SMOKE_TESTS_DIR, FIXTURE_DIR); const ACCOUNT = 'set:SECRET_FROM_KEYCHAIN'; const createdAccounts: Array = []; @@ -22,19 +19,7 @@ function security(args: Array) { } function runVarlockWithInput(args: Array, input: string) { - const result = spawnSync(process.execPath, [VARLOCK_CLI, ...args], { - cwd: FIXTURE_PATH, - env: { ...process.env }, - input, - encoding: 'utf-8', - }); - - return { - stdout: result.stdout ?? '', - stderr: result.stderr ?? '', - exitCode: result.status ?? 1, - output: (result.stdout ?? '') + (result.stderr ?? ''), - }; + return runVarlockHelper(args, { cwd: FIXTURE_DIR, input }); } function runVarlock(args: Array) { From 00fb7e0797a21eef695a6d2c9b308b1626996f8f Mon Sep 17 00:00:00 2001 From: Benjamin Jesuiter Date: Sun, 28 Jun 2026 01:01:58 +0200 Subject: [PATCH 15/26] fix(keychain): fail on unresolved ownership keychains When takeOwnership is scoped to a named keychain, ownership reads and deletes must not fall back to the default keychain search list if that name cannot be resolved. A typo in --keychain could otherwise read or delete a matching item from another keychain before failing during recreation.\n\nResolve the requested keychain explicitly for both the ownership read and delete paths, and throw keychainNotFound when resolution fails. --- .../swift/Sources/VarlockEnclave/KeychainManager.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/KeychainManager.swift b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/KeychainManager.swift index c7e226c83..cac8e0a14 100644 --- a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/KeychainManager.swift +++ b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/KeychainManager.swift @@ -601,7 +601,10 @@ final class KeychainManager { if let account = account { query[kSecAttrAccount] = account } - if let keychainName = keychainName, let keychainRef = resolveKeychain(named: keychainName) { + if let keychainName = keychainName { + guard let keychainRef = resolveKeychain(named: keychainName) else { + throw KeychainError.keychainNotFound(keychainName) + } query[kSecMatchSearchList] = [keychainRef] } @@ -636,7 +639,10 @@ final class KeychainManager { kSecAttrAccount: account, ] - if let keychainName = keychainName, let keychainRef = resolveKeychain(named: keychainName) { + if let keychainName = keychainName { + guard let keychainRef = resolveKeychain(named: keychainName) else { + throw KeychainError.keychainNotFound(keychainName) + } query[kSecMatchSearchList] = [keychainRef] } From b54971aafcc9ea7c2563176d695cc889fe814c75 Mon Sep 17 00:00:00 2001 From: Benjamin Jesuiter Date: Sun, 28 Jun 2026 01:02:56 +0200 Subject: [PATCH 16/26] fix(keychain): restore secret on ownership failure takeOwnership has to delete and recreate the generic password item so VarlockEnclave becomes the owner. If recreation failed after the delete, fix-access could permanently lose the user's secret.\n\nCatch recreation failures, immediately attempt to write the saved service/account/value back, and return a specific ownershipTransferFailed error that reports whether the secret value was restored. This keeps fix-access from silently turning a repair attempt into destructive migration. --- .../VarlockEnclave/KeychainManager.swift | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/KeychainManager.swift b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/KeychainManager.swift index cac8e0a14..fb9f6cb97 100644 --- a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/KeychainManager.swift +++ b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/KeychainManager.swift @@ -11,6 +11,7 @@ enum KeychainError: LocalizedError { case unhandledError(OSStatus) case keychainNotFound(String) case ambiguousMatch(service: String, accounts: [String]) + case ownershipTransferFailed(recreateError: Error, restoreError: Error?) var code: String { switch self { @@ -28,6 +29,8 @@ enum KeychainError: LocalizedError { return "keychainNotFound" case .ambiguousMatch: return "ambiguousMatch" + case .ownershipTransferFailed: + return "ownershipTransferFailed" } } @@ -51,10 +54,23 @@ enum KeychainError: LocalizedError { case .ambiguousMatch(let service, let accounts): let accountList = accounts.map { "\"\($0)\"" }.joined(separator: ", ") return "Multiple keychain items found for service \"\(service)\" with accounts: \(accountList). Specify account to disambiguate." + case .ownershipTransferFailed(let recreateError, let restoreError): + let recreateMessage = keychainErrorDetail(recreateError) + if let restoreError = restoreError { + return "Ownership transfer failed while recreating the keychain item (\(recreateMessage)); restoring the secret value also failed (\(keychainErrorDetail(restoreError)))." + } + return "Ownership transfer failed while recreating the keychain item (\(recreateMessage)); the secret value was restored." } } } +private func keychainErrorDetail(_ error: Error) -> String { + if let keychainError = error as? KeychainError { + return keychainError.localizedDescription + } + return error.localizedDescription +} + /// Metadata about a keychain item (no secret values) struct KeychainItemMeta { let service: String @@ -585,7 +601,16 @@ final class KeychainManager { static func takeOwnership(service: String, account: String? = nil, keychainName: String? = nil) throws -> Bool { let (resolvedAccount, value) = try getGenericPasswordForOwnership(service: service, account: account, keychainName: keychainName) try deleteGenericPassword(service: service, account: resolvedAccount, keychainName: keychainName) - _ = try setGenericPassword(service: service, account: resolvedAccount, value: value, update: false, keychainName: keychainName) + do { + _ = try setGenericPassword(service: service, account: resolvedAccount, value: value, update: false, keychainName: keychainName) + } catch { + do { + _ = try setGenericPassword(service: service, account: resolvedAccount, value: value, update: false, keychainName: keychainName) + } catch let restoreError { + throw KeychainError.ownershipTransferFailed(recreateError: error, restoreError: restoreError) + } + throw KeychainError.ownershipTransferFailed(recreateError: error, restoreError: nil) + } return true } From 42a0f2422a6e9e751f5134834fc6680812edab4a Mon Sep 17 00:00:00 2001 From: Benjamin Jesuiter Date: Sun, 28 Jun 2026 01:05:37 +0200 Subject: [PATCH 17/26] fix(keychain): stage ownership transfers safely Change takeOwnership to avoid deleting the original item until a replacement has been created and verified. The flow now creates a temporary generic-password item with the same secret, reads it back to verify it is usable, deletes the original item only after that check succeeds, renames the temporary item to the original service/account, then verifies the final item.\n\nIf the swap fails after the original delete, keep the previous recovery behavior: restore the saved secret value at the original service/account when possible, and report whether that recovery succeeded. --- .../VarlockEnclave/KeychainManager.swift | 58 ++++++++++++++++++- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/KeychainManager.swift b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/KeychainManager.swift index fb9f6cb97..5bbf4339b 100644 --- a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/KeychainManager.swift +++ b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/KeychainManager.swift @@ -59,7 +59,7 @@ enum KeychainError: LocalizedError { if let restoreError = restoreError { return "Ownership transfer failed while recreating the keychain item (\(recreateMessage)); restoring the secret value also failed (\(keychainErrorDetail(restoreError)))." } - return "Ownership transfer failed while recreating the keychain item (\(recreateMessage)); the secret value was restored." + return "Ownership transfer failed while recreating the keychain item (\(recreateMessage)); the secret value was preserved or restored." } } } @@ -600,17 +600,38 @@ final class KeychainManager { /// macOS, where an updated ACL can still keep prompting on later reads. static func takeOwnership(service: String, account: String? = nil, keychainName: String? = nil) throws -> Bool { let (resolvedAccount, value) = try getGenericPasswordForOwnership(service: service, account: account, keychainName: keychainName) + let tempService = "\(service).varlock-ownership-transfer.\(UUID().uuidString)" + let tempAccount = "\(resolvedAccount).varlock-ownership-transfer.\(UUID().uuidString)" + + do { + _ = try setGenericPassword(service: tempService, account: tempAccount, value: value, update: false, keychainName: keychainName) + let (_, verifiedValue) = try getGenericPasswordForOwnership(service: tempService, account: tempAccount, keychainName: keychainName) + guard verifiedValue == value else { + throw KeychainError.unexpectedData + } + } catch { + try? deleteGenericPassword(service: tempService, account: tempAccount, keychainName: keychainName) + throw KeychainError.ownershipTransferFailed(recreateError: error, restoreError: nil) + } + try deleteGenericPassword(service: service, account: resolvedAccount, keychainName: keychainName) + do { - _ = try setGenericPassword(service: service, account: resolvedAccount, value: value, update: false, keychainName: keychainName) + try renameGenericPassword(service: tempService, account: tempAccount, newService: service, newAccount: resolvedAccount, keychainName: keychainName) + let (_, verifiedValue) = try getGenericPasswordForOwnership(service: service, account: resolvedAccount, keychainName: keychainName) + guard verifiedValue == value else { + throw KeychainError.unexpectedData + } } catch { do { _ = try setGenericPassword(service: service, account: resolvedAccount, value: value, update: false, keychainName: keychainName) + try? deleteGenericPassword(service: tempService, account: tempAccount, keychainName: keychainName) } catch let restoreError { throw KeychainError.ownershipTransferFailed(recreateError: error, restoreError: restoreError) } throw KeychainError.ownershipTransferFailed(recreateError: error, restoreError: nil) } + return true } @@ -682,6 +703,39 @@ final class KeychainManager { } } + private static func renameGenericPassword(service: String, account: String, newService: String, newAccount: String, keychainName: String?) throws { + var query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: account, + ] + + if let keychainName = keychainName { + guard let keychainRef = resolveKeychain(named: keychainName) else { + throw KeychainError.keychainNotFound(keychainName) + } + query[kSecMatchSearchList] = [keychainRef] + } + + let attrs: [CFString: Any] = [ + kSecAttrService: newService, + kSecAttrAccount: newAccount, + kSecAttrLabel: newAccount.isEmpty ? newService : newAccount, + ] + + let status = SecItemUpdate(query as CFDictionary, attrs as CFDictionary) + switch status { + case errSecSuccess: + return + case errSecItemNotFound: + throw KeychainError.itemNotFound + case errSecDuplicateItem: + throw KeychainError.duplicateItem + default: + throw KeychainError.unhandledError(status) + } + } + // MARK: - Private Helpers /// Get a SecKeychainItem reference for ACL operations. From abda8033e5eba23629da300020199cf234124901 Mon Sep 17 00:00:00 2001 From: Benjamin Jesuiter Date: Sun, 28 Jun 2026 01:08:48 +0200 Subject: [PATCH 18/26] chore: update bun lockfile --- bun.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/bun.lock b/bun.lock index 06e0dca4d..75a4da5ff 100644 --- a/bun.lock +++ b/bun.lock @@ -439,6 +439,7 @@ "varlock": "./bin/cli.js", }, "devDependencies": { + "@bomb.sh/tab": "^0.0.15", "@clack/core": "^1.0.0", "@clack/prompts": "^1.0.0", "@env-spec/parser": "workspace:*", From 905597b61a93ac46b7690443bc1f624ca8966b8f Mon Sep 17 00:00:00 2001 From: Benjamin Jesuiter Date: Sun, 28 Jun 2026 11:07:13 +0200 Subject: [PATCH 19/26] fix: keep keychain fix-access non-destructive Restore fix-access to the legacy ACL-editing behavior so it preserves existing per-app Keychain access on existing items. Move the delete-and-recreate ownership rewrite behind a separate keychain take-ownership command. The new command is explicit about the ACL-reset tradeoff and still preserves named-keychain targeting when it recreates generic-password items. Validation: bun run lint:fix; bun run --filter varlock build:binary; bun run --filter varlock typecheck; autoreview --mode local --engine codex reported no accepted/actionable findings. --- .bumpy/keychain-fix-access-take-ownership.md | 2 +- .../VarlockEnclave/KeychainManager.swift | 16 +--- .../swift/Sources/VarlockEnclave/main.swift | 22 +++++ .../content/docs/plugins/macos-keychain.mdx | 8 ++ .../src/cli/commands/keychain.command.ts | 80 +++++++++++++++++++ .../src/lib/local-encrypt/daemon-client.ts | 15 ++++ .../varlock/src/lib/local-encrypt/types.ts | 2 +- 7 files changed, 130 insertions(+), 15 deletions(-) diff --git a/.bumpy/keychain-fix-access-take-ownership.md b/.bumpy/keychain-fix-access-take-ownership.md index 5a6e125f9..ea6fcf15e 100644 --- a/.bumpy/keychain-fix-access-take-ownership.md +++ b/.bumpy/keychain-fix-access-take-ownership.md @@ -2,4 +2,4 @@ varlock: patch --- -Make macOS Keychain fix-access take ownership of items so future reads stop prompting. +Add an explicit macOS Keychain take-ownership command for generic-password items that need an ownership rewrite. diff --git a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/KeychainManager.swift b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/KeychainManager.swift index 5bbf4339b..05ed156a7 100644 --- a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/KeychainManager.swift +++ b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/KeychainManager.swift @@ -526,16 +526,6 @@ final class KeychainManager { } static func addToACL(service: String, account: String? = nil, keychainName: String? = nil, appPath: String) throws -> Bool { - do { - return try takeOwnership(service: service, account: account, keychainName: keychainName) - } catch KeychainError.itemNotFound { - return try addToLegacyACL(service: service, account: account, keychainName: keychainName, appPath: appPath) - } catch KeychainError.accessDenied { - return try addToLegacyACL(service: service, account: account, keychainName: keychainName, appPath: appPath) - } - } - - private static func addToLegacyACL(service: String, account: String? = nil, keychainName: String? = nil, appPath: String) throws -> Bool { let itemRef = try getItemRef(service: service, account: account, keychainName: keychainName) let (accessStatus, access) = LegacyKeychain.itemCopyAccess(itemRef) @@ -594,10 +584,10 @@ final class KeychainManager { return modified } - /// Make VarlockEnclave the owner of an existing keychain item by reading the + /// Make VarlockEnclave the owner of an existing generic-password keychain item by reading the /// current value, deleting the original item, and recreating it through our - /// normal write path. This is more reliable than legacy ACL editing on modern - /// macOS, where an updated ACL can still keep prompting on later reads. + /// normal write path. This intentionally resets item ACLs, so callers should + /// use it only when explicitly requested instead of the non-destructive ACL flow. static func takeOwnership(service: String, account: String? = nil, keychainName: String? = nil) throws -> Bool { let (resolvedAccount, value) = try getGenericPasswordForOwnership(service: service, account: account, keychainName: keychainName) let tempService = "\(service).varlock-ownership-transfer.\(UUID().uuidString)" diff --git a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/main.swift b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/main.swift index 67a77d726..5000188b2 100644 --- a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/main.swift +++ b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/main.swift @@ -405,6 +405,28 @@ case "daemon": return keychainErrorResponse(error) } + case "keychain-take-ownership": + guard let payload = message["payload"] as? [String: Any] else { + return ["error": "Missing payload"] + } + guard let service = payload["service"] as? String else { + return ["error": "Missing service"] + } + let account = payload["account"] as? String + let keychainName = payload["keychain"] as? String + + do { + try KeychainManager.unlockForAccessFix(keychainName: keychainName) + let modified = try KeychainManager.takeOwnership( + service: service, + account: account, + keychainName: keychainName + ) + return ["result": ["modified": modified]] + } catch { + return keychainErrorResponse(error) + } + case "keychain-fix-access-batch": guard let payload = message["payload"] as? [String: Any] else { return ["error": "Missing payload"] diff --git a/packages/varlock-website/src/content/docs/plugins/macos-keychain.mdx b/packages/varlock-website/src/content/docs/plugins/macos-keychain.mdx index a4656b25c..94e3830c7 100644 --- a/packages/varlock-website/src/content/docs/plugins/macos-keychain.mdx +++ b/packages/varlock-website/src/content/docs/plugins/macos-keychain.mdx @@ -99,6 +99,14 @@ You can also fix every explicit `keychain(...)` ref in an env file: varlock keychain fix-access --path .env.myenv ``` +`fix-access` preserves existing Keychain item ACLs. If macOS still prompts after fixing access, you can explicitly recreate a generic-password item so VarlockEnclave owns it: + +```sh +varlock keychain take-ownership --account "my-app:myenv:API_KEY" +``` + +This can reset per-app Keychain ACLs on that item, so use it only when you want that ownership rewrite. + ## List Keychain items To see which Keychain items are available, list them by service name. This shows metadata only (service, account, and keychain) and never reads secret values: diff --git a/packages/varlock/src/cli/commands/keychain.command.ts b/packages/varlock/src/cli/commands/keychain.command.ts index 6057dbddd..76594165b 100644 --- a/packages/varlock/src/cli/commands/keychain.command.ts +++ b/packages/varlock/src/cli/commands/keychain.command.ts @@ -242,6 +242,28 @@ async function fixAccessForRefs(refs: Array) { if (failed > 0) process.exitCode = 1; } +async function takeOwnershipForRefs(refs: Array) { + const client = getDaemonClient(); + let updated = 0; + let failed = 0; + + for (const ref of refs) { + try { + const result = await client.keychainTakeOwnership(ref); + if (result.modified) updated++; + const label = ref.key ? `${ref.key} ` : ''; + console.log(` ${label}${result.modified ? 'ownership taken' : 'already owned'}`); + } catch (err) { + failed++; + const label = ref.key ? `${ref.key}: ` : ''; + console.error(ansis.red(` ${label}${err instanceof Error ? err.message : err}`)); + } + } + + console.log(`\nChecked ${refs.length} item${refs.length === 1 ? '' : 's'}: ${updated} updated, ${failed} failed.`); + if (failed > 0) process.exitCode = 1; +} + async function readSecretForSet(label: string): Promise { if (!process.stdin.isTTY) { const chunks: Array = []; @@ -522,6 +544,62 @@ const fixAccessCommand = define({ }, }); +// --- `varlock keychain take-ownership` -------------------------------------- + +const takeOwnershipCommand = define({ + name: 'take-ownership', + description: 'Recreate generic-password keychain() items so Varlock owns them', + args: { + service: { + type: 'string', + default: 'varlock', + description: 'Keychain service name (default: varlock)', + }, + account: { + type: 'string', + description: 'Keychain account name', + }, + keychain: { + type: 'string', + description: 'Keychain name to search, such as Login or System', + }, + path: { + type: 'string', + description: 'Env file to take ownership for every explicit keychain() ref', + }, + }, + run: async (ctx) => { + assertMacOS(); + await trackCommand('keychain take-ownership', { command: 'keychain take-ownership' }); + + console.warn(ansis.yellow('This recreates generic-password items and can reset existing per-app Keychain ACLs.')); + + if (ctx.values.path) { + const refs = extractKeychainRefsFromFile(path.resolve(ctx.values.path)); + if (refs.length === 0) { + console.log(ansis.gray('No explicit keychain() refs found.')); + return; + } + await takeOwnershipForRefs(refs); + return; + } + + if (!ctx.values.account) { + throw new CliExitError('Missing --account for keychain take-ownership', { + suggestion: 'Use --account "project:profile:KEY" or --path .env.profile', + }); + } + + await takeOwnershipForRefs([ + { + service: ctx.values.service, + account: ctx.values.account, + keychain: ctx.values.keychain, + }, + ]); + }, +}); + // --- `varlock keychain set` ------------------------------------------------- const setCommand = define({ @@ -646,6 +724,7 @@ export const commandSpec = define({ subCommands: { list: listCommand, 'fix-access': fixAccessCommand, + 'take-ownership': takeOwnershipCommand, set: setCommand, import: importCommand, }, @@ -654,6 +733,7 @@ Examples: varlock keychain list varlock keychain fix-access --account "my-project:myenv:API_KEY" varlock keychain fix-access --path .env.myenv + varlock keychain take-ownership --account "my-project:myenv:API_KEY" varlock keychain import .env --profile myenv # migrate .env in place varlock keychain import .env --profile myenv --write-to .env.myenv varlock keychain set API_KEY --profile myenv --write-to .env.myenv diff --git a/packages/varlock/src/lib/local-encrypt/daemon-client.ts b/packages/varlock/src/lib/local-encrypt/daemon-client.ts index b96ab4090..837babdc8 100644 --- a/packages/varlock/src/lib/local-encrypt/daemon-client.ts +++ b/packages/varlock/src/lib/local-encrypt/daemon-client.ts @@ -417,6 +417,21 @@ export class DaemonClient { }); } + async keychainTakeOwnership(opts: { + service: string; + account?: string; + keychain?: string; + }): Promise { + return this.withRetry(async () => { + await this.ensureConnected(); + const result = await this.sendMessage({ + action: 'keychain-take-ownership', + payload: opts, + }, INTERACTIVE_TIMEOUT_MS); + return result as KeychainFixAccessResult; + }); + } + async keychainSet(opts: { service: string; account?: string; diff --git a/packages/varlock/src/lib/local-encrypt/types.ts b/packages/varlock/src/lib/local-encrypt/types.ts index 26b5b8e65..27767bb08 100644 --- a/packages/varlock/src/lib/local-encrypt/types.ts +++ b/packages/varlock/src/lib/local-encrypt/types.ts @@ -25,7 +25,7 @@ export interface BackendInfo { export interface DaemonMessage { id: string; action: 'decrypt' | 'encrypt' | 'prompt-secret' | 'ping' | 'invalidate-session' - | 'keychain-get' | 'keychain-search' | 'keychain-pick' | 'keychain-fix-access' | 'keychain-fix-access-batch' | 'keychain-set'; + | 'keychain-get' | 'keychain-search' | 'keychain-pick' | 'keychain-fix-access' | 'keychain-fix-access-batch' | 'keychain-take-ownership' | 'keychain-set'; payload?: Record; } From 5199bfa3dba838e8722b33eaeb290eb3adeae0e9 Mon Sep 17 00:00:00 2001 From: Benjamin Jesuiter Date: Sun, 28 Jun 2026 11:12:06 +0200 Subject: [PATCH 20/26] test: smoke keychain take-ownership Add a macOS Keychain smoke test for the explicit take-ownership path and include it in the keychain smoke scripts. Validation: bun run lint:fix; bun run --filter varlock build:binary; VARLOCK_RUN_KEYCHAIN_SMOKE=1 bunx vitest run tests/keychain-ownership.test.ts from smoke-tests. --- smoke-tests/package.json | 4 +- .../smoke-test-keychain-ownership/.env.schema | 5 ++ smoke-tests/tests/keychain-ownership.test.ts | 72 +++++++++++++++++++ 3 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 smoke-tests/smoke-test-keychain-ownership/.env.schema create mode 100644 smoke-tests/tests/keychain-ownership.test.ts diff --git a/smoke-tests/package.json b/smoke-tests/package.json index aed6ae6b5..d5afe123a 100644 --- a/smoke-tests/package.json +++ b/smoke-tests/package.json @@ -8,8 +8,8 @@ "test": "vitest run", "test:watch": "vitest", "test:deno-compat": "VARLOCK_RUN_DENO_COMPAT=1 vitest run", - "test:keychain": "VARLOCK_RUN_KEYCHAIN_SMOKE=1 vitest run tests/keychain-fix.test.ts tests/keychain-set.test.ts tests/keychain-import.test.ts", - "test:keychain-deno": "VARLOCK_RUN_DENO_COMPAT=1 VARLOCK_RUN_KEYCHAIN_SMOKE=1 vitest run tests/keychain-fix.test.ts tests/keychain-set.test.ts tests/keychain-import.test.ts" + "test:keychain": "VARLOCK_RUN_KEYCHAIN_SMOKE=1 vitest run tests/keychain-fix.test.ts tests/keychain-ownership.test.ts tests/keychain-set.test.ts tests/keychain-import.test.ts", + "test:keychain-deno": "VARLOCK_RUN_DENO_COMPAT=1 VARLOCK_RUN_KEYCHAIN_SMOKE=1 vitest run tests/keychain-fix.test.ts tests/keychain-ownership.test.ts tests/keychain-set.test.ts tests/keychain-import.test.ts" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/smoke-tests/smoke-test-keychain-ownership/.env.schema b/smoke-tests/smoke-test-keychain-ownership/.env.schema new file mode 100644 index 000000000..8e125e2af --- /dev/null +++ b/smoke-tests/smoke-test-keychain-ownership/.env.schema @@ -0,0 +1,5 @@ +# @defaultSensitive=false +# --- + +# @sensitive +OWNED_API_KEY=keychain(service="varlock-smoke-test-ownership", account="smoke-test-keychain-ownership:local:OWNED_API_KEY") diff --git a/smoke-tests/tests/keychain-ownership.test.ts b/smoke-tests/tests/keychain-ownership.test.ts new file mode 100644 index 000000000..69c2fb1d7 --- /dev/null +++ b/smoke-tests/tests/keychain-ownership.test.ts @@ -0,0 +1,72 @@ +import { + afterEach, beforeEach, describe, expect, test, +} from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { runVarlock } from '../helpers/run-varlock.js'; + +const RUN_KEYCHAIN_SMOKE = process.env.VARLOCK_RUN_KEYCHAIN_SMOKE === '1'; +const SERVICE = 'varlock-smoke-test-ownership'; +const PROFILE = 'local'; +const PROJECT = 'smoke-test-keychain-ownership'; +const FIXTURE_DIR = PROJECT; +const FIXTURE_SCHEMA = '.env.schema'; +const OWNED_SECRETS = { + OWNED_API_KEY: 'owned-api-key-from-keychain', +}; +const OWNED_ACCOUNTS = Object.keys(OWNED_SECRETS).map((key) => `${PROJECT}:${PROFILE}:${key}`); + +function security(args: Array) { + return spawnSync('security', args, { encoding: 'utf-8' }); +} + +function deleteOwnedSecrets() { + for (const account of OWNED_ACCOUNTS) { + security(['delete-generic-password', '-s', SERVICE, '-a', account]); + } +} + +function createKeychainItem(account: string, value: string) { + const result = security([ + 'add-generic-password', + '-U', + '-s', + SERVICE, + '-a', + account, + '-w', + value, + '-T', + '', + ]); + expect(result.status, result.stderr || result.stdout).toBe(0); +} + +beforeEach(() => { + deleteOwnedSecrets(); + for (const [key, value] of Object.entries(OWNED_SECRETS)) { + createKeychainItem(`${PROJECT}:${PROFILE}:${key}`, value); + } +}); + +afterEach(() => { + deleteOwnedSecrets(); +}); + +describe.skipIf(!RUN_KEYCHAIN_SMOKE || process.platform !== 'darwin')('macOS Keychain take-ownership smoke tests', () => { + test('takes ownership for keychain refs and reads the secrets', () => { + const takeOwnershipResult = runVarlock([ + 'keychain', + 'take-ownership', + '--path', + FIXTURE_SCHEMA, + ], { cwd: FIXTURE_DIR }); + expect(takeOwnershipResult.exitCode, takeOwnershipResult.output).toBe(0); + + const loadAfterOwnershipResult = runVarlock(['load', '--format', 'json', '--skip-cache'], { + cwd: FIXTURE_DIR, + }); + expect(loadAfterOwnershipResult.exitCode, loadAfterOwnershipResult.output).toBe(0); + const vars = JSON.parse(loadAfterOwnershipResult.stdout); + expect(vars.OWNED_API_KEY).toBe(OWNED_SECRETS.OWNED_API_KEY); + }); +}); From 8b1c78dd002a54ddc536a4910c8d5990e1ebe806 Mon Sep 17 00:00:00 2001 From: Benjamin Jesuiter Date: Sun, 28 Jun 2026 11:38:58 +0200 Subject: [PATCH 21/26] test: assert keychain fix precondition Before running keychain fix-access, assert that a restricted smoke-test item is not readable yet. Add timeout support to the smoke-test runner so the pre-fix read cannot hang indefinitely on Keychain authorization. Validation: bun run lint:fix; bun run --filter varlock build:binary; VARLOCK_RUN_KEYCHAIN_SMOKE=1 bunx vitest run tests/keychain-fix.test.ts from smoke-tests. --- smoke-tests/helpers/run-varlock.ts | 12 +++++++++--- smoke-tests/tests/keychain-fix.test.ts | 6 ++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/smoke-tests/helpers/run-varlock.ts b/smoke-tests/helpers/run-varlock.ts index c96e6deae..a1775d1a0 100644 --- a/smoke-tests/helpers/run-varlock.ts +++ b/smoke-tests/helpers/run-varlock.ts @@ -24,6 +24,7 @@ export function runVarlock(args: Array, options?: { cwd?: string; env?: Record; input?: string; + timeout?: number; }) { const cwd = options?.cwd ? join(SMOKE_TESTS_DIR, options.cwd) : SMOKE_TESTS_DIR; const env = { ...process.env, ...options?.env }; @@ -32,14 +33,19 @@ export function runVarlock(args: Array, options?: { cwd, env, input: options?.input, + timeout: options?.timeout, encoding: 'utf-8', }); + const stdout = result.stdout ?? ''; + const stderr = result.stderr ?? ''; + const error = result.error ? `\n${result.error.message}` : ''; + return { - stdout: result.stdout ?? '', - stderr: result.stderr ?? '', + stdout, + stderr, exitCode: result.status ?? 1, - output: (result.stdout ?? '') + (result.stderr ?? ''), + output: stdout + stderr + error, }; } diff --git a/smoke-tests/tests/keychain-fix.test.ts b/smoke-tests/tests/keychain-fix.test.ts index a6a6c44f9..9f2e6c853 100644 --- a/smoke-tests/tests/keychain-fix.test.ts +++ b/smoke-tests/tests/keychain-fix.test.ts @@ -55,6 +55,12 @@ afterEach(() => { describe.skipIf(!RUN_KEYCHAIN_SMOKE || process.platform !== 'darwin')('macOS Keychain fix-access smoke tests', () => { test('fixes access for keychain refs in the fixture schema and reads the secrets', () => { + const preFixReadResult = runVarlock(['printenv', 'FIXED_API_KEY', '--skip-cache'], { + cwd: FIXTURE_DIR, + timeout: 5_000, + }); + expect(preFixReadResult.exitCode, preFixReadResult.output).not.toBe(0); + const fixAccessResult = runVarlock([ 'keychain', 'fix-access', From e172a26203608863aed37d4c18cb1e0879be3fe6 Mon Sep 17 00:00:00 2001 From: Benjamin Jesuiter Date: Sun, 28 Jun 2026 11:39:27 +0200 Subject: [PATCH 22/26] Revert "test: assert keychain fix precondition" This reverts commit 8b1c78dd002a54ddc536a4910c8d5990e1ebe806. --- smoke-tests/helpers/run-varlock.ts | 12 +++--------- smoke-tests/tests/keychain-fix.test.ts | 6 ------ 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/smoke-tests/helpers/run-varlock.ts b/smoke-tests/helpers/run-varlock.ts index a1775d1a0..c96e6deae 100644 --- a/smoke-tests/helpers/run-varlock.ts +++ b/smoke-tests/helpers/run-varlock.ts @@ -24,7 +24,6 @@ export function runVarlock(args: Array, options?: { cwd?: string; env?: Record; input?: string; - timeout?: number; }) { const cwd = options?.cwd ? join(SMOKE_TESTS_DIR, options.cwd) : SMOKE_TESTS_DIR; const env = { ...process.env, ...options?.env }; @@ -33,19 +32,14 @@ export function runVarlock(args: Array, options?: { cwd, env, input: options?.input, - timeout: options?.timeout, encoding: 'utf-8', }); - const stdout = result.stdout ?? ''; - const stderr = result.stderr ?? ''; - const error = result.error ? `\n${result.error.message}` : ''; - return { - stdout, - stderr, + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', exitCode: result.status ?? 1, - output: stdout + stderr + error, + output: (result.stdout ?? '') + (result.stderr ?? ''), }; } diff --git a/smoke-tests/tests/keychain-fix.test.ts b/smoke-tests/tests/keychain-fix.test.ts index 9f2e6c853..a6a6c44f9 100644 --- a/smoke-tests/tests/keychain-fix.test.ts +++ b/smoke-tests/tests/keychain-fix.test.ts @@ -55,12 +55,6 @@ afterEach(() => { describe.skipIf(!RUN_KEYCHAIN_SMOKE || process.platform !== 'darwin')('macOS Keychain fix-access smoke tests', () => { test('fixes access for keychain refs in the fixture schema and reads the secrets', () => { - const preFixReadResult = runVarlock(['printenv', 'FIXED_API_KEY', '--skip-cache'], { - cwd: FIXTURE_DIR, - timeout: 5_000, - }); - expect(preFixReadResult.exitCode, preFixReadResult.output).not.toBe(0); - const fixAccessResult = runVarlock([ 'keychain', 'fix-access', From b27043ffbdae151367a141bfb5f7403050b543c2 Mon Sep 17 00:00:00 2001 From: Benjamin Jesuiter Date: Sun, 28 Jun 2026 11:53:09 +0200 Subject: [PATCH 23/26] feat: allow disabling keychain read fallback Add a useFallback option to keychain() reads. It defaults to true to preserve the existing /usr/bin/security fallback, but useFallback=false now requires the Swift daemon to read through Apple's framework APIs directly. Use the option in the keychain fix-access smoke fixture so the test proves fix-access grants VarlockEnclave direct framework access instead of succeeding via the security CLI fallback. Validation: bun run lint:fix; bun run --filter varlock typecheck; bun run --filter varlock build:binary; VARLOCK_RUN_KEYCHAIN_SMOKE=1 bunx vitest run tests/keychain-fix.test.ts from smoke-tests; autoreview --mode local --engine codex. --- .../VarlockEnclave/KeychainManager.swift | 14 +++++++------ .../swift/Sources/VarlockEnclave/main.swift | 4 +++- .../content/docs/plugins/macos-keychain.mdx | 6 ++++++ .../src/lib/local-encrypt/daemon-client.ts | 8 ++++++- .../lib/local-encrypt/keychain-resolver.ts | 21 +++++++++++++++---- .../smoke-test-keychain-fix/.env.schema | 4 ++-- 6 files changed, 43 insertions(+), 14 deletions(-) diff --git a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/KeychainManager.swift b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/KeychainManager.swift index 05ed156a7..f508eb094 100644 --- a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/KeychainManager.swift +++ b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/KeychainManager.swift @@ -212,12 +212,12 @@ final class KeychainManager { /// Fetch the password value for a keychain item. /// At least one of service or account must be provided. /// Throws if not found, access denied, or ambiguous match. - static func getItem(service: String? = nil, account: String? = nil, keychainName: String? = nil) throws -> String { + static func getItem(service: String? = nil, account: String? = nil, keychainName: String? = nil, useFallback: Bool = true) throws -> String { // Try generic password first, then internet password - if let value = try? getItemOfClass(kSecClassGenericPassword, service: service, account: account, keychainName: keychainName) { + if let value = try? getItemOfClass(kSecClassGenericPassword, service: service, account: account, keychainName: keychainName, useFallback: useFallback) { return value } - return try getItemOfClass(kSecClassInternetPassword, service: service, account: account, keychainName: keychainName) + return try getItemOfClass(kSecClassInternetPassword, service: service, account: account, keychainName: keychainName, useFallback: useFallback) } /// Fetch a metadata field (account, label, etc.) from a keychain item instead of the password. @@ -265,7 +265,7 @@ final class KeychainManager { throw KeychainError.itemNotFound } - private static func getItemOfClass(_ itemClass: CFString, service: String?, account: String?, keychainName: String?) throws -> String { + private static func getItemOfClass(_ itemClass: CFString, service: String?, account: String?, keychainName: String?, useFallback: Bool = true) throws -> String { // When account is nil and service is set, check for ambiguity if account == nil, let service = service { var countQuery: [CFString: Any] = [ @@ -328,14 +328,16 @@ final class KeychainManager { case errSecItemNotFound: throw KeychainError.itemNotFound case errSecAuthFailed, errSecInteractionNotAllowed: - if itemClass == kSecClassGenericPassword, + if useFallback, + itemClass == kSecClassGenericPassword, let service = service, let value = try? getGenericPasswordViaSecurityCLI(service: service, account: account, keychainName: keychainName) { return value } throw KeychainError.accessDenied("Authentication failed or interaction not allowed") default: - if itemClass == kSecClassGenericPassword, + if useFallback, + itemClass == kSecClassGenericPassword, let service = service, let value = try? getGenericPasswordViaSecurityCLI(service: service, account: account, keychainName: keychainName) { return value diff --git a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/main.swift b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/main.swift index 5000188b2..759799a9f 100644 --- a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/main.swift +++ b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/main.swift @@ -324,6 +324,7 @@ case "daemon": let account = payload["account"] as? String let keychainName = payload["keychain"] as? String let field = payload["field"] as? String + let useFallback = payload["useFallback"] as? Bool ?? true guard service != nil || account != nil else { return ["error": "At least one of service or account is required"] @@ -356,7 +357,8 @@ case "daemon": let value = try KeychainManager.getItem( service: service, account: account, - keychainName: keychainName + keychainName: keychainName, + useFallback: useFallback ) statusBarMenu?.refresh() return ["result": value] diff --git a/packages/varlock-website/src/content/docs/plugins/macos-keychain.mdx b/packages/varlock-website/src/content/docs/plugins/macos-keychain.mdx index 94e3830c7..90a3fa3f6 100644 --- a/packages/varlock-website/src/content/docs/plugins/macos-keychain.mdx +++ b/packages/varlock-website/src/content/docs/plugins/macos-keychain.mdx @@ -99,6 +99,12 @@ You can also fix every explicit `keychain(...)` ref in an env file: varlock keychain fix-access --path .env.myenv ``` +By default, `keychain()` may fall back to `/usr/bin/security` when Apple's framework APIs cannot read a generic-password item. To require direct framework access, pass `useFallback=false`: + +```env +API_KEY=keychain(service="varlock", account="my-app:myenv:API_KEY", useFallback=false) +``` + `fix-access` preserves existing Keychain item ACLs. If macOS still prompts after fixing access, you can explicitly recreate a generic-password item so VarlockEnclave owns it: ```sh diff --git a/packages/varlock/src/lib/local-encrypt/daemon-client.ts b/packages/varlock/src/lib/local-encrypt/daemon-client.ts index 837babdc8..6f37a6fc2 100644 --- a/packages/varlock/src/lib/local-encrypt/daemon-client.ts +++ b/packages/varlock/src/lib/local-encrypt/daemon-client.ts @@ -340,7 +340,13 @@ export class DaemonClient { }); } - async keychainGet(opts: { service?: string; account?: string; keychain?: string; field?: string }): Promise { + async keychainGet(opts: { + service?: string; + account?: string; + keychain?: string; + field?: string; + useFallback?: boolean; + }): Promise { return this.withRetry(async () => { await this.ensureConnected(); // Password reads may trigger biometric; metadata field reads won't, diff --git a/packages/varlock/src/lib/local-encrypt/keychain-resolver.ts b/packages/varlock/src/lib/local-encrypt/keychain-resolver.ts index 6faa0ec0e..5e8a02e31 100644 --- a/packages/varlock/src/lib/local-encrypt/keychain-resolver.ts +++ b/packages/varlock/src/lib/local-encrypt/keychain-resolver.ts @@ -9,6 +9,7 @@ * keychain(service="com.company.db") * keychain(service="com.company.db", account="admin") * keychain(service="com.company.db", keychain="System") + * keychain(service="com.company.db", useFallback=false) * keychain("com.company.db") — shorthand for service * keychain(prompt) — interactive picker, writes back reference */ @@ -24,6 +25,7 @@ type KeychainResolverState = { account?: string; keychain?: string; field?: string; + useFallback?: boolean; } | { mode: 'prompt'; itemKey: string; @@ -77,29 +79,37 @@ export const KeychainResolver: typeof Resolver = createResolver Date: Sun, 28 Jun 2026 11:55:37 +0200 Subject: [PATCH 24/26] test: reset daemon for keychain smoke tests Kill and clear Varlock daemon state before and after every macOS Keychain smoke test so stale daemon access state cannot affect prompt behavior or test results. Validation: bun run lint:fix; bun run --filter varlock build:binary; VARLOCK_RUN_KEYCHAIN_SMOKE=1 bunx vitest run tests/keychain-fix.test.ts from smoke-tests. --- smoke-tests/helpers/keychain-daemon.ts | 60 ++++++++++++++++++++ smoke-tests/tests/keychain-fix.test.ts | 3 + smoke-tests/tests/keychain-import.test.ts | 3 + smoke-tests/tests/keychain-ownership.test.ts | 3 + smoke-tests/tests/keychain-set.test.ts | 8 ++- 5 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 smoke-tests/helpers/keychain-daemon.ts diff --git a/smoke-tests/helpers/keychain-daemon.ts b/smoke-tests/helpers/keychain-daemon.ts new file mode 100644 index 000000000..a48ee7902 --- /dev/null +++ b/smoke-tests/helpers/keychain-daemon.ts @@ -0,0 +1,60 @@ +import { + existsSync, readFileSync, rmSync, unlinkSync, +} from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +function getUserVarlockDir() { + if (process.env.XDG_CONFIG_HOME) return join(process.env.XDG_CONFIG_HOME, 'varlock'); + const legacyDir = join(homedir(), '.varlock'); + if (existsSync(legacyDir)) return legacyDir; + return join(homedir(), '.config', 'varlock'); +} + +function getDaemonDir() { + return join(getUserVarlockDir(), 'local-encrypt'); +} + +export function resetVarlockDaemon() { + const daemonDir = getDaemonDir(); + const pidPath = join(daemonDir, 'daemon.pid'); + const socketPath = join(daemonDir, 'daemon.sock'); + const stateFiles = [ + pidPath, + join(daemonDir, 'daemon.info'), + socketPath, + `${socketPath}.lock`, + ]; + + try { + const pid = Number.parseInt(readFileSync(pidPath, 'utf-8').trim(), 10); + if (Number.isFinite(pid)) { + try { + process.kill(pid, 'SIGTERM'); + } catch { + // Already gone. + } + } + } catch { + // No daemon pid file. + } + + for (const file of stateFiles) { + try { + unlinkSync(file); + } catch { + // Already gone. + } + } +} + +export function resetVarlockDaemonAfterKeychainSmoke() { + resetVarlockDaemon(); + + // Best-effort cleanup for empty directories left after daemon state removal. + try { + rmSync(getDaemonDir(), { recursive: false }); + } catch { + // Directory may contain keys or other state; leave it alone. + } +} diff --git a/smoke-tests/tests/keychain-fix.test.ts b/smoke-tests/tests/keychain-fix.test.ts index a6a6c44f9..fe8d6c8a3 100644 --- a/smoke-tests/tests/keychain-fix.test.ts +++ b/smoke-tests/tests/keychain-fix.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test, } from 'vitest'; import { spawnSync } from 'node:child_process'; +import { resetVarlockDaemon, resetVarlockDaemonAfterKeychainSmoke } from '../helpers/keychain-daemon.js'; import { runVarlock } from '../helpers/run-varlock.js'; const RUN_KEYCHAIN_SMOKE = process.env.VARLOCK_RUN_KEYCHAIN_SMOKE === '1'; @@ -43,6 +44,7 @@ function createKeychainItem(account: string, value: string) { } beforeEach(() => { + resetVarlockDaemon(); deleteFixedSecrets(); for (const [key, value] of Object.entries(FIXED_SECRETS)) { createKeychainItem(`${PROJECT}:${PROFILE}:${key}`, value); @@ -51,6 +53,7 @@ beforeEach(() => { afterEach(() => { deleteFixedSecrets(); + resetVarlockDaemonAfterKeychainSmoke(); }); describe.skipIf(!RUN_KEYCHAIN_SMOKE || process.platform !== 'darwin')('macOS Keychain fix-access smoke tests', () => { diff --git a/smoke-tests/tests/keychain-import.test.ts b/smoke-tests/tests/keychain-import.test.ts index 5446afab6..81c6db303 100644 --- a/smoke-tests/tests/keychain-import.test.ts +++ b/smoke-tests/tests/keychain-import.test.ts @@ -6,6 +6,7 @@ import { existsSync, readFileSync, unlinkSync, writeFileSync, } from 'node:fs'; import { join } from 'node:path'; +import { resetVarlockDaemon, resetVarlockDaemonAfterKeychainSmoke } from '../helpers/keychain-daemon.js'; import { runVarlock } from '../helpers/run-varlock.js'; const RUN_KEYCHAIN_SMOKE = process.env.VARLOCK_RUN_KEYCHAIN_SMOKE === '1'; @@ -38,6 +39,7 @@ function deleteImportedSecrets() { } beforeEach(() => { + resetVarlockDaemon(); deleteImportedSecrets(); writeFileSync(ENV_PATH, ORIGINAL_ENV); }); @@ -45,6 +47,7 @@ beforeEach(() => { afterEach(() => { writeFileSync(ENV_PATH, ORIGINAL_ENV); deleteImportedSecrets(); + resetVarlockDaemonAfterKeychainSmoke(); }); describe.skipIf(!RUN_KEYCHAIN_SMOKE || process.platform !== 'darwin')('macOS Keychain import smoke tests', () => { diff --git a/smoke-tests/tests/keychain-ownership.test.ts b/smoke-tests/tests/keychain-ownership.test.ts index 69c2fb1d7..76abe51a7 100644 --- a/smoke-tests/tests/keychain-ownership.test.ts +++ b/smoke-tests/tests/keychain-ownership.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test, } from 'vitest'; import { spawnSync } from 'node:child_process'; +import { resetVarlockDaemon, resetVarlockDaemonAfterKeychainSmoke } from '../helpers/keychain-daemon.js'; import { runVarlock } from '../helpers/run-varlock.js'; const RUN_KEYCHAIN_SMOKE = process.env.VARLOCK_RUN_KEYCHAIN_SMOKE === '1'; @@ -42,6 +43,7 @@ function createKeychainItem(account: string, value: string) { } beforeEach(() => { + resetVarlockDaemon(); deleteOwnedSecrets(); for (const [key, value] of Object.entries(OWNED_SECRETS)) { createKeychainItem(`${PROJECT}:${PROFILE}:${key}`, value); @@ -50,6 +52,7 @@ beforeEach(() => { afterEach(() => { deleteOwnedSecrets(); + resetVarlockDaemonAfterKeychainSmoke(); }); describe.skipIf(!RUN_KEYCHAIN_SMOKE || process.platform !== 'darwin')('macOS Keychain take-ownership smoke tests', () => { diff --git a/smoke-tests/tests/keychain-set.test.ts b/smoke-tests/tests/keychain-set.test.ts index 51fc7f760..57abc0ffc 100644 --- a/smoke-tests/tests/keychain-set.test.ts +++ b/smoke-tests/tests/keychain-set.test.ts @@ -1,7 +1,8 @@ import { - afterEach, describe, expect, test, + afterEach, beforeEach, describe, expect, test, } from 'vitest'; import { spawnSync } from 'node:child_process'; +import { resetVarlockDaemon, resetVarlockDaemonAfterKeychainSmoke } from '../helpers/keychain-daemon.js'; import { runVarlock as runVarlockHelper } from '../helpers/run-varlock.js'; const RUN_KEYCHAIN_SMOKE = process.env.VARLOCK_RUN_KEYCHAIN_SMOKE === '1'; @@ -26,10 +27,15 @@ function runVarlock(args: Array) { return runVarlockWithInput(args, ''); } +beforeEach(() => { + resetVarlockDaemon(); +}); + afterEach(() => { for (const account of createdAccounts.splice(0)) { security(['delete-generic-password', '-s', SERVICE, '-a', account]); } + resetVarlockDaemonAfterKeychainSmoke(); }); describe.skipIf(!RUN_KEYCHAIN_SMOKE || process.platform !== 'darwin')('macOS Keychain set smoke tests', () => { From cae1829fe6e429b9f7b30c0aa5e1bfeae7b4d2cb Mon Sep 17 00:00:00 2001 From: Benjamin Jesuiter Date: Sun, 28 Jun 2026 11:57:18 +0200 Subject: [PATCH 25/26] test: use one keychain daemon reset helper Replace the separate post-test keychain daemon cleanup helper with resetVarlockDaemon in both beforeEach and afterEach. The important behavior is killing the daemon and clearing pid/socket state; directory removal is unnecessary. Validation: bun run lint:fix; bun run --filter varlock build:binary; VARLOCK_RUN_KEYCHAIN_SMOKE=1 bunx vitest run tests/keychain-fix.test.ts from smoke-tests. --- smoke-tests/helpers/keychain-daemon.ts | 12 +----------- smoke-tests/tests/keychain-fix.test.ts | 4 ++-- smoke-tests/tests/keychain-import.test.ts | 4 ++-- smoke-tests/tests/keychain-ownership.test.ts | 4 ++-- smoke-tests/tests/keychain-set.test.ts | 4 ++-- 5 files changed, 9 insertions(+), 19 deletions(-) diff --git a/smoke-tests/helpers/keychain-daemon.ts b/smoke-tests/helpers/keychain-daemon.ts index a48ee7902..b62a13ade 100644 --- a/smoke-tests/helpers/keychain-daemon.ts +++ b/smoke-tests/helpers/keychain-daemon.ts @@ -1,5 +1,5 @@ import { - existsSync, readFileSync, rmSync, unlinkSync, + existsSync, readFileSync, unlinkSync, } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; @@ -48,13 +48,3 @@ export function resetVarlockDaemon() { } } -export function resetVarlockDaemonAfterKeychainSmoke() { - resetVarlockDaemon(); - - // Best-effort cleanup for empty directories left after daemon state removal. - try { - rmSync(getDaemonDir(), { recursive: false }); - } catch { - // Directory may contain keys or other state; leave it alone. - } -} diff --git a/smoke-tests/tests/keychain-fix.test.ts b/smoke-tests/tests/keychain-fix.test.ts index fe8d6c8a3..63d3be005 100644 --- a/smoke-tests/tests/keychain-fix.test.ts +++ b/smoke-tests/tests/keychain-fix.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test, } from 'vitest'; import { spawnSync } from 'node:child_process'; -import { resetVarlockDaemon, resetVarlockDaemonAfterKeychainSmoke } from '../helpers/keychain-daemon.js'; +import { resetVarlockDaemon } from '../helpers/keychain-daemon.js'; import { runVarlock } from '../helpers/run-varlock.js'; const RUN_KEYCHAIN_SMOKE = process.env.VARLOCK_RUN_KEYCHAIN_SMOKE === '1'; @@ -53,7 +53,7 @@ beforeEach(() => { afterEach(() => { deleteFixedSecrets(); - resetVarlockDaemonAfterKeychainSmoke(); + resetVarlockDaemon(); }); describe.skipIf(!RUN_KEYCHAIN_SMOKE || process.platform !== 'darwin')('macOS Keychain fix-access smoke tests', () => { diff --git a/smoke-tests/tests/keychain-import.test.ts b/smoke-tests/tests/keychain-import.test.ts index 81c6db303..e0cc6ca9b 100644 --- a/smoke-tests/tests/keychain-import.test.ts +++ b/smoke-tests/tests/keychain-import.test.ts @@ -6,7 +6,7 @@ import { existsSync, readFileSync, unlinkSync, writeFileSync, } from 'node:fs'; import { join } from 'node:path'; -import { resetVarlockDaemon, resetVarlockDaemonAfterKeychainSmoke } from '../helpers/keychain-daemon.js'; +import { resetVarlockDaemon } from '../helpers/keychain-daemon.js'; import { runVarlock } from '../helpers/run-varlock.js'; const RUN_KEYCHAIN_SMOKE = process.env.VARLOCK_RUN_KEYCHAIN_SMOKE === '1'; @@ -47,7 +47,7 @@ beforeEach(() => { afterEach(() => { writeFileSync(ENV_PATH, ORIGINAL_ENV); deleteImportedSecrets(); - resetVarlockDaemonAfterKeychainSmoke(); + resetVarlockDaemon(); }); describe.skipIf(!RUN_KEYCHAIN_SMOKE || process.platform !== 'darwin')('macOS Keychain import smoke tests', () => { diff --git a/smoke-tests/tests/keychain-ownership.test.ts b/smoke-tests/tests/keychain-ownership.test.ts index 76abe51a7..0947e623c 100644 --- a/smoke-tests/tests/keychain-ownership.test.ts +++ b/smoke-tests/tests/keychain-ownership.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test, } from 'vitest'; import { spawnSync } from 'node:child_process'; -import { resetVarlockDaemon, resetVarlockDaemonAfterKeychainSmoke } from '../helpers/keychain-daemon.js'; +import { resetVarlockDaemon } from '../helpers/keychain-daemon.js'; import { runVarlock } from '../helpers/run-varlock.js'; const RUN_KEYCHAIN_SMOKE = process.env.VARLOCK_RUN_KEYCHAIN_SMOKE === '1'; @@ -52,7 +52,7 @@ beforeEach(() => { afterEach(() => { deleteOwnedSecrets(); - resetVarlockDaemonAfterKeychainSmoke(); + resetVarlockDaemon(); }); describe.skipIf(!RUN_KEYCHAIN_SMOKE || process.platform !== 'darwin')('macOS Keychain take-ownership smoke tests', () => { diff --git a/smoke-tests/tests/keychain-set.test.ts b/smoke-tests/tests/keychain-set.test.ts index 57abc0ffc..b363cace1 100644 --- a/smoke-tests/tests/keychain-set.test.ts +++ b/smoke-tests/tests/keychain-set.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test, } from 'vitest'; import { spawnSync } from 'node:child_process'; -import { resetVarlockDaemon, resetVarlockDaemonAfterKeychainSmoke } from '../helpers/keychain-daemon.js'; +import { resetVarlockDaemon } from '../helpers/keychain-daemon.js'; import { runVarlock as runVarlockHelper } from '../helpers/run-varlock.js'; const RUN_KEYCHAIN_SMOKE = process.env.VARLOCK_RUN_KEYCHAIN_SMOKE === '1'; @@ -35,7 +35,7 @@ afterEach(() => { for (const account of createdAccounts.splice(0)) { security(['delete-generic-password', '-s', SERVICE, '-a', account]); } - resetVarlockDaemonAfterKeychainSmoke(); + resetVarlockDaemon(); }); describe.skipIf(!RUN_KEYCHAIN_SMOKE || process.platform !== 'darwin')('macOS Keychain set smoke tests', () => { From bdd1a0a46acf0fcaf5f9ee557bc170549d3ad537 Mon Sep 17 00:00:00 2001 From: Benjamin Jesuiter Date: Mon, 29 Jun 2026 13:15:02 +0200 Subject: [PATCH 26/26] fix: simplify keychain access migration --- .bumpy/keychain-fix-access-batch.md | 2 +- .bumpy/keychain-fix-access-take-ownership.md | 2 +- .../VarlockEnclave/KeychainManager.swift | 160 +----------- .../swift/Sources/VarlockEnclave/main.swift | 93 ++----- .../content/docs/plugins/macos-keychain.mdx | 34 ++- .../src/cli/commands/keychain.command.ts | 239 +++++++++++++----- .../src/lib/local-encrypt/daemon-client.ts | 59 ++--- .../varlock/src/lib/local-encrypt/types.ts | 17 +- smoke-tests/tests/keychain-fix.test.ts | 109 +++++++- smoke-tests/tests/keychain-ownership.test.ts | 53 ++-- 10 files changed, 372 insertions(+), 396 deletions(-) diff --git a/.bumpy/keychain-fix-access-batch.md b/.bumpy/keychain-fix-access-batch.md index 9b91d7bf1..caf1fb736 100644 --- a/.bumpy/keychain-fix-access-batch.md +++ b/.bumpy/keychain-fix-access-batch.md @@ -2,4 +2,4 @@ varlock: patch --- -Reduce macOS Keychain fix-access password prompts when fixing multiple refs. +Remove the unsupported macOS Keychain ACL mutation path from fix-access. diff --git a/.bumpy/keychain-fix-access-take-ownership.md b/.bumpy/keychain-fix-access-take-ownership.md index ea6fcf15e..89f125b43 100644 --- a/.bumpy/keychain-fix-access-take-ownership.md +++ b/.bumpy/keychain-fix-access-take-ownership.md @@ -2,4 +2,4 @@ varlock: patch --- -Add an explicit macOS Keychain take-ownership command for generic-password items that need an ownership rewrite. +Change macOS Keychain fix-access to use the native read prompt with Always Allow guidance, and replace take-ownership with a non-destructive cloneToOwned command. diff --git a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/KeychainManager.swift b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/KeychainManager.swift index f508eb094..0830d0364 100644 --- a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/KeychainManager.swift +++ b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/KeychainManager.swift @@ -486,47 +486,6 @@ final class KeychainManager { } } - // MARK: - ACL Management - - /// Attempt to add the given application path to the ACL of a keychain item. - /// This uses the legacy Keychain API which supports per-application access control. - /// Returns true if the ACL was modified, false if no change was needed. - /// macOS will prompt the user for authentication to authorize the change. - static func unlockForAccessFix(keychainName: String? = nil) throws { - let keychain: SecKeychain? - if let keychainName = keychainName { - keychain = resolveKeychain(named: keychainName) - if keychain == nil { - throw KeychainError.keychainNotFound(keychainName) - } - } else { - let (status, defaultKeychain) = LegacyKeychain.keychainCopyDefault() - if status != errSecSuccess { - throw KeychainError.unhandledError(status) - } - keychain = defaultKeychain - } - - guard let keychain else { - throw KeychainError.itemNotFound - } - - let (statusResult, keychainStatus) = LegacyKeychain.keychainGetStatus(keychain) - if statusResult != errSecSuccess { - throw KeychainError.unhandledError(statusResult) - } - - let unlockedStatus = SecKeychainStatus(1) - if (keychainStatus & unlockedStatus) != 0 { - return - } - - let unlockStatus = LegacyKeychain.keychainUnlock(keychain) - if unlockStatus != errSecSuccess { - throw KeychainError.unhandledError(unlockStatus) - } - } - static func addToACL(service: String, account: String? = nil, keychainName: String? = nil, appPath: String) throws -> Bool { let itemRef = try getItemRef(service: service, account: account, keychainName: keychainName) @@ -586,91 +545,7 @@ final class KeychainManager { return modified } - /// Make VarlockEnclave the owner of an existing generic-password keychain item by reading the - /// current value, deleting the original item, and recreating it through our - /// normal write path. This intentionally resets item ACLs, so callers should - /// use it only when explicitly requested instead of the non-destructive ACL flow. - static func takeOwnership(service: String, account: String? = nil, keychainName: String? = nil) throws -> Bool { - let (resolvedAccount, value) = try getGenericPasswordForOwnership(service: service, account: account, keychainName: keychainName) - let tempService = "\(service).varlock-ownership-transfer.\(UUID().uuidString)" - let tempAccount = "\(resolvedAccount).varlock-ownership-transfer.\(UUID().uuidString)" - - do { - _ = try setGenericPassword(service: tempService, account: tempAccount, value: value, update: false, keychainName: keychainName) - let (_, verifiedValue) = try getGenericPasswordForOwnership(service: tempService, account: tempAccount, keychainName: keychainName) - guard verifiedValue == value else { - throw KeychainError.unexpectedData - } - } catch { - try? deleteGenericPassword(service: tempService, account: tempAccount, keychainName: keychainName) - throw KeychainError.ownershipTransferFailed(recreateError: error, restoreError: nil) - } - - try deleteGenericPassword(service: service, account: resolvedAccount, keychainName: keychainName) - - do { - try renameGenericPassword(service: tempService, account: tempAccount, newService: service, newAccount: resolvedAccount, keychainName: keychainName) - let (_, verifiedValue) = try getGenericPasswordForOwnership(service: service, account: resolvedAccount, keychainName: keychainName) - guard verifiedValue == value else { - throw KeychainError.unexpectedData - } - } catch { - do { - _ = try setGenericPassword(service: service, account: resolvedAccount, value: value, update: false, keychainName: keychainName) - try? deleteGenericPassword(service: tempService, account: tempAccount, keychainName: keychainName) - } catch let restoreError { - throw KeychainError.ownershipTransferFailed(recreateError: error, restoreError: restoreError) - } - throw KeychainError.ownershipTransferFailed(recreateError: error, restoreError: nil) - } - - return true - } - - private static func getGenericPasswordForOwnership(service: String, account: String?, keychainName: String?) throws -> (String, String) { - var query: [CFString: Any] = [ - kSecClass: kSecClassGenericPassword, - kSecAttrService: service, - kSecReturnAttributes: true, - kSecReturnData: true, - kSecMatchLimit: kSecMatchLimitAll, - ] - - if let account = account { - query[kSecAttrAccount] = account - } - if let keychainName = keychainName { - guard let keychainRef = resolveKeychain(named: keychainName) else { - throw KeychainError.keychainNotFound(keychainName) - } - query[kSecMatchSearchList] = [keychainRef] - } - - var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) - switch status { - case errSecSuccess: - guard let items = result as? [[String: Any]], let item = items.first else { - throw KeychainError.itemNotFound - } - if account == nil, items.count > 1 { - let accounts = items.compactMap { $0[kSecAttrAccount as String] as? String } - throw KeychainError.ambiguousMatch(service: service, accounts: accounts) - } - guard let data = item[kSecValueData as String] as? Data, let value = String(data: data, encoding: .utf8) else { - throw KeychainError.unexpectedData - } - return ((item[kSecAttrAccount as String] as? String) ?? "", value) - case errSecItemNotFound: - throw KeychainError.itemNotFound - case errSecAuthFailed, errSecInteractionNotAllowed: - throw KeychainError.accessDenied("Authentication failed or interaction not allowed") - default: - throw KeychainError.unhandledError(status) - } - } - - private static func deleteGenericPassword(service: String, account: String, keychainName: String?) throws { + static func deleteGenericPassword(service: String, account: String, keychainName: String? = nil) throws { var query: [CFString: Any] = [ kSecClass: kSecClassGenericPassword, kSecAttrService: service, @@ -695,39 +570,6 @@ final class KeychainManager { } } - private static func renameGenericPassword(service: String, account: String, newService: String, newAccount: String, keychainName: String?) throws { - var query: [CFString: Any] = [ - kSecClass: kSecClassGenericPassword, - kSecAttrService: service, - kSecAttrAccount: account, - ] - - if let keychainName = keychainName { - guard let keychainRef = resolveKeychain(named: keychainName) else { - throw KeychainError.keychainNotFound(keychainName) - } - query[kSecMatchSearchList] = [keychainRef] - } - - let attrs: [CFString: Any] = [ - kSecAttrService: newService, - kSecAttrAccount: newAccount, - kSecAttrLabel: newAccount.isEmpty ? newService : newAccount, - ] - - let status = SecItemUpdate(query as CFDictionary, attrs as CFDictionary) - switch status { - case errSecSuccess: - return - case errSecItemNotFound: - throw KeychainError.itemNotFound - case errSecDuplicateItem: - throw KeychainError.duplicateItem - default: - throw KeychainError.unhandledError(status) - } - } - // MARK: - Private Helpers /// Get a SecKeychainItem reference for ACL operations. diff --git a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/main.swift b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/main.swift index 759799a9f..bca959af7 100644 --- a/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/main.swift +++ b/packages/encryption-binary-swift/swift/Sources/VarlockEnclave/main.swift @@ -383,111 +383,50 @@ case "daemon": } return ["result": selected] - case "keychain-fix-access": + case "keychain-set": guard let payload = message["payload"] as? [String: Any] else { return ["error": "Missing payload"] } guard let service = payload["service"] as? String else { return ["error": "Missing service"] } - let account = payload["account"] as? String - let keychainName = payload["keychain"] as? String - let appPath = Bundle.main.executablePath ?? ProcessInfo.processInfo.arguments[0] - - do { - try KeychainManager.unlockForAccessFix(keychainName: keychainName) - let modified = try KeychainManager.addToACL( - service: service, - account: account, - keychainName: keychainName, - appPath: appPath - ) - return ["result": ["modified": modified]] - } catch { - return keychainErrorResponse(error) - } - - case "keychain-take-ownership": - guard let payload = message["payload"] as? [String: Any] else { - return ["error": "Missing payload"] - } - guard let service = payload["service"] as? String else { - return ["error": "Missing service"] + guard let value = payload["value"] as? String else { + return ["error": "Missing value"] } - let account = payload["account"] as? String - let keychainName = payload["keychain"] as? String + let account = payload["account"] as? String ?? "" + let update = payload["update"] as? Bool ?? false do { - try KeychainManager.unlockForAccessFix(keychainName: keychainName) - let modified = try KeychainManager.takeOwnership( + let updated = try KeychainManager.setGenericPassword( service: service, account: account, - keychainName: keychainName + value: value, + update: update ) - return ["result": ["modified": modified]] + return ["result": ["updated": updated]] } catch { return keychainErrorResponse(error) } - case "keychain-fix-access-batch": - guard let payload = message["payload"] as? [String: Any] else { - return ["error": "Missing payload"] - } - guard let items = payload["items"] as? [[String: Any]] else { - return ["error": "Missing items"] - } - let appPath = Bundle.main.executablePath ?? ProcessInfo.processInfo.arguments[0] - - var results: [[String: Any]] = [] - for item in items { - guard let service = item["service"] as? String else { - results.append(["modified": false, "error": "Missing service"]) - continue - } - let account = item["account"] as? String - let keychainName = item["keychain"] as? String - do { - try KeychainManager.unlockForAccessFix(keychainName: keychainName) - let modified = try KeychainManager.addToACL( - service: service, - account: account, - keychainName: keychainName, - appPath: appPath - ) - var result: [String: Any] = ["service": service, "modified": modified] - if let account = account { result["account"] = account } - if let keychainName = keychainName { result["keychain"] = keychainName } - results.append(result) - } catch { - var result: [String: Any] = ["service": service, "modified": false, "error": keychainErrorMessage(error)] - if let account = account { result["account"] = account } - if let keychainName = keychainName { result["keychain"] = keychainName } - results.append(result) - } - } - return ["result": ["results": results]] - - case "keychain-set": + case "keychain-delete": guard let payload = message["payload"] as? [String: Any] else { return ["error": "Missing payload"] } guard let service = payload["service"] as? String else { return ["error": "Missing service"] } - guard let value = payload["value"] as? String else { - return ["error": "Missing value"] + guard let account = payload["account"] as? String else { + return ["error": "Missing account"] } - let account = payload["account"] as? String ?? "" - let update = payload["update"] as? Bool ?? false + let keychainName = payload["keychain"] as? String do { - let updated = try KeychainManager.setGenericPassword( + try KeychainManager.deleteGenericPassword( service: service, account: account, - value: value, - update: update + keychainName: keychainName ) - return ["result": ["updated": updated]] + return ["result": ["deleted": true]] } catch { return keychainErrorResponse(error) } diff --git a/packages/varlock-website/src/content/docs/plugins/macos-keychain.mdx b/packages/varlock-website/src/content/docs/plugins/macos-keychain.mdx index 90a3fa3f6..e75752c5b 100644 --- a/packages/varlock-website/src/content/docs/plugins/macos-keychain.mdx +++ b/packages/varlock-website/src/content/docs/plugins/macos-keychain.mdx @@ -79,9 +79,17 @@ cat secret.txt | varlock keychain set PRIVATE_KEY --profile myenv --write-to .en By default, `set` refuses to overwrite an existing Keychain item or env ref. Pass `--force` to replace both. +To delete a generic-password item through Varlock's macOS helper: + +```sh +varlock keychain delete --account "my-app:myenv:API_KEY" +``` + +Pass `--service` if the item does not use the default `varlock` service. + ## Access management -If VarlockEnclave cannot read an existing Keychain item, grant access without using `/usr/bin/security` directly: +If VarlockEnclave cannot read an existing Keychain item, ask the helper to read it once and approve the macOS prompt: ```sh varlock keychain fix-access --account "my-app:myenv:API_KEY" @@ -99,19 +107,37 @@ You can also fix every explicit `keychain(...)` ref in an env file: varlock keychain fix-access --path .env.myenv ``` +macOS may show one Keychain prompt per secret. Choose **Always Allow** for each prompt. Choosing **Allow Once** lets that single read continue, but future reads will still prompt. + By default, `keychain()` may fall back to `/usr/bin/security` when Apple's framework APIs cannot read a generic-password item. To require direct framework access, pass `useFallback=false`: ```env API_KEY=keychain(service="varlock", account="my-app:myenv:API_KEY", useFallback=false) ``` -`fix-access` preserves existing Keychain item ACLs. If macOS still prompts after fixing access, you can explicitly recreate a generic-password item so VarlockEnclave owns it: +If you want a new Varlock-owned copy instead of relying on the original item's access rules, clone one existing secret into a new item: + +```sh +varlock keychain cloneToOwned \ + --service "com.company.api" \ + --account "admin" \ + --target-account "my-app:myenv:API_KEY" +``` + +`cloneToOwned` reads the source secret and creates a new destination item through Varlock. It does not delete or modify the original item. + +To also write the new ref to an env file, provide the env key explicitly: ```sh -varlock keychain take-ownership --account "my-app:myenv:API_KEY" +varlock keychain cloneToOwned \ + --service "com.company.api" \ + --account "admin" \ + --target-account "my-app:myenv:API_KEY" \ + --write-to .env.myenv \ + --key API_KEY ``` -This can reset per-app Keychain ACLs on that item, so use it only when you want that ownership rewrite. +If the env key already exists, pass `--force` to replace it. ## List Keychain items diff --git a/packages/varlock/src/cli/commands/keychain.command.ts b/packages/varlock/src/cli/commands/keychain.command.ts index 76594165b..350446769 100644 --- a/packages/varlock/src/cli/commands/keychain.command.ts +++ b/packages/varlock/src/cli/commands/keychain.command.ts @@ -207,52 +207,23 @@ async function listKeychainItems(query?: string, keychain?: string) { async function fixAccessForRefs(refs: Array) { const client = getDaemonClient(); - let updated = 0; - let unchanged = 0; + let read = 0; let failed = 0; - try { - const result = await client.keychainFixAccessBatch(refs); - for (let i = 0; i < refs.length; i++) { - const ref = refs[i]!; - const item = result.results[i]; - if (!item) { - failed++; - const label = ref.key ? `${ref.key}: ` : ''; - console.error(ansis.red(` ${label}No result returned`)); - continue; - } - if (item.error) { - failed++; - const label = ref.key ? `${ref.key}: ` : ''; - console.error(ansis.red(` ${label}${item.error}`)); - continue; - } - if (item.modified) updated++; - else unchanged++; - const label = ref.key ? `${ref.key} ` : ''; - console.log(` ${label}${item.modified ? 'updated' : 'already allowed'}`); - } - } catch (err) { - failed = refs.length; - console.error(ansis.red(` ${err instanceof Error ? err.message : err}`)); - } - - console.log(`\nChecked ${refs.length} item${refs.length === 1 ? '' : 's'}: ${updated} updated, ${unchanged} already allowed, ${failed} failed.`); - if (failed > 0) process.exitCode = 1; -} - -async function takeOwnershipForRefs(refs: Array) { - const client = getDaemonClient(); - let updated = 0; - let failed = 0; + console.log(ansis.yellow('macOS may ask for Keychain access once per secret. Choose “Always Allow” for each prompt.')); + console.log(ansis.gray('Choosing “Allow Once” will not make future reads prompt-free.')); for (const ref of refs) { try { - const result = await client.keychainTakeOwnership(ref); - if (result.modified) updated++; + await client.keychainGet({ + service: ref.service, + account: ref.account, + keychain: ref.keychain, + useFallback: false, + }); + read++; const label = ref.key ? `${ref.key} ` : ''; - console.log(` ${label}${result.modified ? 'ownership taken' : 'already owned'}`); + console.log(` ${label}read successfully`); } catch (err) { failed++; const label = ref.key ? `${ref.key}: ` : ''; @@ -260,7 +231,7 @@ async function takeOwnershipForRefs(refs: Array) { } } - console.log(`\nChecked ${refs.length} item${refs.length === 1 ? '' : 's'}: ${updated} updated, ${failed} failed.`); + console.log(`\nChecked ${refs.length} item${refs.length === 1 ? '' : 's'}: ${read} read successfully, ${failed} failed.`); if (failed > 0) process.exitCode = 1; } @@ -300,6 +271,55 @@ async function assertKeychainItemAbsent( throw new CliExitError(`Refusing to overwrite existing Keychain item for ${label}`, { suggestion }); } +async function cloneKeychainSecretToOwned(opts: { + source: KeychainRef; + target: { service: string; account: string }; + write?: { file: string; key: string }; + force: boolean; +}) { + const client = getDaemonClient(); + if (!opts.force) { + await assertKeychainItemAbsent( + client, + opts.target, + opts.target.account, + 'Re-run with --force to overwrite the destination Keychain item.', + ); + + if (opts.write && getExistingEnvKeys(opts.write.file).has(opts.write.key)) { + throw new CliExitError(`Refusing to overwrite ${opts.write.key} in ${opts.write.file}`, { + suggestion: 'Re-run with --force to overwrite the existing env ref and destination Keychain item.', + }); + } + } + + console.log(ansis.yellow('macOS may ask for Keychain access to read the source secret. Choose “Always Allow” if you want future source reads to be prompt-free.')); + const value = await client.keychainGet({ + service: opts.source.service, + account: opts.source.account, + keychain: opts.source.keychain, + useFallback: false, + }); + + await client.keychainSet({ + service: opts.target.service, + account: opts.target.account, + value, + update: opts.force, + }); + + const ref = formatKeychainRef(opts.target); + if (opts.write) { + writeEnvRef(opts.write.file, opts.write.key, ref, opts.force); + } + + console.log(`Cloned ${formatKeychainRef(opts.source)} to ${ref}.`); + console.log(ansis.gray('The original Keychain item was left unchanged.')); + if (opts.write) { + console.log(`Wrote ${opts.write.key} ref to ${opts.write.file}.`); + } +} + async function setKeychainSecret(opts: { key?: string; service: string; @@ -357,6 +377,16 @@ async function setKeychainSecret(opts: { } } +async function deleteKeychainSecret(opts: { + service: string; + account: string; + keychain?: string; +}) { + const client = getDaemonClient(); + await client.keychainDelete(opts); + console.log(`Deleted Keychain item ${formatKeychainRef(opts)}.`); +} + async function importPlaintextEnv(opts: { from: string; /** When omitted, refs are written back into the source file in place (replacing plaintext). */ @@ -544,59 +574,87 @@ const fixAccessCommand = define({ }, }); -// --- `varlock keychain take-ownership` -------------------------------------- +// --- `varlock keychain cloneToOwned` ---------------------------------------- -const takeOwnershipCommand = define({ - name: 'take-ownership', - description: 'Recreate generic-password keychain() items so Varlock owns them', +const cloneToOwnedCommand = define({ + name: 'cloneToOwned', + description: 'Clone one existing Keychain secret into a new Varlock-owned item', args: { service: { type: 'string', default: 'varlock', - description: 'Keychain service name (default: varlock)', + description: 'Source Keychain service name (default: varlock)', }, account: { type: 'string', - description: 'Keychain account name', + description: 'Source Keychain account name', }, keychain: { type: 'string', - description: 'Keychain name to search, such as Login or System', + description: 'Source keychain name to search, such as Login or System', }, - path: { + 'target-service': { + type: 'string', + default: 'varlock', + description: 'Destination Keychain service name (default: varlock)', + }, + 'target-account': { + type: 'string', + description: 'Destination Keychain account name', + }, + 'write-to': { type: 'string', - description: 'Env file to take ownership for every explicit keychain() ref', + description: 'Env file to write the destination keychain() ref to', + }, + key: { + type: 'string', + description: 'Env var key to write when using --write-to', + }, + force: { + type: 'boolean', + description: 'Overwrite an existing destination Keychain item', }, }, run: async (ctx) => { assertMacOS(); - await trackCommand('keychain take-ownership', { command: 'keychain take-ownership' }); - - console.warn(ansis.yellow('This recreates generic-password items and can reset existing per-app Keychain ACLs.')); - - if (ctx.values.path) { - const refs = extractKeychainRefsFromFile(path.resolve(ctx.values.path)); - if (refs.length === 0) { - console.log(ansis.gray('No explicit keychain() refs found.')); - return; - } - await takeOwnershipForRefs(refs); - return; - } + await trackCommand('keychain cloneToOwned', { command: 'keychain cloneToOwned' }); if (!ctx.values.account) { - throw new CliExitError('Missing --account for keychain take-ownership', { - suggestion: 'Use --account "project:profile:KEY" or --path .env.profile', + throw new CliExitError('Missing --account for keychain cloneToOwned', { + suggestion: 'Use --account for the source item you want to clone.', + }); + } + if (!ctx.values['target-account']) { + throw new CliExitError('Missing --target-account for keychain cloneToOwned', { + suggestion: 'Use --target-account "project:profile:KEY" for the new Varlock-owned item.', + }); + } + if (ctx.values['write-to'] && !ctx.values.key) { + throw new CliExitError('Missing --key for keychain cloneToOwned --write-to', { + suggestion: 'Use --key API_KEY so Varlock knows which env var to write.', + }); + } + if (ctx.values.key && !ctx.values['write-to']) { + throw new CliExitError('Missing --write-to for keychain cloneToOwned --key', { + suggestion: 'Use --write-to .env.local, or omit --key.', }); } - await takeOwnershipForRefs([ - { + await cloneKeychainSecretToOwned({ + source: { service: ctx.values.service, account: ctx.values.account, keychain: ctx.values.keychain, }, - ]); + target: { + service: ctx.values['target-service'], + account: ctx.values['target-account'], + }, + write: ctx.values['write-to'] && ctx.values.key + ? { file: path.resolve(ctx.values['write-to']), key: ctx.values.key } + : undefined, + force: Boolean(ctx.values.force), + }); }, }); @@ -661,6 +719,44 @@ const setCommand = define({ }, }); +// --- `varlock keychain delete` ---------------------------------------------- + +const deleteCommand = define({ + name: 'delete', + description: 'Delete a macOS Keychain generic-password item', + args: { + service: { + type: 'string', + default: 'varlock', + description: 'Keychain service name (default: varlock)', + }, + account: { + type: 'string', + description: 'Keychain account name', + }, + keychain: { + type: 'string', + description: 'Keychain name to search, such as Login or System', + }, + }, + run: async (ctx) => { + assertMacOS(); + await trackCommand('keychain delete', { command: 'keychain delete' }); + + if (!ctx.values.account) { + throw new CliExitError('Missing --account for keychain delete', { + suggestion: 'Use --account "project:profile:KEY".', + }); + } + + await deleteKeychainSecret({ + service: ctx.values.service, + account: ctx.values.account, + keychain: ctx.values.keychain, + }); + }, +}); + // --- `varlock keychain import` ---------------------------------------------- const importCommand = define({ @@ -724,8 +820,9 @@ export const commandSpec = define({ subCommands: { list: listCommand, 'fix-access': fixAccessCommand, - 'take-ownership': takeOwnershipCommand, + cloneToOwned: cloneToOwnedCommand, set: setCommand, + delete: deleteCommand, import: importCommand, }, examples: ` @@ -733,7 +830,9 @@ Examples: varlock keychain list varlock keychain fix-access --account "my-project:myenv:API_KEY" varlock keychain fix-access --path .env.myenv - varlock keychain take-ownership --account "my-project:myenv:API_KEY" + varlock keychain cloneToOwned --service "old-service" --account "API_KEY" --target-account "my-project:myenv:API_KEY" + varlock keychain cloneToOwned --service "old-service" --account "API_KEY" --target-account "my-project:myenv:API_KEY" --write-to .env.myenv --key API_KEY + varlock keychain delete --account "my-project:myenv:API_KEY" varlock keychain import .env --profile myenv # migrate .env in place varlock keychain import .env --profile myenv --write-to .env.myenv varlock keychain set API_KEY --profile myenv --write-to .env.myenv diff --git a/packages/varlock/src/lib/local-encrypt/daemon-client.ts b/packages/varlock/src/lib/local-encrypt/daemon-client.ts index 6f37a6fc2..e1e936ed0 100644 --- a/packages/varlock/src/lib/local-encrypt/daemon-client.ts +++ b/packages/varlock/src/lib/local-encrypt/daemon-client.ts @@ -24,7 +24,10 @@ import { getUserVarlockDir } from '../user-config-dir'; import { resolveNativeBinary } from './binary-resolver'; import { isWSL } from './wsl-detect'; import type { - KeychainFixAccessBatchResult, KeychainFixAccessResult, KeychainItemMeta, KeychainItemRef, KeychainSetResult, + KeychainDeleteResult, + KeychainItemMeta, + KeychainItemRef, + KeychainSetResult, } from './types'; /** Timeout for daemon IPC messages that don't involve user interaction */ @@ -393,64 +396,34 @@ export class DaemonClient { }); } - async keychainFixAccess(opts: { + async keychainSet(opts: { service: string; account?: string; - keychain?: string; - }): Promise { + value: string; + update?: boolean; + }): Promise { return this.withRetry(async () => { await this.ensureConnected(); const result = await this.sendMessage({ - action: 'keychain-fix-access', + action: 'keychain-set', payload: opts, - }, INTERACTIVE_TIMEOUT_MS); - return result as KeychainFixAccessResult; + }, BIOMETRIC_TIMEOUT_MS); + return result as KeychainSetResult; }); } - async keychainFixAccessBatch(opts: Array<{ + async keychainDelete(opts: { service: string; - account?: string; + account: string; keychain?: string; - }>): Promise { + }): Promise { return this.withRetry(async () => { await this.ensureConnected(); const result = await this.sendMessage({ - action: 'keychain-fix-access-batch', - payload: { items: opts }, - }, INTERACTIVE_TIMEOUT_MS); - return result as KeychainFixAccessBatchResult; - }); - } - - async keychainTakeOwnership(opts: { - service: string; - account?: string; - keychain?: string; - }): Promise { - return this.withRetry(async () => { - await this.ensureConnected(); - const result = await this.sendMessage({ - action: 'keychain-take-ownership', - payload: opts, - }, INTERACTIVE_TIMEOUT_MS); - return result as KeychainFixAccessResult; - }); - } - - async keychainSet(opts: { - service: string; - account?: string; - value: string; - update?: boolean; - }): Promise { - return this.withRetry(async () => { - await this.ensureConnected(); - const result = await this.sendMessage({ - action: 'keychain-set', + action: 'keychain-delete', payload: opts, }, BIOMETRIC_TIMEOUT_MS); - return result as KeychainSetResult; + return result as KeychainDeleteResult; }); } diff --git a/packages/varlock/src/lib/local-encrypt/types.ts b/packages/varlock/src/lib/local-encrypt/types.ts index 27767bb08..981f32e8a 100644 --- a/packages/varlock/src/lib/local-encrypt/types.ts +++ b/packages/varlock/src/lib/local-encrypt/types.ts @@ -25,7 +25,7 @@ export interface BackendInfo { export interface DaemonMessage { id: string; action: 'decrypt' | 'encrypt' | 'prompt-secret' | 'ping' | 'invalidate-session' - | 'keychain-get' | 'keychain-search' | 'keychain-pick' | 'keychain-fix-access' | 'keychain-fix-access-batch' | 'keychain-take-ownership' | 'keychain-set'; + | 'keychain-get' | 'keychain-search' | 'keychain-pick' | 'keychain-set' | 'keychain-delete'; payload?: Record; } @@ -54,21 +54,16 @@ export interface KeychainItemRef { label?: string; } -/** Result from adding VarlockEnclave to a keychain item's access list */ -export interface KeychainFixAccessResult { - modified: boolean; -} - -/** Result from adding VarlockEnclave to multiple keychain item access lists */ -export interface KeychainFixAccessBatchResult { - results: Array; -} - /** Result from creating or updating a keychain item */ export interface KeychainSetResult { updated: boolean; } +/** Result from deleting a keychain item */ +export interface KeychainDeleteResult { + deleted: boolean; +} + /** Result from the status command of a native binary */ export interface NativeStatusResult { backend: string; diff --git a/smoke-tests/tests/keychain-fix.test.ts b/smoke-tests/tests/keychain-fix.test.ts index 63d3be005..658d25996 100644 --- a/smoke-tests/tests/keychain-fix.test.ts +++ b/smoke-tests/tests/keychain-fix.test.ts @@ -2,39 +2,105 @@ import { afterEach, beforeEach, describe, expect, test, } from 'vitest'; import { spawnSync } from 'node:child_process'; +import { unlinkSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; import { resetVarlockDaemon } from '../helpers/keychain-daemon.js'; import { runVarlock } from '../helpers/run-varlock.js'; const RUN_KEYCHAIN_SMOKE = process.env.VARLOCK_RUN_KEYCHAIN_SMOKE === '1'; -const SERVICE = 'varlock-smoke-test-fix'; +const SERVICE_PREFIX = 'varlock-smoke-test-fix'; const PROFILE = 'local'; const PROJECT = 'smoke-test-keychain-fix'; const FIXTURE_DIR = PROJECT; -const FIXTURE_SCHEMA = '.env.schema'; +const FIXTURE_SCHEMA = '.env.keychainfix.schema'; +const FIXTURE_SCHEMA_PATH = join(import.meta.dirname, '..', FIXTURE_DIR, FIXTURE_SCHEMA); const FIXED_SECRETS = { FIXED_API_KEY: 'fixed-api-key-from-keychain', FIXED_DATABASE_URL: 'postgres://fixed-user:fixed-pass@localhost:5432/fixed-db', }; const FIXED_ACCOUNTS = Object.keys(FIXED_SECRETS).map((key) => `${PROJECT}:${PROFILE}:${key}`); +let cleanupWithVarlockDaemon = false; +let service = SERVICE_PREFIX; + +function phase(message: string) { + console.error(`[keychain-fix smoke] PHASE: ${message}`); +} + +function uniqueService() { + const suffix = `${Date.now().toString(36)}-${process.pid}-${Math.random().toString(36).slice(2)}`; + return `${SERVICE_PREFIX}-${suffix}`; +} function security(args: Array) { return spawnSync('security', args, { encoding: 'utf-8' }); } -function deleteFixedSecrets() { +function writeGeneratedSchema() { + const apiAccount = `${PROJECT}:${PROFILE}:FIXED_API_KEY`; + const databaseAccount = `${PROJECT}:${PROFILE}:FIXED_DATABASE_URL`; + writeFileSync(FIXTURE_SCHEMA_PATH, [ + '# @defaultSensitive=false', + '# ---', + '', + '# @sensitive', + `FIXED_API_KEY=keychain(service="${service}", account="${apiAccount}", useFallback=false)`, + '', + '# @sensitive', + `FIXED_DATABASE_URL=keychain(service="${service}", account="${databaseAccount}", useFallback=false)`, + '', + ].join('\n')); +} + +function removeGeneratedSchema() { + try { + unlinkSync(FIXTURE_SCHEMA_PATH); + } catch { + // Already gone. + } +} + +function deleteFixedSecretsWithSecurity(reason: string) { + phase(`${reason}: delete fixture items for service ${service} via /usr/bin/security`); + for (const account of FIXED_ACCOUNTS) { + security(['delete-generic-password', '-s', service, '-a', account]); + } +} + +function deleteFixedSecretsWithVarlockDaemon(reason: string) { + phase(`${reason}: delete fixture items for service ${service} via Varlock daemon`); + const failures: Array = []; for (const account of FIXED_ACCOUNTS) { - security(['delete-generic-password', '-s', SERVICE, '-a', account]); + const result = runVarlock([ + 'keychain', + 'delete', + '--service', + service, + '--account', + account, + ], { cwd: FIXTURE_DIR }); + if (result.exitCode !== 0) failures.push(result.output); + } + if (failures.length > 0) { + deleteFixedSecretsWithSecurity(`${reason} fallback after Varlock daemon cleanup failed`); } + expect(failures.join('\n')).toBe(''); } -function createKeychainItem(account: string, value: string) { +function createKeychainItem(key: string, account: string, value: string) { + phase(`beforeEach: create restricted Keychain item for ${key} via /usr/bin/security`); const result = security([ 'add-generic-password', '-U', '-s', - SERVICE, + service, '-a', account, + '-l', + `VARLOCK SMOKE keychain-fix beforeEach target: ${key}`, + '-j', + 'Created by smoke-tests/tests/keychain-fix.test.ts beforeEach. Successful cleanup should use Varlock daemon; failed/pre-test cleanup uses /usr/bin/security.', + '-D', + 'Varlock keychain-fix smoke test restricted generic password', '-w', value, '-T', @@ -44,20 +110,33 @@ function createKeychainItem(account: string, value: string) { } beforeEach(() => { + cleanupWithVarlockDaemon = false; + service = uniqueService(); + phase(`beforeEach: reset Varlock daemon for service ${service}`); resetVarlockDaemon(); - deleteFixedSecrets(); + writeGeneratedSchema(); for (const [key, value] of Object.entries(FIXED_SECRETS)) { - createKeychainItem(`${PROJECT}:${PROFILE}:${key}`, value); + createKeychainItem(key, `${PROJECT}:${PROFILE}:${key}`, value); } }); afterEach(() => { - deleteFixedSecrets(); - resetVarlockDaemon(); + try { + if (cleanupWithVarlockDaemon) { + deleteFixedSecretsWithVarlockDaemon('afterEach successful fix-access cleanup'); + } else { + deleteFixedSecretsWithSecurity('afterEach failed/pre-fix cleanup'); + } + } finally { + removeGeneratedSchema(); + phase('afterEach: reset Varlock daemon'); + resetVarlockDaemon(); + } }); describe.skipIf(!RUN_KEYCHAIN_SMOKE || process.platform !== 'darwin')('macOS Keychain fix-access smoke tests', () => { test('fixes access for keychain refs in the fixture schema and reads the secrets', () => { + phase('test: run varlock keychain fix-access'); const fixAccessResult = runVarlock([ 'keychain', 'fix-access', @@ -66,20 +145,24 @@ describe.skipIf(!RUN_KEYCHAIN_SMOKE || process.platform !== 'darwin')('macOS Key ], { cwd: FIXTURE_DIR }); expect(fixAccessResult.exitCode, fixAccessResult.output).toBe(0); - const apiKeyResult = runVarlock(['printenv', 'FIXED_API_KEY', '--skip-cache'], { cwd: FIXTURE_DIR }); + phase('test: printenv FIXED_API_KEY after fix-access'); + const apiKeyResult = runVarlock(['printenv', 'FIXED_API_KEY', '--skip-cache', '--path', FIXTURE_SCHEMA], { cwd: FIXTURE_DIR }); expect(apiKeyResult.exitCode, apiKeyResult.output).toBe(0); expect(apiKeyResult.stdout.trim()).toBe(FIXED_SECRETS.FIXED_API_KEY); - const databaseUrlResult = runVarlock(['printenv', 'FIXED_DATABASE_URL', '--skip-cache'], { cwd: FIXTURE_DIR }); + phase('test: printenv FIXED_DATABASE_URL after fix-access'); + const databaseUrlResult = runVarlock(['printenv', 'FIXED_DATABASE_URL', '--skip-cache', '--path', FIXTURE_SCHEMA], { cwd: FIXTURE_DIR }); expect(databaseUrlResult.exitCode, databaseUrlResult.output).toBe(0); expect(databaseUrlResult.stdout.trim()).toBe(FIXED_SECRETS.FIXED_DATABASE_URL); - const loadAfterFixResult = runVarlock(['load', '--format', 'json', '--skip-cache'], { + phase('test: load after fix-access'); + const loadAfterFixResult = runVarlock(['load', '--format', 'json', '--skip-cache', '--path', FIXTURE_SCHEMA], { cwd: FIXTURE_DIR, }); expect(loadAfterFixResult.exitCode, loadAfterFixResult.output).toBe(0); const vars = JSON.parse(loadAfterFixResult.stdout); expect(vars.FIXED_API_KEY).toBe(FIXED_SECRETS.FIXED_API_KEY); expect(vars.FIXED_DATABASE_URL).toBe(FIXED_SECRETS.FIXED_DATABASE_URL); + cleanupWithVarlockDaemon = true; }); }); diff --git a/smoke-tests/tests/keychain-ownership.test.ts b/smoke-tests/tests/keychain-ownership.test.ts index 0947e623c..b0ce08d77 100644 --- a/smoke-tests/tests/keychain-ownership.test.ts +++ b/smoke-tests/tests/keychain-ownership.test.ts @@ -2,27 +2,35 @@ import { afterEach, beforeEach, describe, expect, test, } from 'vitest'; import { spawnSync } from 'node:child_process'; +import { existsSync, unlinkSync } from 'node:fs'; +import { join } from 'node:path'; import { resetVarlockDaemon } from '../helpers/keychain-daemon.js'; import { runVarlock } from '../helpers/run-varlock.js'; const RUN_KEYCHAIN_SMOKE = process.env.VARLOCK_RUN_KEYCHAIN_SMOKE === '1'; -const SERVICE = 'varlock-smoke-test-ownership'; +const SOURCE_SERVICE = 'varlock-smoke-test-ownership-source'; +const TARGET_SERVICE = 'varlock-smoke-test-ownership'; const PROFILE = 'local'; const PROJECT = 'smoke-test-keychain-ownership'; const FIXTURE_DIR = PROJECT; -const FIXTURE_SCHEMA = '.env.schema'; +const WRITTEN_ENV = '.env.cloned'; +const WRITTEN_ENV_PATH = join(import.meta.dirname, '..', FIXTURE_DIR, WRITTEN_ENV); const OWNED_SECRETS = { OWNED_API_KEY: 'owned-api-key-from-keychain', }; -const OWNED_ACCOUNTS = Object.keys(OWNED_SECRETS).map((key) => `${PROJECT}:${PROFILE}:${key}`); +const SOURCE_ACCOUNTS = Object.keys(OWNED_SECRETS).map((key) => `${PROJECT}:${PROFILE}:source:${key}`); +const TARGET_ACCOUNTS = Object.keys(OWNED_SECRETS).map((key) => `${PROJECT}:${PROFILE}:${key}`); function security(args: Array) { return spawnSync('security', args, { encoding: 'utf-8' }); } function deleteOwnedSecrets() { - for (const account of OWNED_ACCOUNTS) { - security(['delete-generic-password', '-s', SERVICE, '-a', account]); + for (const account of SOURCE_ACCOUNTS) { + security(['delete-generic-password', '-s', SOURCE_SERVICE, '-a', account]); + } + for (const account of TARGET_ACCOUNTS) { + security(['delete-generic-password', '-s', TARGET_SERVICE, '-a', account]); } } @@ -31,7 +39,7 @@ function createKeychainItem(account: string, value: string) { 'add-generic-password', '-U', '-s', - SERVICE, + SOURCE_SERVICE, '-a', account, '-w', @@ -46,30 +54,41 @@ beforeEach(() => { resetVarlockDaemon(); deleteOwnedSecrets(); for (const [key, value] of Object.entries(OWNED_SECRETS)) { - createKeychainItem(`${PROJECT}:${PROFILE}:${key}`, value); + createKeychainItem(`${PROJECT}:${PROFILE}:source:${key}`, value); } }); afterEach(() => { + if (existsSync(WRITTEN_ENV_PATH)) unlinkSync(WRITTEN_ENV_PATH); deleteOwnedSecrets(); resetVarlockDaemon(); }); -describe.skipIf(!RUN_KEYCHAIN_SMOKE || process.platform !== 'darwin')('macOS Keychain take-ownership smoke tests', () => { - test('takes ownership for keychain refs and reads the secrets', () => { - const takeOwnershipResult = runVarlock([ +describe.skipIf(!RUN_KEYCHAIN_SMOKE || process.platform !== 'darwin')('macOS Keychain cloneToOwned smoke tests', () => { + test('clones one source secret into a Varlock-owned item and reads it', () => { + const cloneResult = runVarlock([ 'keychain', - 'take-ownership', - '--path', - FIXTURE_SCHEMA, + 'cloneToOwned', + '--service', + SOURCE_SERVICE, + '--account', + `${PROJECT}:${PROFILE}:source:OWNED_API_KEY`, + '--target-service', + TARGET_SERVICE, + '--target-account', + `${PROJECT}:${PROFILE}:OWNED_API_KEY`, + '--write-to', + WRITTEN_ENV, + '--key', + 'OWNED_API_KEY', ], { cwd: FIXTURE_DIR }); - expect(takeOwnershipResult.exitCode, takeOwnershipResult.output).toBe(0); + expect(cloneResult.exitCode, cloneResult.output).toBe(0); - const loadAfterOwnershipResult = runVarlock(['load', '--format', 'json', '--skip-cache'], { + const loadAfterCloneResult = runVarlock(['load', '--format', 'json', '--skip-cache', '--path', WRITTEN_ENV], { cwd: FIXTURE_DIR, }); - expect(loadAfterOwnershipResult.exitCode, loadAfterOwnershipResult.output).toBe(0); - const vars = JSON.parse(loadAfterOwnershipResult.stdout); + expect(loadAfterCloneResult.exitCode, loadAfterCloneResult.output).toBe(0); + const vars = JSON.parse(loadAfterCloneResult.stdout); expect(vars.OWNED_API_KEY).toBe(OWNED_SECRETS.OWNED_API_KEY); }); });