mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
feat: 添加 Local Vault 加密存储服务
AES-256-GCM 加密 vault,支持 OS keychain 和加密文件回退, scrypt KDF 密钥派生,64KB secret 上限。 Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
This commit is contained in:
91
src/services/localVault/__tests__/keychain.test.ts
Normal file
91
src/services/localVault/__tests__/keychain.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { describe, test, expect, mock, beforeEach } from 'bun:test'
|
||||
import { logMock } from '../../../../tests/mocks/log.js'
|
||||
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
mock.module('bun:bundle', () => ({ feature: () => false }))
|
||||
|
||||
// ── In-memory store backing the mock ─────────────────────────────────────────
|
||||
|
||||
const store: Record<string, string> = {}
|
||||
|
||||
// ── Class-based Entry mock ────────────────────────────────────────────────────
|
||||
|
||||
class MockEntry {
|
||||
constructor(
|
||||
public service: string,
|
||||
public account: string,
|
||||
) {}
|
||||
|
||||
getPassword(): string | null {
|
||||
return store[this.account] ?? null
|
||||
}
|
||||
|
||||
setPassword(pw: string): void {
|
||||
store[this.account] = pw
|
||||
}
|
||||
|
||||
deletePassword(): boolean {
|
||||
if (this.account in store) {
|
||||
delete store[this.account]
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
mock.module('@napi-rs/keyring', () => ({ Entry: MockEntry }))
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('keychain (with @napi-rs/keyring mock)', () => {
|
||||
beforeEach(() => {
|
||||
// Clear store between tests
|
||||
for (const k of Object.keys(store)) delete store[k]
|
||||
// Reset the module load cache so keychain re-imports the mocked module
|
||||
const keychainMod = require.cache?.['../keychain.js']
|
||||
if (keychainMod) delete require.cache['../keychain.js']
|
||||
})
|
||||
|
||||
test('set and get round-trip', async () => {
|
||||
const { tryKeychain, _resetKeychainModuleCache } = await import(
|
||||
'../keychain.js'
|
||||
)
|
||||
_resetKeychainModuleCache()
|
||||
await tryKeychain.set('MY_KEY', 'my_secret_value')
|
||||
const result = await tryKeychain.get('MY_KEY')
|
||||
expect(result).toBe('my_secret_value')
|
||||
})
|
||||
|
||||
test('get returns null for missing key', async () => {
|
||||
const { tryKeychain, _resetKeychainModuleCache } = await import(
|
||||
'../keychain.js'
|
||||
)
|
||||
_resetKeychainModuleCache()
|
||||
const result = await tryKeychain.get('NONEXISTENT_KEY')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test('delete returns true for existing key', async () => {
|
||||
const { tryKeychain, _resetKeychainModuleCache } = await import(
|
||||
'../keychain.js'
|
||||
)
|
||||
_resetKeychainModuleCache()
|
||||
await tryKeychain.set('DELETE_ME', 'value')
|
||||
const result = await tryKeychain.delete('DELETE_ME')
|
||||
expect(result).toBe(true)
|
||||
expect(await tryKeychain.get('DELETE_ME')).toBeNull()
|
||||
})
|
||||
|
||||
test('KeychainUnavailableError thrown when module exports invalid shape', async () => {
|
||||
// Temporarily replace with a bad module
|
||||
mock.module('@napi-rs/keyring', () => ({ Entry: null }))
|
||||
const { tryKeychain, KeychainUnavailableError, _resetKeychainModuleCache } =
|
||||
await import('../keychain.js')
|
||||
_resetKeychainModuleCache()
|
||||
await expect(tryKeychain.get('x')).rejects.toBeInstanceOf(
|
||||
KeychainUnavailableError,
|
||||
)
|
||||
// Restore
|
||||
mock.module('@napi-rs/keyring', () => ({ Entry: MockEntry }))
|
||||
})
|
||||
})
|
||||
468
src/services/localVault/__tests__/store.test.ts
Normal file
468
src/services/localVault/__tests__/store.test.ts
Normal file
@@ -0,0 +1,468 @@
|
||||
import {
|
||||
describe,
|
||||
test,
|
||||
expect,
|
||||
mock,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
spyOn,
|
||||
} from 'bun:test'
|
||||
import {
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
statSync,
|
||||
readFileSync,
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { logMock } from '../../../../tests/mocks/log.js'
|
||||
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
mock.module('bun:bundle', () => ({ feature: () => false }))
|
||||
|
||||
// ── Keychain mock (unavailable by default to test fallback path) ───────────────
|
||||
|
||||
import { KeychainUnavailableError } from '../keychain.js'
|
||||
|
||||
const keychainUnavailable = async (): Promise<never> => {
|
||||
throw new KeychainUnavailableError('test: keychain mocked as unavailable')
|
||||
}
|
||||
|
||||
const keychainMock = {
|
||||
set: mock(keychainUnavailable),
|
||||
get: mock(keychainUnavailable),
|
||||
delete: mock(keychainUnavailable),
|
||||
list: mock(keychainUnavailable),
|
||||
_addToIndex: mock(keychainUnavailable),
|
||||
_removeFromIndex: mock(keychainUnavailable),
|
||||
}
|
||||
|
||||
mock.module('../keychain.js', () => ({
|
||||
KeychainUnavailableError,
|
||||
tryKeychain: keychainMock,
|
||||
_resetKeychainModuleCache: () => {},
|
||||
}))
|
||||
|
||||
// ── Crypto fallback tests ─────────────────────────────────────────────────────
|
||||
|
||||
describe('store (AES-256-GCM file fallback)', () => {
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'local-vault-test-'))
|
||||
process.env['CLAUDE_CONFIG_DIR'] = tmpDir
|
||||
// Use a fixed passphrase via env to avoid file creation
|
||||
process.env['CLAUDE_LOCAL_VAULT_PASSPHRASE'] =
|
||||
'test-passphrase-fixed-32chars-xxx'
|
||||
// Reset all keychain mocks to unavailable
|
||||
keychainMock.set.mockImplementation(keychainUnavailable)
|
||||
keychainMock.get.mockImplementation(keychainUnavailable)
|
||||
keychainMock.delete.mockImplementation(keychainUnavailable)
|
||||
keychainMock.list.mockImplementation(keychainUnavailable)
|
||||
keychainMock._addToIndex.mockImplementation(keychainUnavailable)
|
||||
keychainMock._removeFromIndex.mockImplementation(keychainUnavailable)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
delete process.env['CLAUDE_CONFIG_DIR']
|
||||
delete process.env['CLAUDE_LOCAL_VAULT_PASSPHRASE']
|
||||
})
|
||||
|
||||
test('round-trip: set then get returns same value', async () => {
|
||||
const { setSecret, getSecret } = await import('../store.js')
|
||||
await setSecret('API_KEY', 'super-secret-value-abc123')
|
||||
const result = await getSecret('API_KEY')
|
||||
expect(result).toBe('super-secret-value-abc123')
|
||||
})
|
||||
|
||||
test('get returns null for missing key', async () => {
|
||||
const { getSecret } = await import('../store.js')
|
||||
const result = await getSecret('NONEXISTENT_KEY')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test('delete removes key; subsequent get returns null', async () => {
|
||||
const { setSecret, getSecret, deleteSecret } = await import('../store.js')
|
||||
await setSecret('TO_DELETE', 'temporary-value')
|
||||
const deleted = await deleteSecret('TO_DELETE')
|
||||
expect(deleted).toBe(true)
|
||||
expect(await getSecret('TO_DELETE')).toBeNull()
|
||||
})
|
||||
|
||||
test('delete returns false for nonexistent key', async () => {
|
||||
const { deleteSecret } = await import('../store.js')
|
||||
const result = await deleteSecret('GHOST_KEY')
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test('listKeys returns stored keys without values', async () => {
|
||||
const { setSecret, listKeys } = await import('../store.js')
|
||||
await setSecret('KEY_A', 'value-a')
|
||||
await setSecret('KEY_B', 'value-b')
|
||||
const keys = await listKeys()
|
||||
expect(keys).toContain('KEY_A')
|
||||
expect(keys).toContain('KEY_B')
|
||||
expect(keys.join('')).not.toContain('value-a')
|
||||
expect(keys.join('')).not.toContain('value-b')
|
||||
})
|
||||
|
||||
test('wrong passphrase throws LocalVaultDecryptionError (does not leak bytes)', async () => {
|
||||
const { setSecret } = await import('../store.js')
|
||||
await setSecret('SENSITIVE', 'my-secret-12345')
|
||||
|
||||
// Change passphrase to simulate wrong key
|
||||
process.env['CLAUDE_LOCAL_VAULT_PASSPHRASE'] =
|
||||
'wrong-passphrase-different-xxxxx'
|
||||
const { getSecret, LocalVaultDecryptionError } = await import('../store.js')
|
||||
await expect(getSecret('SENSITIVE')).rejects.toBeInstanceOf(
|
||||
LocalVaultDecryptionError,
|
||||
)
|
||||
// Restore
|
||||
process.env['CLAUDE_LOCAL_VAULT_PASSPHRASE'] =
|
||||
'test-passphrase-fixed-32chars-xxx'
|
||||
})
|
||||
|
||||
test('file does not exist → getSecret returns null (not error)', async () => {
|
||||
const { getSecret } = await import('../store.js')
|
||||
const result = await getSecret('ANY_KEY')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test('corrupted JSON vault file → getSecret throws LocalVaultDecryptionError (A2 fix)', async () => {
|
||||
writeFileSync(join(tmpDir, 'local-vault.enc.json'), 'not-valid-json')
|
||||
const { getSecret, LocalVaultDecryptionError } = await import('../store.js')
|
||||
await expect(getSecret('ANY_KEY')).rejects.toBeInstanceOf(
|
||||
LocalVaultDecryptionError,
|
||||
)
|
||||
})
|
||||
|
||||
test('value at exactly 64KB round-trips successfully', async () => {
|
||||
const { setSecret, getSecret } = await import('../store.js')
|
||||
const exactValue = 'X'.repeat(64 * 1024)
|
||||
await setSecret('LARGE_KEY', exactValue)
|
||||
const result = await getSecret('LARGE_KEY')
|
||||
expect(result).toBe(exactValue)
|
||||
})
|
||||
|
||||
test('value over 64KB is rejected by setSecret (D1 fix)', async () => {
|
||||
const { setSecret, LocalVaultValueTooLargeError } = await import(
|
||||
'../store.js'
|
||||
)
|
||||
const tooLarge = 'X'.repeat(64 * 1024 + 1)
|
||||
await expect(setSecret('LARGE_KEY', tooLarge)).rejects.toBeInstanceOf(
|
||||
LocalVaultValueTooLargeError,
|
||||
)
|
||||
})
|
||||
|
||||
test('Unicode key round-trip', async () => {
|
||||
const { setSecret, getSecret } = await import('../store.js')
|
||||
await setSecret('KEY_🔑', 'unicode-secret-日本語')
|
||||
const result = await getSecret('KEY_🔑')
|
||||
expect(result).toBe('unicode-secret-日本語')
|
||||
})
|
||||
|
||||
test('IV is unique per encryption (AES-GCM invariant)', async () => {
|
||||
// Write two entries; IVs in vault file should differ
|
||||
const { setSecret } = await import('../store.js')
|
||||
await setSecret('KEY_1', 'value-1')
|
||||
await setSecret('KEY_2', 'value-2')
|
||||
const vaultRaw = readFileSync(join(tmpDir, 'local-vault.enc.json'), 'utf8')
|
||||
const vault = JSON.parse(vaultRaw) as Record<string, unknown>
|
||||
// Only check actual encrypted records (skip metadata keys like _salt, _version)
|
||||
const records = Object.entries(vault)
|
||||
.filter(([k]) => !k.startsWith('_'))
|
||||
.map(([, v]) => (v as { iv: string }).iv)
|
||||
expect(new Set(records).size).toBe(records.length) // all IVs unique
|
||||
})
|
||||
|
||||
test('passphrase file mode 600 on POSIX', async () => {
|
||||
// Remove env passphrase to force file creation
|
||||
delete process.env['CLAUDE_LOCAL_VAULT_PASSPHRASE']
|
||||
const { setSecret } = await import('../store.js')
|
||||
await setSecret('MODE_TEST', 'value')
|
||||
const passphraseFile = join(tmpDir, '.local-vault-passphrase')
|
||||
if (process.platform !== 'win32') {
|
||||
const stat = statSync(passphraseFile)
|
||||
const mode = stat.mode & 0o777
|
||||
expect(mode).toBe(0o600)
|
||||
}
|
||||
// On Windows: file should exist (mode check is best-effort)
|
||||
const { existsSync } = await import('node:fs')
|
||||
expect(existsSync(passphraseFile)).toBe(true)
|
||||
process.env['CLAUDE_LOCAL_VAULT_PASSPHRASE'] =
|
||||
'test-passphrase-fixed-32chars-xxx'
|
||||
})
|
||||
})
|
||||
|
||||
// ── maskSecret tests ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('maskSecret', () => {
|
||||
test('masks long secret correctly', async () => {
|
||||
const { maskSecret } = await import('../store.js')
|
||||
const masked = maskSecret('ABCDEFGHIJKLMNOP')
|
||||
expect(masked.startsWith('ABCD')).toBe(true)
|
||||
expect(masked).toContain('...')
|
||||
expect(masked).not.toBe('ABCDEFGHIJKLMNOP')
|
||||
})
|
||||
|
||||
test('short secret uses length notation', async () => {
|
||||
const { maskSecret } = await import('../store.js')
|
||||
expect(maskSecret('abc')).toContain('len=3')
|
||||
expect(maskSecret('abc')).not.toContain('abc')
|
||||
})
|
||||
})
|
||||
|
||||
// ── I1: Security invariant — secret never appears in logs ─────────────────────
|
||||
|
||||
describe('store: security invariants (I1)', () => {
|
||||
let tmpDir: string
|
||||
const SECRET_VALUE = 'super-secret-never-log-me-abc999'
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'vault-sec-'))
|
||||
process.env['CLAUDE_CONFIG_DIR'] = tmpDir
|
||||
process.env['CLAUDE_LOCAL_VAULT_PASSPHRASE'] =
|
||||
'test-passphrase-fixed-32chars-xxx'
|
||||
keychainMock.set.mockImplementation(keychainUnavailable)
|
||||
keychainMock.get.mockImplementation(keychainUnavailable)
|
||||
keychainMock.delete.mockImplementation(keychainUnavailable)
|
||||
keychainMock.list.mockImplementation(keychainUnavailable)
|
||||
keychainMock._addToIndex.mockImplementation(keychainUnavailable)
|
||||
keychainMock._removeFromIndex.mockImplementation(keychainUnavailable)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
delete process.env['CLAUDE_CONFIG_DIR']
|
||||
delete process.env['CLAUDE_LOCAL_VAULT_PASSPHRASE']
|
||||
})
|
||||
|
||||
test('secret value never appears in console.warn calls after setSecret', async () => {
|
||||
const warnSpy = spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const { setSecret } = await import('../store.js')
|
||||
await setSecret('MY_KEY', SECRET_VALUE)
|
||||
const allWarnCalls = warnSpy.mock.calls.flat().map(String).join(' ')
|
||||
expect(allWarnCalls).not.toContain(SECRET_VALUE)
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
test('secret value never appears in vault file keys (only encrypted blob)', async () => {
|
||||
const { setSecret } = await import('../store.js')
|
||||
await setSecret('MY_KEY', SECRET_VALUE)
|
||||
const vaultPath = join(tmpDir, 'local-vault.enc.json')
|
||||
const vaultContent = readFileSync(vaultPath, 'utf8')
|
||||
// The plaintext secret must not appear in the vault file
|
||||
expect(vaultContent).not.toContain(SECRET_VALUE)
|
||||
// The key name IS stored (by design), but the value must not be
|
||||
expect(vaultContent).toContain('MY_KEY')
|
||||
})
|
||||
})
|
||||
|
||||
// ── I2: AES-GCM tamper detection ──────────────────────────────────────────────
|
||||
|
||||
describe('store: AES-GCM tamper detection (I2)', () => {
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'vault-tamper-'))
|
||||
process.env['CLAUDE_CONFIG_DIR'] = tmpDir
|
||||
process.env['CLAUDE_LOCAL_VAULT_PASSPHRASE'] =
|
||||
'test-passphrase-fixed-32chars-xxx'
|
||||
keychainMock.set.mockImplementation(keychainUnavailable)
|
||||
keychainMock.get.mockImplementation(keychainUnavailable)
|
||||
keychainMock.delete.mockImplementation(keychainUnavailable)
|
||||
keychainMock.list.mockImplementation(keychainUnavailable)
|
||||
keychainMock._addToIndex.mockImplementation(keychainUnavailable)
|
||||
keychainMock._removeFromIndex.mockImplementation(keychainUnavailable)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
delete process.env['CLAUDE_CONFIG_DIR']
|
||||
delete process.env['CLAUDE_LOCAL_VAULT_PASSPHRASE']
|
||||
})
|
||||
|
||||
test('flipping a byte in data causes LocalVaultDecryptionError', async () => {
|
||||
const { setSecret, getSecret, LocalVaultDecryptionError } = await import(
|
||||
'../store.js'
|
||||
)
|
||||
await setSecret('TAMPER_KEY', 'original-value-to-tamper')
|
||||
const vaultPath = join(tmpDir, 'local-vault.enc.json')
|
||||
const vault = JSON.parse(readFileSync(vaultPath, 'utf8')) as Record<
|
||||
string,
|
||||
{ iv: string; tag: string; data: string }
|
||||
>
|
||||
// Flip last byte of data hex
|
||||
const record = vault['TAMPER_KEY']!
|
||||
const dataHex = record.data
|
||||
const flippedByte = (parseInt(dataHex.slice(-2), 16) ^ 0xff)
|
||||
.toString(16)
|
||||
.padStart(2, '0')
|
||||
vault['TAMPER_KEY'] = {
|
||||
...record,
|
||||
data: dataHex.slice(0, -2) + flippedByte,
|
||||
}
|
||||
writeFileSync(vaultPath, JSON.stringify(vault), 'utf8')
|
||||
await expect(getSecret('TAMPER_KEY')).rejects.toBeInstanceOf(
|
||||
LocalVaultDecryptionError,
|
||||
)
|
||||
})
|
||||
|
||||
test('flipping a byte in tag causes LocalVaultDecryptionError', async () => {
|
||||
const { setSecret, getSecret, LocalVaultDecryptionError } = await import(
|
||||
'../store.js'
|
||||
)
|
||||
await setSecret('TAMPER_TAG', 'original-value-tag-tamper')
|
||||
const vaultPath = join(tmpDir, 'local-vault.enc.json')
|
||||
const vault = JSON.parse(readFileSync(vaultPath, 'utf8')) as Record<
|
||||
string,
|
||||
{ iv: string; tag: string; data: string }
|
||||
>
|
||||
const record = vault['TAMPER_TAG']!
|
||||
const tagHex = record.tag
|
||||
const flippedByte = (parseInt(tagHex.slice(-2), 16) ^ 0xff)
|
||||
.toString(16)
|
||||
.padStart(2, '0')
|
||||
vault['TAMPER_TAG'] = { ...record, tag: tagHex.slice(0, -2) + flippedByte }
|
||||
writeFileSync(vaultPath, JSON.stringify(vault), 'utf8')
|
||||
await expect(getSecret('TAMPER_TAG')).rejects.toBeInstanceOf(
|
||||
LocalVaultDecryptionError,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ── H3 fix (codecov-100 audit): invalid-UTF-8 decryption surfaces as error ────
|
||||
|
||||
describe('store: invalid-UTF-8 decryption rejection (H3)', () => {
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'vault-utf8-'))
|
||||
process.env['CLAUDE_CONFIG_DIR'] = tmpDir
|
||||
process.env['CLAUDE_LOCAL_VAULT_PASSPHRASE'] =
|
||||
'test-passphrase-fixed-32chars-xxx'
|
||||
keychainMock.set.mockImplementation(keychainUnavailable)
|
||||
keychainMock.get.mockImplementation(keychainUnavailable)
|
||||
keychainMock.delete.mockImplementation(keychainUnavailable)
|
||||
keychainMock.list.mockImplementation(keychainUnavailable)
|
||||
keychainMock._addToIndex.mockImplementation(keychainUnavailable)
|
||||
keychainMock._removeFromIndex.mockImplementation(keychainUnavailable)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
delete process.env['CLAUDE_CONFIG_DIR']
|
||||
delete process.env['CLAUDE_LOCAL_VAULT_PASSPHRASE']
|
||||
})
|
||||
|
||||
test('regression: decrypted payload with invalid UTF-8 throws LocalVaultDecryptionError (no silent U+FFFD)', async () => {
|
||||
// We craft a vault file whose encrypted record decrypts to a buffer
|
||||
// containing invalid UTF-8 (lone continuation byte 0xC3 followed by
|
||||
// 0x28 — '(' — which is NOT a valid continuation byte).
|
||||
// The encrypted record must pass GCM authentication, so we encrypt
|
||||
// the malformed bytes ourselves with the same passphrase + salt as
|
||||
// the store would derive.
|
||||
const { LocalVaultDecryptionError, getSecret } = await import('../store.js')
|
||||
const { createCipheriv, randomBytes, scryptSync } = await import(
|
||||
'node:crypto'
|
||||
)
|
||||
|
||||
// Mirror the constants from store.ts
|
||||
const ALGORITHM = 'aes-256-gcm' as const
|
||||
const IV_BYTES = 12
|
||||
const KEY_BYTES = 32
|
||||
const SALT_BYTES = 16
|
||||
const SCRYPT_PARAMS = { N: 16384, r: 8, p: 1 }
|
||||
|
||||
const passphrase = 'test-passphrase-fixed-32chars-xxx'
|
||||
const salt = randomBytes(SALT_BYTES)
|
||||
const key256 = scryptSync(
|
||||
passphrase,
|
||||
salt,
|
||||
KEY_BYTES,
|
||||
SCRYPT_PARAMS,
|
||||
) as Buffer
|
||||
|
||||
// Invalid UTF-8 sequence: lone continuation byte / overlong / truncated
|
||||
// multi-byte. 0xC3 0x28 is the canonical "invalid 2-byte sequence" example.
|
||||
const invalidUtf8 = Buffer.from([0xc3, 0x28, 0xa0, 0xa1])
|
||||
|
||||
const iv = randomBytes(IV_BYTES)
|
||||
const cipher = createCipheriv(ALGORITHM, key256, iv)
|
||||
const entryKey = 'BAD_UTF8'
|
||||
cipher.setAAD(Buffer.from(entryKey, 'utf8'))
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(invalidUtf8),
|
||||
cipher.final(),
|
||||
])
|
||||
const tag = cipher.getAuthTag()
|
||||
|
||||
const vaultData = {
|
||||
_salt: salt.toString('hex'),
|
||||
_version: 2,
|
||||
[entryKey]: {
|
||||
iv: iv.toString('hex'),
|
||||
tag: tag.toString('hex'),
|
||||
data: encrypted.toString('hex'),
|
||||
},
|
||||
}
|
||||
writeFileSync(
|
||||
join(tmpDir, 'local-vault.enc.json'),
|
||||
JSON.stringify(vaultData),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
// Old code: returned a string with U+FFFD replacement chars (corruption
|
||||
// undetectable to caller). New code: throws LocalVaultDecryptionError.
|
||||
await expect(getSecret(entryKey)).rejects.toBeInstanceOf(
|
||||
LocalVaultDecryptionError,
|
||||
)
|
||||
await expect(getSecret(entryKey)).rejects.toMatchObject({
|
||||
message: expect.stringMatching(/UTF-8|corrupted/i),
|
||||
})
|
||||
})
|
||||
|
||||
test('valid UTF-8 (CJK / emoji) still round-trips after H3 fix', async () => {
|
||||
// Sanity: H3's fatal TextDecoder must not break valid multi-byte UTF-8.
|
||||
const { setSecret, getSecret } = await import('../store.js')
|
||||
const value = '日本語🎉🌟αβγ test 123'
|
||||
await setSecret('UTF8_OK', value)
|
||||
expect(await getSecret('UTF8_OK')).toBe(value)
|
||||
})
|
||||
})
|
||||
|
||||
// ── D1: Value size limit ───────────────────────────────────────────────────────
|
||||
|
||||
describe('store: value size limit (D1)', () => {
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'vault-size-'))
|
||||
process.env['CLAUDE_CONFIG_DIR'] = tmpDir
|
||||
process.env['CLAUDE_LOCAL_VAULT_PASSPHRASE'] =
|
||||
'test-passphrase-fixed-32chars-xxx'
|
||||
keychainMock.set.mockImplementation(keychainUnavailable)
|
||||
keychainMock._addToIndex.mockImplementation(keychainUnavailable)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
delete process.env['CLAUDE_CONFIG_DIR']
|
||||
delete process.env['CLAUDE_LOCAL_VAULT_PASSPHRASE']
|
||||
})
|
||||
|
||||
test('setSecret rejects value >64KB', async () => {
|
||||
const { setSecret } = await import('../store.js')
|
||||
const bigValue = 'X'.repeat(64 * 1024 + 1)
|
||||
await expect(setSecret('BIG_KEY', bigValue)).rejects.toThrow()
|
||||
})
|
||||
|
||||
test('setSecret accepts value exactly at 64KB', async () => {
|
||||
const { setSecret, getSecret } = await import('../store.js')
|
||||
const exactValue = 'X'.repeat(64 * 1024)
|
||||
await expect(setSecret('EXACT_KEY', exactValue)).resolves.toBeUndefined()
|
||||
expect(await getSecret('EXACT_KEY')).toBe(exactValue)
|
||||
})
|
||||
})
|
||||
133
src/services/localVault/keychain.ts
Normal file
133
src/services/localVault/keychain.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Thin wrapper around @napi-rs/keyring OS keychain.
|
||||
* If the native module is unavailable (platform not supported, module missing),
|
||||
* throws KeychainUnavailableError so that store.ts can fall back to encrypted
|
||||
* file storage.
|
||||
*/
|
||||
|
||||
export class KeychainUnavailableError extends Error {
|
||||
constructor(reason: string) {
|
||||
super(`OS keychain not available: ${reason}`)
|
||||
this.name = 'KeychainUnavailableError'
|
||||
}
|
||||
}
|
||||
|
||||
const SERVICE_NAME = 'claude-code-local-vault'
|
||||
|
||||
type KeyringEntry = {
|
||||
getPassword: () => string | null
|
||||
setPassword: (password: string) => void
|
||||
deletePassword: () => boolean
|
||||
}
|
||||
|
||||
type KeyringModule = {
|
||||
Entry: new (service: string, account: string) => KeyringEntry
|
||||
}
|
||||
|
||||
let _mod: KeyringModule | null | 'not-tried' = 'not-tried'
|
||||
|
||||
async function loadModule(): Promise<KeyringModule> {
|
||||
if (_mod !== 'not-tried') {
|
||||
if (_mod === null)
|
||||
throw new KeychainUnavailableError('module load failed previously')
|
||||
return _mod
|
||||
}
|
||||
try {
|
||||
// Dynamic import so the rest of the codebase compiles even without the module.
|
||||
const m = (await import('@napi-rs/keyring')) as unknown as KeyringModule
|
||||
if (!m || typeof m.Entry !== 'function') {
|
||||
_mod = null
|
||||
throw new KeychainUnavailableError('module does not export Entry')
|
||||
}
|
||||
_mod = m
|
||||
return m
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof KeychainUnavailableError) throw err
|
||||
_mod = null
|
||||
throw new KeychainUnavailableError(
|
||||
err instanceof Error ? err.message : String(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset module cache — for testing only.
|
||||
* B2: intentionally not exported from the package's public API.
|
||||
* Only imported via the tests' mock.module() boundary.
|
||||
* @internal
|
||||
*/
|
||||
export function _resetKeychainModuleCache(): void {
|
||||
_mod = 'not-tried'
|
||||
}
|
||||
|
||||
export const tryKeychain = {
|
||||
async set(account: string, value: string): Promise<void> {
|
||||
const mod = await loadModule()
|
||||
const entry = new mod.Entry(SERVICE_NAME, account)
|
||||
entry.setPassword(value)
|
||||
},
|
||||
|
||||
async get(account: string): Promise<string | null> {
|
||||
const mod = await loadModule()
|
||||
const entry = new mod.Entry(SERVICE_NAME, account)
|
||||
return entry.getPassword()
|
||||
},
|
||||
|
||||
async delete(account: string): Promise<boolean> {
|
||||
const mod = await loadModule()
|
||||
const entry = new mod.Entry(SERVICE_NAME, account)
|
||||
return entry.deletePassword()
|
||||
},
|
||||
|
||||
/**
|
||||
* Keyring has no native "list all" — we maintain our own index in a
|
||||
* dedicated account named __index__.
|
||||
*
|
||||
* A3 fix: a corrupt index throws KeychainUnavailableError so the caller
|
||||
* can fall back to the file vault rather than silently returning [] and
|
||||
* stranding existing keys (they become undeletable via delete()).
|
||||
*
|
||||
* C4 note: index read-modify-write is not atomic across processes. In
|
||||
* practice /local-vault set is user-interactive (not concurrently scripted),
|
||||
* so the advisory risk is acceptable. A future version can use Bun.lock or
|
||||
* an exclusive file lock for cross-process safety.
|
||||
*/
|
||||
async list(): Promise<string[]> {
|
||||
const mod = await loadModule()
|
||||
const indexEntry = new mod.Entry(SERVICE_NAME, '__index__')
|
||||
const raw = indexEntry.getPassword()
|
||||
if (!raw) return []
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(raw)
|
||||
} catch {
|
||||
// A3: corrupt index — throw so caller can fall back, not silently lose key references
|
||||
throw new KeychainUnavailableError(
|
||||
'keychain index is corrupt (invalid JSON). Reset via: /local-vault list (will regenerate index on next set).',
|
||||
)
|
||||
}
|
||||
if (Array.isArray(parsed)) {
|
||||
return (parsed as unknown[]).filter(
|
||||
(x): x is string => typeof x === 'string',
|
||||
)
|
||||
}
|
||||
return []
|
||||
},
|
||||
|
||||
async _addToIndex(account: string): Promise<void> {
|
||||
const mod = await loadModule()
|
||||
const indexEntry = new mod.Entry(SERVICE_NAME, '__index__')
|
||||
const existing = await this.list()
|
||||
if (!existing.includes(account)) {
|
||||
indexEntry.setPassword(JSON.stringify([...existing, account]))
|
||||
}
|
||||
},
|
||||
|
||||
async _removeFromIndex(account: string): Promise<void> {
|
||||
const mod = await loadModule()
|
||||
const indexEntry = new mod.Entry(SERVICE_NAME, '__index__')
|
||||
const existing = await this.list()
|
||||
const updated = existing.filter(k => k !== account)
|
||||
indexEntry.setPassword(JSON.stringify(updated))
|
||||
},
|
||||
}
|
||||
464
src/services/localVault/store.ts
Normal file
464
src/services/localVault/store.ts
Normal file
@@ -0,0 +1,464 @@
|
||||
/**
|
||||
* LocalVault store — OS keychain primary, AES-256-GCM file fallback.
|
||||
*
|
||||
* Passphrase priority:
|
||||
* 1. CLAUDE_LOCAL_VAULT_PASSPHRASE env var
|
||||
* 2. ~/.claude/.local-vault-passphrase (mode 600 on POSIX)
|
||||
* 3. Auto-generate + write to file (warns user to backup)
|
||||
*
|
||||
* Fallback file: ~/.claude/local-vault.enc.json (gitignored)
|
||||
*
|
||||
* Security invariants:
|
||||
* - AES-256-GCM with per-record random IV; scryptSync KDF for passphrase
|
||||
* - Vault-level 16-byte random salt stored in vault file header
|
||||
* - D1: value size capped at MAX_SECRET_BYTES (64 KB)
|
||||
* - B1: derived key buffer is zeroed after use (best-effort)
|
||||
* - C1: vault file writes use tmp+rename (atomic on POSIX)
|
||||
* - C5: passphrase file creation uses 'wx' exclusive flag (no double-write)
|
||||
* - A2: readVaultFile differentiates ENOENT vs JSON-parse error
|
||||
* - F1/F2: scryptSync KDF + per-vault salt (no rainbow tables)
|
||||
* - G4: decryption error includes recovery instructions
|
||||
*/
|
||||
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
randomBytes,
|
||||
scryptSync,
|
||||
} from 'node:crypto'
|
||||
import {
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
chmodSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
} from 'node:fs'
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { logError } from '../../utils/log.js'
|
||||
import { KeychainUnavailableError, tryKeychain } from './keychain.js'
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Maximum secret value size: 64 KB (OS keychain typically < 4 KB; file fallback keeps overhead low). */
|
||||
const MAX_SECRET_BYTES = 64 * 1024
|
||||
|
||||
/** AES-GCM algorithm. */
|
||||
const ALGORITHM = 'aes-256-gcm' as const
|
||||
const IV_BYTES = 12
|
||||
const TAG_BYTES = 16
|
||||
const KEY_BYTES = 32
|
||||
const SALT_BYTES = 16
|
||||
|
||||
/** scrypt parameters: N=16384 (2^14), r=8, p=1. OWASP-recommended minimum for interactive. */
|
||||
const SCRYPT_PARAMS: Parameters<typeof scryptSync>[3] = { N: 16384, r: 8, p: 1 }
|
||||
|
||||
// ── Error types ───────────────────────────────────────────────────────────────
|
||||
|
||||
export class LocalVaultDecryptionError extends Error {
|
||||
constructor(reason: string) {
|
||||
super(
|
||||
`LocalVault decryption failed: ${reason}. ` +
|
||||
'Restore from your backup of ~/.claude/.local-vault-passphrase, ' +
|
||||
'or delete ~/.claude/local-vault.enc.json to reset (DESTROYS ALL SECRETS).',
|
||||
)
|
||||
this.name = 'LocalVaultDecryptionError'
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalVaultValueTooLargeError extends Error {
|
||||
constructor(byteLength: number) {
|
||||
super(
|
||||
`LocalVault: secret value is too large (${byteLength} bytes). ` +
|
||||
`Maximum allowed is ${MAX_SECRET_BYTES} bytes (${MAX_SECRET_BYTES / 1024} KB). ` +
|
||||
'Use external storage for large data.',
|
||||
)
|
||||
this.name = 'LocalVaultValueTooLargeError'
|
||||
}
|
||||
}
|
||||
|
||||
// ── Path helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function getClaudeDir(): string {
|
||||
return process.env['CLAUDE_CONFIG_DIR'] ?? join(homedir(), '.claude')
|
||||
}
|
||||
|
||||
function getVaultFilePath(): string {
|
||||
return join(getClaudeDir(), 'local-vault.enc.json')
|
||||
}
|
||||
|
||||
function getPassphraseFilePath(): string {
|
||||
return join(getClaudeDir(), '.local-vault-passphrase')
|
||||
}
|
||||
|
||||
// ── Passphrase management ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Derives a 32-byte AES key from a passphrase + salt using scryptSync.
|
||||
*
|
||||
* F1/F2 fix: replaces single SHA-256 with memory-hard KDF + per-vault salt.
|
||||
* The salt is stored in the vault file header so it survives process restarts.
|
||||
* For the auto-generated 64-hex passphrase (256 bits entropy) this is defense-
|
||||
* in-depth; for user-provided low-entropy passphrases it is mandatory.
|
||||
*/
|
||||
function deriveKey(passphrase: string, salt: Buffer): Buffer {
|
||||
return scryptSync(passphrase, salt, KEY_BYTES, SCRYPT_PARAMS) as Buffer
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the passphrase.
|
||||
*
|
||||
* C5 fix: uses { flag: 'wx' } (exclusive create) for atomic first-run write.
|
||||
* If EEXIST (race: another process wrote first), re-reads from disk.
|
||||
*/
|
||||
async function getOrCreatePassphrase(): Promise<string> {
|
||||
// Priority 1: env var
|
||||
const envVal = process.env['CLAUDE_LOCAL_VAULT_PASSPHRASE']
|
||||
if (envVal) return envVal
|
||||
|
||||
const passphraseFile = getPassphraseFilePath()
|
||||
|
||||
// Priority 2: existing passphrase file
|
||||
if (existsSync(passphraseFile)) {
|
||||
return readFileSync(passphraseFile, 'utf8').trim()
|
||||
}
|
||||
|
||||
// Priority 3: auto-generate + write to file (exclusive create to avoid double-write)
|
||||
const claudeDir = getClaudeDir()
|
||||
if (!existsSync(claudeDir)) {
|
||||
mkdirSync(claudeDir, { recursive: true })
|
||||
}
|
||||
|
||||
const generated = randomBytes(32).toString('hex')
|
||||
try {
|
||||
// C5: 'wx' flag means exclusive create — EEXIST if another process wrote first
|
||||
writeFileSync(passphraseFile, generated, {
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
flag: 'wx',
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
const code = (err as NodeJS.ErrnoException).code
|
||||
if (code === 'EEXIST') {
|
||||
// Another concurrent first-run wrote the file — use theirs
|
||||
return readFileSync(passphraseFile, 'utf8').trim()
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
// Ensure mode 600 even if umask interfered
|
||||
try {
|
||||
chmodSync(passphraseFile, 0o600)
|
||||
} catch {
|
||||
// A4: Windows — best effort; user cannot act before encryption proceeds.
|
||||
// Recommend env var as the secure alternative.
|
||||
logError(
|
||||
new Error(
|
||||
'LocalVault: could not set passphrase file permissions on Windows. ' +
|
||||
'To secure your vault, set CLAUDE_LOCAL_VAULT_PASSPHRASE env var instead of relying on the passphrase file. ' +
|
||||
'Run: icacls "%USERPROFILE%\\.claude\\.local-vault-passphrase" /inheritance:r /grant:r "%USERNAME%":F',
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// E5: Use logError (consistent with rest of file) instead of console.warn
|
||||
logError(
|
||||
new Error(
|
||||
'[LocalVault] Generated new passphrase file: ' +
|
||||
passphraseFile +
|
||||
' — Back it up! Losing this file means losing access to your encrypted vault.',
|
||||
),
|
||||
)
|
||||
|
||||
return generated
|
||||
}
|
||||
|
||||
// ── Vault file format ─────────────────────────────────────────────────────────
|
||||
|
||||
type EncryptedRecord = {
|
||||
iv: string // hex
|
||||
tag: string // hex
|
||||
data: string // hex
|
||||
}
|
||||
|
||||
type VaultFile = {
|
||||
/** F1/F2: per-vault KDF salt, 32 hex chars (16 bytes). */
|
||||
_salt?: string
|
||||
/** Version marker for forward compatibility. */
|
||||
_version?: number
|
||||
[key: string]: EncryptedRecord | string | number | undefined
|
||||
}
|
||||
|
||||
// ── Crypto primitives ─────────────────────────────────────────────────────────
|
||||
|
||||
function encrypt(
|
||||
plaintext: string,
|
||||
key: Buffer,
|
||||
entryKey: string,
|
||||
): EncryptedRecord {
|
||||
// New IV per encryption — invariant: no IV reuse
|
||||
const iv = randomBytes(IV_BYTES)
|
||||
const cipher = createCipheriv(ALGORITHM, key, iv)
|
||||
// F3: bind entry key as AAD so swapping records fails GCM authentication
|
||||
cipher.setAAD(Buffer.from(entryKey, 'utf8'))
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(plaintext, 'utf8'),
|
||||
cipher.final(),
|
||||
])
|
||||
const tag = cipher.getAuthTag()
|
||||
return {
|
||||
iv: iv.toString('hex'),
|
||||
tag: tag.toString('hex'),
|
||||
data: encrypted.toString('hex'),
|
||||
}
|
||||
}
|
||||
|
||||
function decrypt(
|
||||
record: EncryptedRecord,
|
||||
key: Buffer,
|
||||
entryKey: string,
|
||||
): string {
|
||||
let iv: Buffer
|
||||
let tag: Buffer
|
||||
let data: Buffer
|
||||
try {
|
||||
iv = Buffer.from(record.iv, 'hex')
|
||||
tag = Buffer.from(record.tag, 'hex')
|
||||
data = Buffer.from(record.data, 'hex')
|
||||
} catch {
|
||||
throw new LocalVaultDecryptionError('corrupted record encoding')
|
||||
}
|
||||
|
||||
if (iv.length !== IV_BYTES || tag.length !== TAG_BYTES) {
|
||||
throw new LocalVaultDecryptionError('invalid IV or tag length')
|
||||
}
|
||||
|
||||
const decipher = createDecipheriv(ALGORITHM, key, iv)
|
||||
decipher.setAuthTag(tag)
|
||||
// F3: must supply the same AAD used during encryption
|
||||
decipher.setAAD(Buffer.from(entryKey, 'utf8'))
|
||||
let decrypted: Buffer
|
||||
try {
|
||||
decrypted = Buffer.concat([decipher.update(data), decipher.final()])
|
||||
} catch {
|
||||
// Do not leak partial decrypted bytes
|
||||
throw new LocalVaultDecryptionError(
|
||||
'authentication tag mismatch — wrong passphrase or tampered data',
|
||||
)
|
||||
}
|
||||
// H3 fix (codecov-100 audit): use a fatal TextDecoder so invalid UTF-8
|
||||
// surfaces as a thrown error instead of being silently replaced with
|
||||
// U+FFFD. AES-GCM authentication catches *most* tampering, but the
|
||||
// decryption succeeds before we get here — and a vault written by a
|
||||
// bug in an older version (or by a manual `local-vault.enc.json`
|
||||
// edit) could still contain non-UTF-8 bytes. Without this check the
|
||||
// caller would receive a lossy string and have no way to detect that
|
||||
// their secret has been corrupted.
|
||||
try {
|
||||
return new TextDecoder('utf-8', { fatal: true }).decode(decrypted)
|
||||
} catch {
|
||||
throw new LocalVaultDecryptionError(
|
||||
'decrypted payload is not valid UTF-8 — vault record may be corrupted',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Vault file I/O ────────────────────────────────────────────────────────────
|
||||
|
||||
async function readVaultFile(): Promise<VaultFile> {
|
||||
const filePath = getVaultFilePath()
|
||||
if (!existsSync(filePath)) return {}
|
||||
let raw: string
|
||||
try {
|
||||
raw = await readFile(filePath, 'utf8')
|
||||
} catch (err: unknown) {
|
||||
const code = (err as NodeJS.ErrnoException).code
|
||||
if (code === 'ENOENT') return {}
|
||||
// Rethrow unexpected read errors (permissions, hardware fault)
|
||||
throw err
|
||||
}
|
||||
// A2: differentiate parse error from absence
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(raw)
|
||||
} catch {
|
||||
throw new LocalVaultDecryptionError(
|
||||
'vault file is corrupt (invalid JSON) — restore from backup',
|
||||
)
|
||||
}
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
throw new LocalVaultDecryptionError(
|
||||
'vault file has unexpected format — restore from backup',
|
||||
)
|
||||
}
|
||||
return parsed as VaultFile
|
||||
}
|
||||
|
||||
async function writeVaultFile(data: VaultFile): Promise<void> {
|
||||
const claudeDir = getClaudeDir()
|
||||
if (!existsSync(claudeDir)) {
|
||||
mkdirSync(claudeDir, { recursive: true })
|
||||
}
|
||||
const filePath = getVaultFilePath()
|
||||
// C1: atomic write — tmp file + rename (POSIX rename(2) is atomic)
|
||||
const tmpPath = join(
|
||||
tmpdir(),
|
||||
`.local-vault-${randomBytes(8).toString('hex')}.tmp`,
|
||||
)
|
||||
try {
|
||||
await writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf8')
|
||||
renameSync(tmpPath, filePath)
|
||||
} catch (err) {
|
||||
// Clean up tmp on failure
|
||||
try {
|
||||
rmSync(tmpPath, { force: true })
|
||||
} catch {
|
||||
/* ignore cleanup error */
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/** Get or create the per-vault salt, storing it in the vault file. */
|
||||
async function getOrCreateSalt(vaultData: VaultFile): Promise<Buffer> {
|
||||
if (
|
||||
typeof vaultData['_salt'] === 'string' &&
|
||||
vaultData['_salt'].length === SALT_BYTES * 2
|
||||
) {
|
||||
return Buffer.from(vaultData['_salt'], 'hex')
|
||||
}
|
||||
// Generate new salt and persist it (the caller will write the vault file)
|
||||
const salt = randomBytes(SALT_BYTES)
|
||||
vaultData['_salt'] = salt.toString('hex')
|
||||
vaultData['_version'] = 2
|
||||
return salt
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function setSecret(key: string, value: string): Promise<void> {
|
||||
// D1: Guard against unbounded value sizes
|
||||
const byteLength = Buffer.byteLength(value, 'utf8')
|
||||
if (byteLength > MAX_SECRET_BYTES) {
|
||||
throw new LocalVaultValueTooLargeError(byteLength)
|
||||
}
|
||||
|
||||
// Primary: OS keychain
|
||||
try {
|
||||
await tryKeychain.set(key, value)
|
||||
await tryKeychain._addToIndex(key)
|
||||
return
|
||||
} catch (err: unknown) {
|
||||
if (!(err instanceof KeychainUnavailableError)) {
|
||||
throw err
|
||||
}
|
||||
// Keychain unavailable → fall through to file
|
||||
// A: Not silently swallowed; user gets a console warning each call
|
||||
logError(
|
||||
new Error(
|
||||
'[LocalVault] OS keychain not available, falling back to encrypted file. ' +
|
||||
'Install platform keychain or set CLAUDE_LOCAL_VAULT_PASSPHRASE env.',
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Fallback: encrypted file
|
||||
const passphrase = await getOrCreatePassphrase()
|
||||
const vaultData = await readVaultFile()
|
||||
const salt = await getOrCreateSalt(vaultData)
|
||||
|
||||
// B1: zero the key buffer after use regardless of success/failure
|
||||
const key256 = deriveKey(passphrase, salt)
|
||||
try {
|
||||
vaultData[key] = encrypt(value, key256, key)
|
||||
await writeVaultFile(vaultData)
|
||||
} finally {
|
||||
key256.fill(0)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSecret(key: string): Promise<string | null> {
|
||||
// Primary: OS keychain
|
||||
try {
|
||||
const val = await tryKeychain.get(key)
|
||||
return val
|
||||
} catch (err: unknown) {
|
||||
if (!(err instanceof KeychainUnavailableError)) {
|
||||
throw err
|
||||
}
|
||||
// Keychain unavailable — fall through to file (no log needed on read path)
|
||||
}
|
||||
|
||||
// Fallback: encrypted file
|
||||
const vaultData = await readVaultFile()
|
||||
const record = vaultData[key]
|
||||
if (!record || typeof record !== 'object' || Array.isArray(record))
|
||||
return null
|
||||
|
||||
// Detect old format: no salt field → record was encrypted without scrypt KDF.
|
||||
// The new AAD binding also means old records will fail authentication.
|
||||
// Instruct user to re-set secrets encrypted under the old format.
|
||||
if (typeof vaultData['_salt'] !== 'string') {
|
||||
throw new LocalVaultDecryptionError(
|
||||
'vault was created with an older format (no KDF salt). ' +
|
||||
'Please re-set your secrets using /local-vault set to upgrade to the secure format',
|
||||
)
|
||||
}
|
||||
|
||||
const passphrase = await getOrCreatePassphrase()
|
||||
const salt = Buffer.from(vaultData['_salt'], 'hex')
|
||||
|
||||
// B1: zero the key buffer after use
|
||||
const key256 = deriveKey(passphrase, salt)
|
||||
try {
|
||||
return decrypt(record as EncryptedRecord, key256, key)
|
||||
} finally {
|
||||
key256.fill(0)
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSecret(key: string): Promise<boolean> {
|
||||
// Primary: OS keychain
|
||||
try {
|
||||
const deleted = await tryKeychain.delete(key)
|
||||
await tryKeychain._removeFromIndex(key)
|
||||
return deleted
|
||||
} catch (err: unknown) {
|
||||
if (!(err instanceof KeychainUnavailableError)) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: encrypted file
|
||||
const vaultData = await readVaultFile()
|
||||
if (!(key in vaultData)) return false
|
||||
const updated = { ...vaultData }
|
||||
delete updated[key]
|
||||
await writeVaultFile(updated)
|
||||
return true
|
||||
}
|
||||
|
||||
export async function listKeys(): Promise<string[]> {
|
||||
// Primary: OS keychain index
|
||||
try {
|
||||
return await tryKeychain.list()
|
||||
} catch (err: unknown) {
|
||||
if (!(err instanceof KeychainUnavailableError)) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: encrypted file keys (no decryption needed — just keys)
|
||||
const vaultData = await readVaultFile()
|
||||
// Filter out internal metadata keys
|
||||
return Object.keys(vaultData).filter(k => !k.startsWith('_'))
|
||||
}
|
||||
|
||||
/** Mask a secret value for display: first 4 chars + ... + last 2 chars + length */
|
||||
export function maskSecret(value: string): string {
|
||||
if (value.length <= 6) return `***[len=${value.length}]`
|
||||
return `${value.slice(0, 4)}...[len=${value.length}]`
|
||||
}
|
||||
9
src/types/internal-modules.d.ts
vendored
9
src/types/internal-modules.d.ts
vendored
@@ -48,3 +48,12 @@ declare module 'asciichart' {
|
||||
export { plot }
|
||||
export default { plot }
|
||||
}
|
||||
|
||||
declare module '@napi-rs/keyring' {
|
||||
export class Entry {
|
||||
constructor(service: string, account: string)
|
||||
getPassword(): string | null
|
||||
setPassword(password: string): void
|
||||
deletePassword(): boolean
|
||||
}
|
||||
}
|
||||
|
||||
90
src/utils/__tests__/localValidate.test.ts
Normal file
90
src/utils/__tests__/localValidate.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { isValidKey, validateKey } from '../localValidate.js'
|
||||
|
||||
describe('validateKey', () => {
|
||||
test('rejects empty', () => {
|
||||
expect(() => validateKey('')).toThrow(/empty/i)
|
||||
})
|
||||
|
||||
test('rejects too long', () => {
|
||||
expect(() => validateKey('a'.repeat(129))).toThrow(/too long/i)
|
||||
})
|
||||
|
||||
test('rejects path separators', () => {
|
||||
expect(() => validateKey('a/b')).toThrow(/invalid key chars/i)
|
||||
expect(() => validateKey('a\\b')).toThrow(/invalid key chars/i)
|
||||
})
|
||||
|
||||
test('rejects null byte', () => {
|
||||
expect(() => validateKey('a\0b')).toThrow(/invalid key chars/i)
|
||||
})
|
||||
|
||||
test('rejects spaces', () => {
|
||||
expect(() => validateKey('a b')).toThrow(/invalid key chars/i)
|
||||
})
|
||||
|
||||
test('rejects unicode', () => {
|
||||
expect(() => validateKey('键名')).toThrow(/invalid key chars/i)
|
||||
})
|
||||
|
||||
test('rejects leading dot', () => {
|
||||
expect(() => validateKey('.gitconfig')).toThrow(/leading dot/i)
|
||||
expect(() => validateKey('..parent')).toThrow(/leading dot/i)
|
||||
expect(() => validateKey('.')).toThrow(/leading dot/i)
|
||||
})
|
||||
|
||||
test('rejects Windows reserved names (case-insensitive)', () => {
|
||||
for (const name of [
|
||||
'NUL',
|
||||
'CON',
|
||||
'PRN',
|
||||
'AUX',
|
||||
'COM1',
|
||||
'COM9',
|
||||
'LPT1',
|
||||
'LPT9',
|
||||
]) {
|
||||
expect(() => validateKey(name)).toThrow(/windows reserved/i)
|
||||
expect(() => validateKey(name.toLowerCase())).toThrow(/windows reserved/i)
|
||||
}
|
||||
})
|
||||
|
||||
test('accepts valid keys', () => {
|
||||
expect(() => validateKey('a')).not.toThrow()
|
||||
expect(() => validateKey('a_b')).not.toThrow()
|
||||
expect(() => validateKey('a-b')).not.toThrow()
|
||||
expect(() => validateKey('a.b')).not.toThrow()
|
||||
expect(() => validateKey('My_Key-2026.01')).not.toThrow()
|
||||
expect(() => validateKey('a'.repeat(128))).not.toThrow()
|
||||
})
|
||||
|
||||
test('M6: Windows reserved name with extension is REJECTED', () => {
|
||||
// Windows aliases NUL.txt → NUL device regardless of extension.
|
||||
expect(() => validateKey('NUL.txt')).toThrow(/windows reserved/i)
|
||||
expect(() => validateKey('CON.foo')).toThrow(/windows reserved/i)
|
||||
expect(() => validateKey('COM1.bak')).toThrow(/windows reserved/i)
|
||||
expect(() => validateKey('lpt9.dat')).toThrow(/windows reserved/i)
|
||||
})
|
||||
|
||||
test('Names containing reserved as substring are still allowed (myCON)', () => {
|
||||
expect(() => validateKey('myCON')).not.toThrow()
|
||||
expect(() => validateKey('CONfetti')).not.toThrow()
|
||||
})
|
||||
|
||||
test('L2: bare ".." is rejected (leading-dot guard)', () => {
|
||||
expect(() => validateKey('..')).toThrow(/leading dot/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValidKey', () => {
|
||||
test('returns true for valid keys', () => {
|
||||
expect(isValidKey('a_b')).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false for invalid keys', () => {
|
||||
expect(isValidKey('')).toBe(false)
|
||||
expect(isValidKey('.git')).toBe(false)
|
||||
expect(isValidKey('a/b')).toBe(false)
|
||||
expect(isValidKey('NUL')).toBe(false)
|
||||
})
|
||||
})
|
||||
56
src/utils/localValidate.ts
Normal file
56
src/utils/localValidate.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Shared validation utilities for /local-memory and /local-vault input names.
|
||||
*
|
||||
* Both LocalMemoryRecallTool (PR-1) and VaultHttpFetchTool (PR-2) need a
|
||||
* consistent, path-safe, OS-portable key naming scheme. multiStore.ts also
|
||||
* uses validateKey for entry keys after PR-0a key-collision fix.
|
||||
*
|
||||
* Allowed: letters, digits, dot, underscore, hyphen.
|
||||
* Length 1..128.
|
||||
* Rejected:
|
||||
* - empty / too long
|
||||
* - any character outside [A-Za-z0-9._-]
|
||||
* - leading dot (hidden file pattern, e.g. ".gitconfig")
|
||||
* - Windows reserved device names (NUL, CON, COM1, etc.) — would silently
|
||||
* write to a device on Windows and lose data
|
||||
*/
|
||||
|
||||
const KEY_REGEX = /^[A-Za-z0-9._-]+$/
|
||||
// Windows treats device names as reserved REGARDLESS of extension —
|
||||
// `NUL.txt`, `CON.foo`, `COM1.bak` all alias to the device. So we must
|
||||
// match the basename component (everything before the first dot) against
|
||||
// the reserved set, not just the entire key.
|
||||
const WINDOWS_RESERVED_BASENAME = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i
|
||||
const MAX_KEY_LENGTH = 128
|
||||
|
||||
export function validateKey(key: string): void {
|
||||
if (!key) {
|
||||
throw new Error('Empty key')
|
||||
}
|
||||
if (key.length > MAX_KEY_LENGTH) {
|
||||
throw new Error(`Key too long (max ${MAX_KEY_LENGTH})`)
|
||||
}
|
||||
if (!KEY_REGEX.test(key)) {
|
||||
throw new Error(`Invalid key chars: ${JSON.stringify(key)}`)
|
||||
}
|
||||
if (key.startsWith('.')) {
|
||||
throw new Error('Leading dot forbidden')
|
||||
}
|
||||
// M6 fix: match the basename (pre-dot component) so e.g. NUL.txt and
|
||||
// CON.foo are also rejected. On Windows these still alias to the device
|
||||
// file regardless of extension and would silently lose data.
|
||||
const basenameComponent = key.includes('.') ? key.split('.')[0]! : key
|
||||
if (WINDOWS_RESERVED_BASENAME.test(basenameComponent)) {
|
||||
throw new Error(`Windows reserved name: ${key}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true iff key would pass validateKey (no throw). Useful for guards. */
|
||||
export function isValidKey(key: string): boolean {
|
||||
try {
|
||||
validateKey(key)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
14
src/utils/sanitizeId.ts
Normal file
14
src/utils/sanitizeId.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Sanitize an ID for use in error messages.
|
||||
*
|
||||
* Security invariant: full IDs (vault_id, credential_id, agent_id, etc.) must
|
||||
* not appear in error messages as they may be leaked into logs, bug reports,
|
||||
* or user-facing text. Expose only the first 8 characters.
|
||||
*
|
||||
* H3: single source of truth extracted from the 4 P2 API client files
|
||||
* (vaultsApi, agentsApi, memoryStoresApi, skillsApi).
|
||||
*/
|
||||
export function sanitizeId(id: string): string {
|
||||
if (id.length <= 8) return id
|
||||
return `${id.slice(0, 8)}…`
|
||||
}
|
||||
Reference in New Issue
Block a user