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:
claude-code-best
2026-05-09 23:04:07 +08:00
parent eebda578bf
commit b8d86e5279
8 changed files with 1325 additions and 0 deletions

View 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)
})
})

View 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
View 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)}`
}