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.
This commit is contained in:
OpenCode
2026-08-17 17:12:26 +07:00
parent 02fc536e85
commit 62c692c5a1
9 changed files with 469 additions and 13 deletions
+10
View File
@@ -14,6 +14,16 @@ NEXUS_EMBED_MAX_BYTES=10485760
# Log level: debug | info | warn | error (logs go to stderr) # Log level: debug | info | warn | error (logs go to stderr)
NEXUS_LOG_LEVEL=info 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: <NAME>_API_KEY env > encrypted DB entry
# --- Local STT (faster-whisper via uv sidecar) --- # --- Local STT (faster-whisper via uv sidecar) ---
# Launcher: "uv" (recommended, auto-installs deps) or a python interpreter path # Launcher: "uv" (recommended, auto-installs deps) or a python interpreter path
NEXUS_STT_PYTHON=uv NEXUS_STT_PYTHON=uv
+26
View File
@@ -114,6 +114,32 @@ npm run build
- **Журнал** SQLite (`nexus.sqlite` в media-dir) пишет каждую операцию; `nexus_usage_stats` агрегирует траты. - **Журнал** SQLite (`nexus.sqlite` в media-dir) пишет каждую операцию; `nexus_usage_stats` агрегирует траты.
- MCP работает по stdio — **все логи идут в stderr**. - MCP работает по stdio — **все логи идут в stderr**.
## Ключи провайдеров
Ключи можно задавать двумя способами, с приоритетом **env → БД**:
1. **Env (приоритет)**`NORDROUTER_API_KEY``<NAME>_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 в ограниченных сетях ## STT в ограниченных сетях
faster-whisper скачивает модель с HuggingFace. Если `huggingface.co` недоступен: faster-whisper скачивает модель с HuggingFace. Если `huggingface.co` недоступен:
+6
View File
@@ -19,6 +19,10 @@ export interface NexusConfig {
defaultProvider: string; defaultProvider: string;
mediaDir: string; mediaDir: string;
dbPath: 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; embedMaxBytes: number;
nordrouter: NordRouterConfig; nordrouter: NordRouterConfig;
stt: SttConfig; stt: SttConfig;
@@ -40,6 +44,8 @@ export function loadConfig(): NexusConfig {
defaultProvider: process.env.NEXUS_DEFAULT_PROVIDER || 'nordrouter', defaultProvider: process.env.NEXUS_DEFAULT_PROVIDER || 'nordrouter',
mediaDir, mediaDir,
dbPath, dbPath,
providersDbPath: process.env.NEXUS_PROVIDERS_DB_PATH || dbPath,
masterKey: process.env.NEXUS_MASTER_KEY,
embedMaxBytes: envInt('NEXUS_EMBED_MAX_BYTES', 10 * 1024 * 1024), embedMaxBytes: envInt('NEXUS_EMBED_MAX_BYTES', 10 * 1024 * 1024),
nordrouter: { nordrouter: {
apiKey: process.env.NORDROUTER_API_KEY, apiKey: process.env.NORDROUTER_API_KEY,
@@ -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();
}
}
+3
View File
@@ -4,6 +4,9 @@ export * from './shared/logger.js';
export * from './shared/http.js'; export * from './shared/http.js';
export * from './shared/polling.js'; export * from './shared/polling.js';
export * from './db/journal.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/storage.js';
export * from './media/content.js'; export * from './media/content.js';
export * from './providers/types.js'; export * from './providers/types.js';
@@ -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)}`;
}
@@ -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: <NAME>_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<string>();
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;
}
}
+48 -8
View File
@@ -1,7 +1,10 @@
import { import {
Journal, Journal,
KeyCipher,
KeyResolver,
MediaStorage, MediaStorage,
ProviderRegistry, ProviderRegistry,
ProvidersStore,
createNordRouterProviders, createNordRouterProviders,
loadConfig, loadConfig,
LocalWhisperProvider, LocalWhisperProvider,
@@ -16,10 +19,17 @@ export interface AppContext {
registry: ProviderRegistry; registry: ProviderRegistry;
storage: MediaStorage; storage: MediaStorage;
journal: Journal; 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 { export function buildContext(): AppContext {
const config = loadConfig(); const config = loadConfig();
@@ -27,20 +37,50 @@ export function buildContext(): AppContext {
const journal = new Journal(config.dbPath); const journal = new Journal(config.dbPath);
const registry = new ProviderRegistry(); const registry = new ProviderRegistry();
// NordRouter (media + search). Registered even without a key so error messages const cipher = new KeyCipher(config.masterKey);
// are clear when a tool is actually invoked. 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 { try {
const nr = createNordRouterProviders({ config: config.nordrouter, storage, save: true }); 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.registerMedia(nr.media);
registry.registerSearch(nr.search); registry.registerSearch(nr.search);
log.info('registered nordrouter provider (media + search)'); log.info(`registered provider "${p.name}" (media + search, key: ${p.keySource})`);
} catch (err) { } catch (err) {
log.warn('nordrouter not registered', err instanceof Error ? err.message : String(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)); registry.registerStt(new LocalWhisperProvider(config.stt));
log.info('registered local STT provider'); log.info('registered local STT provider');
log.info(`default provider: ${defaultProvider}`);
return { config, registry, storage, journal }; return { config, registry, storage, journal, providersStore, defaultProvider };
} }
+1 -1
View File
@@ -45,7 +45,7 @@ export class Handlers {
constructor(private readonly ctx: AppContext) {} constructor(private readonly ctx: AppContext) {}
private providerName(explicit?: string): string { private providerName(explicit?: string): string {
return explicit || this.ctx.config.defaultProvider; return explicit || this.ctx.defaultProvider;
} }
async handle(name: string, args: Record<string, unknown>): Promise<McpResult> { async handle(name: string, args: Record<string, unknown>): Promise<McpResult> {