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:
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user