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:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>): Promise<McpResult> {
|
||||
|
||||
Reference in New Issue
Block a user