feat: add secret settings manager and migration for legacy settings

- Implemented SecretSettingsManager to handle secret settings with encryption.
- Added functionality to migrate legacy plaintext settings into encrypted storage.
- Introduced methods for setting, getting, and clearing secret settings.
- Created tests for verifying the migration and canonicalization of secret settings.
- Updated app state to handle service updates via socket communication.
- Added interface for push service updates to manage service state changes.
This commit is contained in:
2026-04-19 01:47:06 +00:00
parent 618d4d674f
commit 061ce7c3f2
17 changed files with 413 additions and 73 deletions
+35
View File
@@ -26,6 +26,7 @@ import type { TBindValue } from './types.ts';
import { logger } from '../logging.ts';
import { getErrorMessage } from '../utils/error.ts';
import { MigrationRunner } from './migrations/index.ts';
import { SecretSettingsManager } from './secret-settings.ts';
// Import repositories
import {
@@ -50,6 +51,7 @@ export class OneboxDatabase {
private metricsRepo!: MetricsRepository;
private platformRepo!: PlatformRepository;
private backupRepo!: BackupRepository;
public secretSettings!: SecretSettingsManager;
constructor(dbPath = './.nogit/onebox.db') {
this.dbPath = dbPath;
@@ -84,6 +86,7 @@ export class OneboxDatabase {
this.metricsRepo = new MetricsRepository(queryFn);
this.platformRepo = new PlatformRepository(queryFn);
this.backupRepo = new BackupRepository(queryFn);
this.secretSettings = new SecretSettingsManager(this.authRepo);
} catch (error) {
logger.error(`Failed to initialize database: ${getErrorMessage(error)}`);
throw error;
@@ -229,6 +232,14 @@ export class OneboxDatabase {
)
`);
this.query(`
CREATE TABLE IF NOT EXISTS secret_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
)
`);
// Version table for migrations
this.query(`
CREATE TABLE IF NOT EXISTS migrations (
@@ -333,10 +344,34 @@ export class OneboxDatabase {
this.authRepo.setSetting(key, value);
}
deleteSetting(key: string): void {
this.authRepo.deleteSetting(key);
}
getAllSettings(): Record<string, string> {
return this.authRepo.getAllSettings();
}
async getSecretSetting(key: string): Promise<string | null> {
return await this.secretSettings.get(key);
}
async setSecretSetting(key: string, value: string | null): Promise<void> {
await this.secretSettings.set(key, value);
}
async hasSecretSetting(key: string): Promise<boolean> {
return await this.secretSettings.has(key);
}
isSecretSettingKey(key: string): boolean {
return this.secretSettings.isSecretKey(key);
}
getCanonicalSecretSettingKeys(): string[] {
return this.secretSettings.getCanonicalKeys();
}
// ============ Users CRUD (delegated to repository) ============
async createUser(user: Omit<IUser, 'id'>): Promise<IUser> {
@@ -70,6 +70,10 @@ export class AuthRepository extends BaseRepository {
);
}
deleteSetting(key: string): void {
this.query('DELETE FROM settings WHERE key = ?', [key]);
}
getAllSettings(): Record<string, string> {
const rows = this.query('SELECT key, value FROM settings');
const settings: Record<string, string> = {};
@@ -80,4 +84,24 @@ export class AuthRepository extends BaseRepository {
}
return settings;
}
getSecretSetting(key: string): string | null {
const rows = this.query('SELECT value FROM secret_settings WHERE key = ?', [key]);
if (rows.length === 0) return null;
const value = (rows[0] as any).value || rows[0][0];
return value ? String(value) : null;
}
setSecretSetting(key: string, value: string): void {
const now = Date.now();
this.query(
'INSERT OR REPLACE INTO secret_settings (key, value, updated_at) VALUES (?, ?, ?)',
[key, value, now],
);
}
deleteSecretSetting(key: string): void {
this.query('DELETE FROM secret_settings WHERE key = ?', [key]);
}
}
+141
View File
@@ -0,0 +1,141 @@
import { credentialEncryption } from '../classes/encryption.ts';
import type { AuthRepository } from './repositories/auth.repository.ts';
const encryptedSecretPrefix = 'enc:v1:';
const secretSettingAliases = {
backupPassword: ['backup_encryption_password'],
cloudflareToken: ['cloudflareAPIKey'],
} as const;
type TCanonicalSecretSettingKey = keyof typeof secretSettingAliases;
export class SecretSettingsManager {
constructor(private authRepo: AuthRepository) {}
public isSecretKey(key: string): boolean {
return this.resolveCanonicalKey(key) !== null;
}
public getCanonicalKeys(): TCanonicalSecretSettingKey[] {
return Object.keys(secretSettingAliases) as TCanonicalSecretSettingKey[];
}
public async get(key: string): Promise<string | null> {
const canonicalKey = this.resolveCanonicalKey(key);
if (!canonicalKey) {
return null;
}
for (const candidateKey of this.getCandidateKeys(canonicalKey)) {
const secretValue = this.authRepo.getSecretSetting(candidateKey);
if (secretValue !== null) {
const decryptedValue = await this.decodeStoredValue(secretValue);
await this.normalizeStoredSecret(canonicalKey, candidateKey, secretValue, decryptedValue);
return decryptedValue;
}
const legacyValue = this.authRepo.getSetting(candidateKey);
if (legacyValue !== null) {
await this.set(canonicalKey, legacyValue);
if (candidateKey !== canonicalKey) {
this.authRepo.deleteSetting(candidateKey);
}
this.authRepo.deleteSetting(canonicalKey);
return legacyValue;
}
}
return null;
}
public async set(key: string, value: string | null): Promise<void> {
const canonicalKey = this.resolveCanonicalKey(key);
if (!canonicalKey) {
throw new Error(`Unsupported secret setting key: ${key}`);
}
if (!value) {
this.clear(canonicalKey);
return;
}
const encryptedValue = await credentialEncryption.encrypt({ value });
this.authRepo.setSecretSetting(canonicalKey, `${encryptedSecretPrefix}${encryptedValue}`);
for (const aliasKey of secretSettingAliases[canonicalKey]) {
this.authRepo.deleteSecretSetting(aliasKey);
this.authRepo.deleteSetting(aliasKey);
}
this.authRepo.deleteSetting(canonicalKey);
}
public async has(key: string): Promise<boolean> {
return (await this.get(key)) !== null;
}
public clear(key: string): void {
const canonicalKey = this.resolveCanonicalKey(key);
if (!canonicalKey) {
return;
}
this.authRepo.deleteSecretSetting(canonicalKey);
this.authRepo.deleteSetting(canonicalKey);
for (const aliasKey of secretSettingAliases[canonicalKey]) {
this.authRepo.deleteSecretSetting(aliasKey);
this.authRepo.deleteSetting(aliasKey);
}
}
private resolveCanonicalKey(key: string): TCanonicalSecretSettingKey | null {
if (key in secretSettingAliases) {
return key as TCanonicalSecretSettingKey;
}
for (const [canonicalKey, aliases] of Object.entries(secretSettingAliases)) {
if ((aliases as readonly string[]).includes(key)) {
return canonicalKey as TCanonicalSecretSettingKey;
}
}
return null;
}
private getCandidateKeys(canonicalKey: TCanonicalSecretSettingKey): string[] {
return [canonicalKey, ...secretSettingAliases[canonicalKey]];
}
private async decodeStoredValue(value: string): Promise<string> {
if (value.startsWith(encryptedSecretPrefix)) {
const decrypted = await credentialEncryption.decrypt<{ value: string }>(
value.slice(encryptedSecretPrefix.length),
);
return decrypted.value;
}
// Compatibility for any earlier secret_settings rows stored without encryption.
return value;
}
private async normalizeStoredSecret(
canonicalKey: TCanonicalSecretSettingKey,
sourceKey: string,
storedValue: string,
decryptedValue: string,
): Promise<void> {
if (sourceKey !== canonicalKey || !storedValue.startsWith(encryptedSecretPrefix)) {
await this.set(canonicalKey, decryptedValue);
if (sourceKey !== canonicalKey) {
this.authRepo.deleteSecretSetting(sourceKey);
}
}
this.authRepo.deleteSetting(canonicalKey);
for (const aliasKey of secretSettingAliases[canonicalKey]) {
this.authRepo.deleteSetting(aliasKey);
}
}
}