From 62c692c5a193643323b82a8734ad1a3d4fdf038c Mon Sep 17 00:00:00 2001 From: OpenCode Date: Mon, 17 Aug 2026 17:12:26 +0700 Subject: [PATCH] Add managed provider keys: encrypted store + env>DB resolver AES-256-GCM encryption (KeyCipher) with master key from NEXUS_MASTER_KEY, never persisted. ProvidersStore in SQLite: CRUD, enable/disable, default, encrypted api keys, masked views for UI. KeyResolver applies env>DB precedence and supports multiple providers. context.ts registers providers dynamically from resolved config; backward compatible with env-only setups. --- NexusAI/.env.example | 10 + NexusAI/README.md | 26 +++ NexusAI/packages/core/src/config.ts | 6 + .../packages/core/src/db/providers-store.ts | 187 ++++++++++++++++++ NexusAI/packages/core/src/index.ts | 3 + NexusAI/packages/core/src/security/crypto.ts | 88 +++++++++ .../packages/core/src/security/resolver.ts | 96 +++++++++ NexusAI/packages/mcp-server/src/context.ts | 64 ++++-- NexusAI/packages/mcp-server/src/handlers.ts | 2 +- 9 files changed, 469 insertions(+), 13 deletions(-) create mode 100644 NexusAI/packages/core/src/db/providers-store.ts create mode 100644 NexusAI/packages/core/src/security/crypto.ts create mode 100644 NexusAI/packages/core/src/security/resolver.ts diff --git a/NexusAI/.env.example b/NexusAI/.env.example index 6f6b070..9f28979 100644 --- a/NexusAI/.env.example +++ b/NexusAI/.env.example @@ -14,6 +14,16 @@ NEXUS_EMBED_MAX_BYTES=10485760 # Log level: debug | info | warn | error (logs go to stderr) NEXUS_LOG_LEVEL=info +# --- Managed provider keys (optional, for admin-managed keys) --- +# Master secret used to encrypt provider API keys stored in SQLite (AES-256-GCM). +# Accepts a 64-char hex string or any passphrase. NEVER stored in the DB. +# If unset, only env keys (e.g. NORDROUTER_API_KEY) are used. +# Generate: openssl rand -hex 32 +# NEXUS_MASTER_KEY= +# Optional separate DB for the providers store (defaults to NEXUS_DB_PATH) +# NEXUS_PROVIDERS_DB_PATH=./media-output/nexus.sqlite +# Key precedence: _API_KEY env > encrypted DB entry + # --- Local STT (faster-whisper via uv sidecar) --- # Launcher: "uv" (recommended, auto-installs deps) or a python interpreter path NEXUS_STT_PYTHON=uv diff --git a/NexusAI/README.md b/NexusAI/README.md index 38a2d3c..9a4b1a3 100644 --- a/NexusAI/README.md +++ b/NexusAI/README.md @@ -114,6 +114,32 @@ npm run build - **Журнал** SQLite (`nexus.sqlite` в media-dir) пишет каждую операцию; `nexus_usage_stats` агрегирует траты. - MCP работает по stdio — **все логи идут в stderr**. +## Ключи провайдеров + +Ключи можно задавать двумя способами, с приоритетом **env → БД**: + +1. **Env (приоритет)** — `NORDROUTER_API_KEY` (и `_API_KEY` для других провайдеров). Просто для CI и локального запуска. +2. **Зашифрованная БД** — управляется из будущей админки. Ключи хранятся в SQLite, зашифрованные **AES-256-GCM** на мастер-ключе `NEXUS_MASTER_KEY` (сам мастер-ключ — только в env, в БД его нет). + +Свойства слоя: +- В git/логи/БД сырой ключ не попадает; в UI показывается только маска `sk-…last4`. +- Бэкап БД не раскрывает ключи (нужен мастер-ключ). +- Ротация `NEXUS_MASTER_KEY` инвалидирует зашифрованные ключи — их нужно ввести заново. +- Без `NEXUS_MASTER_KEY` работают только env-ключи (сервер предупреждает в stderr). + +Программное управление (пока до админки) — через `@nexusai/core`: + +```ts +import { KeyCipher, ProvidersStore } from '@nexusai/core'; +const cipher = new KeyCipher(process.env.NEXUS_MASTER_KEY); +const store = new ProvidersStore('./media-output/nexus.sqlite', cipher); +store.upsert({ name: 'nordrouter', type: 'nordrouter', apiKey: 'sk-...', isDefault: true }); +store.setEnabled('nordrouter', true); +store.listViews(); // masked view for UI +``` + +Генерация мастер-ключа: `openssl rand -hex 32`. + ## STT в ограниченных сетях faster-whisper скачивает модель с HuggingFace. Если `huggingface.co` недоступен: diff --git a/NexusAI/packages/core/src/config.ts b/NexusAI/packages/core/src/config.ts index 2ec364e..7f14143 100644 --- a/NexusAI/packages/core/src/config.ts +++ b/NexusAI/packages/core/src/config.ts @@ -19,6 +19,10 @@ export interface NexusConfig { defaultProvider: string; mediaDir: string; dbPath: string; + /** SQLite path for the encrypted providers store (defaults to same as dbPath). */ + providersDbPath: string; + /** Master secret for encrypting stored API keys (env NEXUS_MASTER_KEY). */ + masterKey?: string; embedMaxBytes: number; nordrouter: NordRouterConfig; stt: SttConfig; @@ -40,6 +44,8 @@ export function loadConfig(): NexusConfig { defaultProvider: process.env.NEXUS_DEFAULT_PROVIDER || 'nordrouter', mediaDir, dbPath, + providersDbPath: process.env.NEXUS_PROVIDERS_DB_PATH || dbPath, + masterKey: process.env.NEXUS_MASTER_KEY, embedMaxBytes: envInt('NEXUS_EMBED_MAX_BYTES', 10 * 1024 * 1024), nordrouter: { apiKey: process.env.NORDROUTER_API_KEY, diff --git a/NexusAI/packages/core/src/db/providers-store.ts b/NexusAI/packages/core/src/db/providers-store.ts new file mode 100644 index 0000000..b81db80 --- /dev/null +++ b/NexusAI/packages/core/src/db/providers-store.ts @@ -0,0 +1,187 @@ +import Database from 'better-sqlite3'; +import * as fs from 'fs'; +import * as path from 'path'; +import { NexusError } from '../shared/errors.js'; +import { createLogger } from '../shared/logger.js'; +import { KeyCipher, maskSecret } from '../security/crypto.js'; + +const log = createLogger('providers-store'); + +export interface ProviderRow { + name: string; + type: string; // e.g. "nordrouter" | "routerai" | "openrouter" + baseUrl: string | null; + enabled: boolean; + isDefault: boolean; + createdAt: string; + updatedAt: string; +} + +/** Public view: never exposes the raw key, only a masked hint. */ +export interface ProviderView extends ProviderRow { + hasKey: boolean; + keyHint: string | null; +} + +export interface UpsertProviderInput { + name: string; + type: string; + baseUrl?: string | null; + apiKey?: string; // plaintext; will be encrypted at rest + enabled?: boolean; + isDefault?: boolean; +} + +/** + * SQLite-backed provider registry with encrypted API keys. + * Managed by the admin (Phase 2); read by the MCP server at startup. + */ +export class ProvidersStore { + private readonly db: Database.Database; + + constructor(dbPath: string, private readonly cipher: KeyCipher) { + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + this.db = new Database(dbPath); + this.db.pragma('journal_mode = WAL'); + this.init(); + } + + private init(): void { + this.db.exec(` + CREATE TABLE IF NOT EXISTS providers ( + name TEXT PRIMARY KEY, + type TEXT NOT NULL, + base_url TEXT, + api_key_enc TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + is_default INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + `); + } + + upsert(input: UpsertProviderInput): void { + const now = new Date().toISOString(); + const encKey = input.apiKey !== undefined ? this.cipher.encrypt(input.apiKey) : undefined; + + const existing = this.db.prepare('SELECT name FROM providers WHERE name = ?').get(input.name); + + if (existing) { + // Update only provided fields; keep existing key if not supplied. + this.db + .prepare( + `UPDATE providers SET + type = @type, + base_url = COALESCE(@baseUrl, base_url), + api_key_enc = COALESCE(@encKey, api_key_enc), + enabled = @enabled, + is_default = @isDefault, + updated_at = @now + WHERE name = @name` + ) + .run({ + name: input.name, + type: input.type, + baseUrl: input.baseUrl ?? null, + encKey: encKey ?? null, + enabled: input.enabled === false ? 0 : 1, + isDefault: input.isDefault ? 1 : 0, + now, + }); + } else { + this.db + .prepare( + `INSERT INTO providers (name, type, base_url, api_key_enc, enabled, is_default, created_at, updated_at) + VALUES (@name, @type, @baseUrl, @encKey, @enabled, @isDefault, @now, @now)` + ) + .run({ + name: input.name, + type: input.type, + baseUrl: input.baseUrl ?? null, + encKey: encKey ?? null, + enabled: input.enabled === false ? 0 : 1, + isDefault: input.isDefault ? 1 : 0, + now, + }); + } + + if (input.isDefault) this.setDefault(input.name); + log.info(`provider upserted: ${input.name}`); + } + + setEnabled(name: string, enabled: boolean): void { + const r = this.db + .prepare('UPDATE providers SET enabled = ?, updated_at = ? WHERE name = ?') + .run(enabled ? 1 : 0, new Date().toISOString(), name); + if (r.changes === 0) throw new NexusError('NOT_FOUND', `Provider not found: ${name}`); + } + + setDefault(name: string): void { + const exists = this.db.prepare('SELECT 1 FROM providers WHERE name = ?').get(name); + if (!exists) throw new NexusError('NOT_FOUND', `Provider not found: ${name}`); + const tx = this.db.transaction(() => { + this.db.prepare('UPDATE providers SET is_default = 0').run(); + this.db + .prepare('UPDATE providers SET is_default = 1, updated_at = ? WHERE name = ?') + .run(new Date().toISOString(), name); + }); + tx(); + } + + remove(name: string): void { + this.db.prepare('DELETE FROM providers WHERE name = ?').run(name); + } + + /** Return the decrypted API key for a provider, or undefined. */ + getApiKey(name: string): string | undefined { + const row = this.db.prepare('SELECT api_key_enc FROM providers WHERE name = ?').get(name) as + | { api_key_enc: string | null } + | undefined; + if (!row?.api_key_enc) return undefined; + return this.cipher.decrypt(row.api_key_enc); + } + + get(name: string): ProviderRow | undefined { + const row = this.db.prepare('SELECT * FROM providers WHERE name = ?').get(name) as any; + return row ? this.mapRow(row) : undefined; + } + + list(): ProviderRow[] { + const rows = this.db.prepare('SELECT * FROM providers ORDER BY name').all() as any[]; + return rows.map((r) => this.mapRow(r)); + } + + /** Admin-safe listing: masked key hint, never the raw key. */ + listViews(): ProviderView[] { + const rows = this.db.prepare('SELECT * FROM providers ORDER BY name').all() as any[]; + return rows.map((r) => { + const base = this.mapRow(r); + let keyHint: string | null = null; + if (r.api_key_enc) { + try { + keyHint = maskSecret(this.cipher.decrypt(r.api_key_enc)); + } catch { + keyHint = '(undecryptable)'; + } + } + return { ...base, hasKey: Boolean(r.api_key_enc), keyHint }; + }); + } + + private mapRow(r: any): ProviderRow { + return { + name: r.name, + type: r.type, + baseUrl: r.base_url, + enabled: Boolean(r.enabled), + isDefault: Boolean(r.is_default), + createdAt: r.created_at, + updatedAt: r.updated_at, + }; + } + + close(): void { + this.db.close(); + } +} diff --git a/NexusAI/packages/core/src/index.ts b/NexusAI/packages/core/src/index.ts index 6e4cb20..4a61391 100644 --- a/NexusAI/packages/core/src/index.ts +++ b/NexusAI/packages/core/src/index.ts @@ -4,6 +4,9 @@ export * from './shared/logger.js'; export * from './shared/http.js'; export * from './shared/polling.js'; export * from './db/journal.js'; +export * from './db/providers-store.js'; +export * from './security/crypto.js'; +export * from './security/resolver.js'; export * from './media/storage.js'; export * from './media/content.js'; export * from './providers/types.js'; diff --git a/NexusAI/packages/core/src/security/crypto.ts b/NexusAI/packages/core/src/security/crypto.ts new file mode 100644 index 0000000..ed26e27 --- /dev/null +++ b/NexusAI/packages/core/src/security/crypto.ts @@ -0,0 +1,88 @@ +import * as crypto from 'crypto'; +import { NexusError } from '../shared/errors.js'; + +/** + * AES-256-GCM encryption for API keys at rest. + * + * The master key is provided via env NEXUS_MASTER_KEY and NEVER stored in the DB. + * Only encrypted key material lives in SQLite, so backups / inspection of the DB + * do not leak provider API keys. Rotating NEXUS_MASTER_KEY invalidates stored + * ciphertext (re-enter keys), which is the intended security trade-off. + * + * Ciphertext format (base64): [ 12-byte IV | 16-byte auth tag | ciphertext ] + */ + +const ALGO = 'aes-256-gcm'; +const IV_LEN = 12; +const TAG_LEN = 16; + +/** + * Derive a 32-byte key from the env master secret. Accepts either a 64-char + * hex string (used directly) or any passphrase (hashed with SHA-256). + */ +export function deriveMasterKey(secret: string | undefined): Buffer | undefined { + if (!secret) return undefined; + const trimmed = secret.trim(); + if (/^[0-9a-fA-F]{64}$/.test(trimmed)) { + return Buffer.from(trimmed, 'hex'); + } + return crypto.createHash('sha256').update(trimmed, 'utf8').digest(); +} + +export class KeyCipher { + private readonly key?: Buffer; + + constructor(masterSecret: string | undefined) { + this.key = deriveMasterKey(masterSecret); + } + + get enabled(): boolean { + return this.key !== undefined; + } + + private requireKey(): Buffer { + if (!this.key) { + throw new NexusError( + 'CONFIG', + 'NEXUS_MASTER_KEY is not set; cannot encrypt/decrypt stored API keys' + ); + } + return this.key; + } + + encrypt(plaintext: string): string { + const key = this.requireKey(); + const iv = crypto.randomBytes(IV_LEN); + const cipher = crypto.createCipheriv(ALGO, key, iv); + const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + return Buffer.concat([iv, tag, enc]).toString('base64'); + } + + decrypt(payload: string): string { + const key = this.requireKey(); + const raw = Buffer.from(payload, 'base64'); + if (raw.length < IV_LEN + TAG_LEN) { + throw new NexusError('CONFIG', 'Malformed encrypted key payload'); + } + const iv = raw.subarray(0, IV_LEN); + const tag = raw.subarray(IV_LEN, IV_LEN + TAG_LEN); + const enc = raw.subarray(IV_LEN + TAG_LEN); + const decipher = crypto.createDecipheriv(ALGO, key, iv); + decipher.setAuthTag(tag); + try { + return Buffer.concat([decipher.update(enc), decipher.final()]).toString('utf8'); + } catch { + throw new NexusError( + 'CONFIG', + 'Failed to decrypt API key (wrong NEXUS_MASTER_KEY or corrupted data)' + ); + } + } +} + +/** Mask a secret for display: keep last 4 chars. */ +export function maskSecret(secret: string): string { + if (secret.length <= 4) return '****'; + return `${secret.slice(0, 3)}…${secret.slice(-4)}`; +} diff --git a/NexusAI/packages/core/src/security/resolver.ts b/NexusAI/packages/core/src/security/resolver.ts new file mode 100644 index 0000000..fff82bb --- /dev/null +++ b/NexusAI/packages/core/src/security/resolver.ts @@ -0,0 +1,96 @@ +import type { NexusConfig } from '../config.js'; +import type { ProvidersStore } from '../db/providers-store.js'; +import { createLogger } from '../shared/logger.js'; + +const log = createLogger('key-resolver'); + +export interface ResolvedProvider { + name: string; + type: string; + baseUrl: string; + apiKey?: string; + enabled: boolean; + isDefault: boolean; + /** Where the key came from, for diagnostics. */ + keySource: 'env' | 'db' | 'none'; +} + +/** + * Resolve effective provider settings. + * + * Precedence for keys: ENV wins over DB. This keeps simple/CI setups working + * (just set NORDROUTER_API_KEY) while allowing admin-managed keys in the DB. + * + * Built-in providers (nordrouter) are always considered; DB rows can add more + * or override base URL / enabled / default. + */ +export class KeyResolver { + constructor( + private readonly config: NexusConfig, + private readonly store?: ProvidersStore + ) {} + + private envKeyFor(providerName: string): string | undefined { + // Convention: _API_KEY, e.g. NORDROUTER_API_KEY + const envName = `${providerName.toUpperCase()}_API_KEY`; + return process.env[envName]; + } + + private envBaseUrlFor(providerName: string): string | undefined { + const envName = `${providerName.toUpperCase()}_BASE_URL`; + return process.env[envName]; + } + + /** Resolve one provider by name. */ + resolve(providerName: string, defaultBaseUrl: string): ResolvedProvider { + const dbRow = this.store?.get(providerName); + const envKey = this.envKeyFor(providerName); + const dbKey = this.store?.getApiKey(providerName); + + const apiKey = envKey ?? dbKey; + const keySource: ResolvedProvider['keySource'] = envKey ? 'env' : dbKey ? 'db' : 'none'; + + const baseUrl = + this.envBaseUrlFor(providerName) || dbRow?.baseUrl || defaultBaseUrl; + + return { + name: providerName, + type: dbRow?.type ?? providerName, + baseUrl, + apiKey, + enabled: dbRow ? dbRow.enabled : true, + isDefault: dbRow?.isDefault ?? false, + keySource, + }; + } + + /** All providers to register: built-ins + any DB-managed ones. */ + resolveAll(builtins: Array<{ name: string; defaultBaseUrl: string }>): ResolvedProvider[] { + const seen = new Set(); + const out: ResolvedProvider[] = []; + + for (const b of builtins) { + out.push(this.resolve(b.name, b.defaultBaseUrl)); + seen.add(b.name); + } + + for (const row of this.store?.list() ?? []) { + if (seen.has(row.name)) continue; + out.push(this.resolve(row.name, row.baseUrl || '')); + seen.add(row.name); + } + + log.debug( + 'resolved providers', + out.map((p) => ({ name: p.name, key: p.keySource, enabled: p.enabled })) + ); + return out; + } + + /** The effective default provider name. */ + resolveDefault(resolved: ResolvedProvider[]): string { + const dbDefault = resolved.find((p) => p.isDefault && p.enabled); + if (dbDefault) return dbDefault.name; + return this.config.defaultProvider; + } +} diff --git a/NexusAI/packages/mcp-server/src/context.ts b/NexusAI/packages/mcp-server/src/context.ts index 92a2037..b1ecb1a 100644 --- a/NexusAI/packages/mcp-server/src/context.ts +++ b/NexusAI/packages/mcp-server/src/context.ts @@ -1,7 +1,10 @@ import { Journal, + KeyCipher, + KeyResolver, MediaStorage, ProviderRegistry, + ProvidersStore, createNordRouterProviders, loadConfig, LocalWhisperProvider, @@ -16,10 +19,17 @@ export interface AppContext { registry: ProviderRegistry; storage: MediaStorage; journal: Journal; + providersStore: ProvidersStore; + /** Effective default provider name after resolving DB/env. */ + defaultProvider: string; } +/** Built-in providers always considered, even without a DB row. */ +const BUILTINS = [{ name: 'nordrouter', defaultBaseUrl: 'https://nordrouter.com' }]; + /** - * Wire up all providers, storage and the journal once at startup. + * Wire up providers, storage, journal and the encrypted providers store. + * API keys resolve with ENV > DB precedence (see KeyResolver). */ export function buildContext(): AppContext { const config = loadConfig(); @@ -27,20 +37,50 @@ export function buildContext(): AppContext { const journal = new Journal(config.dbPath); const registry = new ProviderRegistry(); - // NordRouter (media + search). Registered even without a key so error messages - // are clear when a tool is actually invoked. - try { - const nr = createNordRouterProviders({ config: config.nordrouter, storage, save: true }); - registry.registerMedia(nr.media); - registry.registerSearch(nr.search); - log.info('registered nordrouter provider (media + search)'); - } catch (err) { - log.warn('nordrouter not registered', err instanceof Error ? err.message : String(err)); + const cipher = new KeyCipher(config.masterKey); + const providersStore = new ProvidersStore(config.providersDbPath, cipher); + const resolver = new KeyResolver(config, providersStore); + + if (!cipher.enabled) { + log.warn('NEXUS_MASTER_KEY not set — DB-stored API keys are unavailable; using env keys only'); + } + + const resolved = resolver.resolveAll(BUILTINS); + const defaultProvider = resolver.resolveDefault(resolved); + + for (const p of resolved) { + if (!p.enabled) { + log.info(`provider "${p.name}" disabled — skipped`); + continue; + } + if (p.type === 'nordrouter') { + if (!p.apiKey) { + log.warn(`provider "${p.name}" has no API key (env or DB) — tools will error when used`); + } + try { + const nr = createNordRouterProviders({ + config: { apiKey: p.apiKey, baseUrl: p.baseUrl }, + storage, + save: true, + }); + // Register under the provider's own name so multiple nordrouter-type + // providers with different keys can coexist. + Object.defineProperty(nr.media, 'name', { value: p.name }); + Object.defineProperty(nr.search, 'name', { value: p.name }); + registry.registerMedia(nr.media); + registry.registerSearch(nr.search); + log.info(`registered provider "${p.name}" (media + search, key: ${p.keySource})`); + } catch (err) { + log.warn(`failed to register "${p.name}"`, err instanceof Error ? err.message : String(err)); + } + } else { + log.warn(`provider type "${p.type}" (${p.name}) not implemented yet — skipped`); + } } - // Local STT (faster-whisper sidecar). registry.registerStt(new LocalWhisperProvider(config.stt)); log.info('registered local STT provider'); + log.info(`default provider: ${defaultProvider}`); - return { config, registry, storage, journal }; + return { config, registry, storage, journal, providersStore, defaultProvider }; } diff --git a/NexusAI/packages/mcp-server/src/handlers.ts b/NexusAI/packages/mcp-server/src/handlers.ts index b5d0315..a8cdf2f 100644 --- a/NexusAI/packages/mcp-server/src/handlers.ts +++ b/NexusAI/packages/mcp-server/src/handlers.ts @@ -45,7 +45,7 @@ export class Handlers { constructor(private readonly ctx: AppContext) {} private providerName(explicit?: string): string { - return explicit || this.ctx.config.defaultProvider; + return explicit || this.ctx.defaultProvider; } async handle(name: string, args: Record): Promise {