061ce7c3f2
- 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.
142 lines
4.3 KiB
TypeScript
142 lines
4.3 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|