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
+6
View File
@@ -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();
}
}
+3
View File
@@ -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;
}
}