mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
feat(plugins): add local-only secret storage API for plugins (#8633)
* feat(plugins): add local-only secret storage API for plugins Add setSecret/getSecret/deleteSecret to the plugin API, backed by a dedicated 'sup-plugin-secrets' IndexedDB that is never part of Super Productivity's sync, exports, or backups (mirrors the existing OAuth token store). Secrets are namespaced per plugin and purged on uninstall. Unblocks credential-using plugins (e.g. IMAP mailbox -> task) that must not put passwords in persistDataSynced or synced issue-provider config. Also purge plugin OAuth tokens on uninstall (best-effort, alongside the secret purge) — they previously leaked past uninstall. Refs #7511 * fix(plugins): purge plugin secrets and OAuth tokens on cache clear clearUploadedPluginsFromMemory (the 'Clear plugin cache' action) wiped plugin code and persisted nodeExecution consent (#8512 Phase 2) but left secrets and OAuth tokens in their dedicated stores. A same-id re-upload after a cache clear has no existingState, so the re-upload purge never fires and the new plugin could inherit the previous plugin's credentials — the same id-reuse gap #8512 closed for consent. Purge both here too, best-effort and idempotent, mirroring the per-plugin uninstall purge. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
parent
7182ff4fba
commit
ceefb5000c
9 changed files with 496 additions and 11 deletions
|
|
@ -528,6 +528,74 @@ const data = await PluginAPI.loadSyncedData();
|
|||
console.log(data); // '{ count: 42 }'
|
||||
```
|
||||
|
||||
### Secret Storage
|
||||
|
||||
For credentials — IMAP/SMTP passwords, API tokens, app passwords — use
|
||||
`setSecret` / `getSecret` / `deleteSecret`. Secrets are stored **local-only**:
|
||||
they are never synced, exported, or included in backups, and each plugin can
|
||||
only read its own keys.
|
||||
|
||||
```javascript
|
||||
// Store a credential (key must be a non-empty string)
|
||||
await PluginAPI.setSecret('imapPassword', 'app-password-123');
|
||||
|
||||
// Read it back when you need to connect
|
||||
const pw = await PluginAPI.getSecret('imapPassword'); // string | null
|
||||
|
||||
// Remove it (e.g. when the user disconnects)
|
||||
await PluginAPI.deleteSecret('imapPassword');
|
||||
```
|
||||
|
||||
Rules of thumb:
|
||||
|
||||
- **Never** put a credential in `persistDataSynced` or in issue-provider
|
||||
config — those sync to the server and land in exports/backups. Keep only
|
||||
non-secret connection details there (host, port, username, filters) and put
|
||||
the password/token in secret storage.
|
||||
- Secrets are **per-device**: a value set on desktop is not available on mobile,
|
||||
so prompt the user to enter the credential on each device. (This matches how
|
||||
IMAP app-passwords are typically used anyway.)
|
||||
- Secrets are stored unencrypted at rest today (the same as plugin OAuth
|
||||
tokens); the guarantee is "stays on this device, never synced," not
|
||||
hardware-level encryption. Don't store anything you wouldn't accept living in
|
||||
the app's local profile.
|
||||
- All secrets for a plugin are purged automatically when the plugin is
|
||||
uninstalled.
|
||||
|
||||
#### Secrets in issue-provider plugins
|
||||
|
||||
Issue-provider plugins get the same secret API (an issue provider is a normal
|
||||
plugin that also calls `registerIssueProvider`). Your definition callbacks
|
||||
(`getHeaders`, `getById`, `searchIssues`, …) run in your plugin's context, so
|
||||
they can read secrets directly:
|
||||
|
||||
```javascript
|
||||
PluginAPI.registerIssueProvider({
|
||||
// Declare only NON-secret fields here — their values are stored in the
|
||||
// synced issue-provider config:
|
||||
configFields: [
|
||||
{ key: 'host', type: 'text', label: 'Host' },
|
||||
{ key: 'username', type: 'text', label: 'Username' },
|
||||
],
|
||||
// getHeaders may return a Promise, so read the credential from secret
|
||||
// storage instead of from `config`:
|
||||
async getHeaders(config) {
|
||||
const token = await PluginAPI.getSecret('apiToken');
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
},
|
||||
async getById(issueId, config, http) {
|
||||
/* ... http call uses the headers above ... */
|
||||
},
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
The host passes only the synced `config` into these callbacks — there is no
|
||||
secret parameter, and the declarative `configFields` form always writes to the
|
||||
synced config. So collect the secret through your own UI (a config dialog
|
||||
registered via `registerConfigHandler`, or a side panel) and store it with
|
||||
`setSecret` there; do **not** add the credential as a `configFields` entry.
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Performance
|
||||
|
|
|
|||
|
|
@ -675,6 +675,22 @@ export interface PluginAPI {
|
|||
|
||||
clearOAuthToken(): Promise<void>;
|
||||
|
||||
// secret storage
|
||||
//
|
||||
// Local-only, per-plugin credential storage (IMAP passwords, API tokens, …).
|
||||
// Stored in a dedicated store that is never part of Super Productivity's
|
||||
// sync, exports, or backups, so secrets are per-device — the user re-enters
|
||||
// them on each device. (This is not protection against OS-level device
|
||||
// backups; values are stored unencrypted at rest, like plugin OAuth tokens.)
|
||||
// Use this instead of `persistDataSynced` / issue-provider config for
|
||||
// anything sensitive; those land in synced state, exports, and backups.
|
||||
// `key` must be a non-empty string.
|
||||
setSecret(key: string, value: string): Promise<void>;
|
||||
|
||||
getSecret(key: string): Promise<string | null>;
|
||||
|
||||
deleteSecret(key: string): Promise<void>;
|
||||
|
||||
// download file
|
||||
downloadFile(filename: string, data: string): Promise<void>;
|
||||
|
||||
|
|
|
|||
|
|
@ -610,6 +610,19 @@ export class PluginAPI implements PluginAPIInterface {
|
|||
return this.#boundMethods.clearOAuthToken();
|
||||
}
|
||||
|
||||
async setSecret(key: string, value: string): Promise<void> {
|
||||
return this.#boundMethods.setSecret(key, value);
|
||||
}
|
||||
|
||||
async getSecret(key: string): Promise<string | null> {
|
||||
return this.#boundMethods.getSecret(key);
|
||||
}
|
||||
|
||||
async deleteSecret(key: string): Promise<void> {
|
||||
PluginLog.log(`Plugin ${this.#pluginId} requested secret delete`);
|
||||
return this.#boundMethods.deleteSecret(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all resources associated with this plugin API instance
|
||||
* Called when the plugin is being unloaded
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ import { IssueSyncAdapterRegistryService } from '../features/issue/two-way-sync/
|
|||
import { PluginHttpService } from './issue-provider/plugin-http.service';
|
||||
import { createPluginSyncAdapter } from './issue-provider/plugin-sync-adapter.service';
|
||||
import { PluginOAuthBridgeService } from './oauth/plugin-oauth-bridge.service';
|
||||
import { PluginSecretService } from './secret/plugin-secret.service';
|
||||
import { ISSUE_PROVIDER_TYPES } from '../features/issue/issue.const';
|
||||
import { PluginService } from './plugin.service';
|
||||
import { PluginI18nService } from './plugin-i18n.service';
|
||||
|
|
@ -155,6 +156,7 @@ export class PluginBridgeService implements OnDestroy {
|
|||
private _syncAdapterRegistry = inject(IssueSyncAdapterRegistryService);
|
||||
private _pluginHttpService = inject(PluginHttpService);
|
||||
private _pluginOAuthBridge = inject(PluginOAuthBridgeService);
|
||||
private _pluginSecretService = inject(PluginSecretService);
|
||||
private _dataInitService = inject(DataInitService);
|
||||
private _globalConfigService = inject(GlobalConfigService);
|
||||
readonly #nodeExecutionGrantTokens = new Map<string, string>();
|
||||
|
|
@ -270,6 +272,9 @@ export class PluginBridgeService implements OnDestroy {
|
|||
startOAuthFlow: (config: OAuthFlowConfig) => Promise<OAuthTokenResult>;
|
||||
getOAuthToken: () => Promise<string | null>;
|
||||
clearOAuthToken: () => Promise<void>;
|
||||
setSecret: (key: string, value: string) => Promise<void>;
|
||||
getSecret: (key: string) => Promise<string | null>;
|
||||
deleteSecret: (key: string) => Promise<void>;
|
||||
translate: (key: string, params?: Record<string, string | number>) => string;
|
||||
formatDate: (date: Date | string | number, format: PluginDateFormat) => string;
|
||||
getCurrentLanguage: () => string;
|
||||
|
|
@ -364,6 +369,14 @@ export class PluginBridgeService implements OnDestroy {
|
|||
clearOAuthToken: (): Promise<void> =>
|
||||
this._pluginOAuthBridge.clearOAuthTokens(pluginId),
|
||||
|
||||
// Secret storage (local-only, per-plugin, never synced)
|
||||
setSecret: (key: string, value: string): Promise<void> =>
|
||||
this._pluginSecretService.setSecret(pluginId, key, value),
|
||||
getSecret: (key: string): Promise<string | null> =>
|
||||
this._pluginSecretService.getSecret(pluginId, key),
|
||||
deleteSecret: (key: string): Promise<void> =>
|
||||
this._pluginSecretService.deleteSecret(pluginId, key),
|
||||
|
||||
// i18n
|
||||
translate: (key: string, params?: Record<string, string | number>): string =>
|
||||
this._injector.get(PluginI18nService).translate(pluginId, key, params),
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { GlobalThemeService } from '../core/theme/global-theme.service';
|
|||
import { IssueSyncAdapterRegistryService } from '../features/issue/two-way-sync/issue-sync-adapter-registry.service';
|
||||
import { PluginIssueProviderRegistryService } from './issue-provider/plugin-issue-provider-registry.service';
|
||||
import { PluginCacheService } from './plugin-cache.service';
|
||||
import { PluginSecretService } from './secret/plugin-secret.service';
|
||||
import { PluginCleanupService } from './plugin-cleanup.service';
|
||||
import { PluginHooksService } from './plugin-hooks';
|
||||
import { PluginI18nService } from './plugin-i18n.service';
|
||||
|
|
@ -72,10 +73,12 @@ describe('PluginService', () => {
|
|||
'setNodeExecutionGrantToken',
|
||||
'revokeNodeExecutionGrantToken',
|
||||
'revokeNodeExecutionGrant',
|
||||
'clearOAuthTokens',
|
||||
'clearNodeExecutionConsent',
|
||||
]);
|
||||
pluginBridge.hasNodeExecutionGrantToken.and.returnValue(false);
|
||||
pluginBridge.requestNodeExecutionGrant.and.resolveTo(null);
|
||||
pluginBridge.clearOAuthTokens.and.resolveTo(undefined);
|
||||
pluginBridge.clearNodeExecutionConsent.and.resolveTo(undefined);
|
||||
pluginRunner = jasmine.createSpyObj<PluginRunner>('PluginRunner', [
|
||||
'loadPlugin',
|
||||
|
|
@ -384,6 +387,44 @@ describe('PluginService', () => {
|
|||
expect(pluginBridge.clearNodeExecutionConsent).not.toHaveBeenCalledWith('builtin-1');
|
||||
});
|
||||
|
||||
it('clearUploadedPluginsFromMemory purges local-only credentials for uploaded plugins', async () => {
|
||||
// Same id-reuse gap as the consent clear above: the cache wipe leaves secrets/OAuth
|
||||
// tokens in their dedicated stores, so a same-id re-upload could read the previous
|
||||
// plugin's credentials unless they are purged here too.
|
||||
const secretService = TestBed.inject(PluginSecretService);
|
||||
const removeSecretsSpy = spyOn(
|
||||
secretService,
|
||||
'removeSecretsForPlugin',
|
||||
).and.resolveTo();
|
||||
const setState = (
|
||||
service as unknown as {
|
||||
_setPluginState: (pluginId: string, state: PluginState) => void;
|
||||
}
|
||||
)._setPluginState.bind(service);
|
||||
setState('uploaded-1', {
|
||||
manifest: { ...mockManifest, id: 'uploaded-1' },
|
||||
status: 'not-loaded',
|
||||
path: 'uploaded://uploaded-1',
|
||||
type: 'uploaded',
|
||||
isEnabled: true,
|
||||
});
|
||||
setState('builtin-1', {
|
||||
manifest: { ...mockManifest, id: 'builtin-1' },
|
||||
status: 'not-loaded',
|
||||
path: 'assets/bundled-plugins/builtin-1',
|
||||
type: 'built-in',
|
||||
isEnabled: true,
|
||||
});
|
||||
|
||||
await service.clearUploadedPluginsFromMemory();
|
||||
|
||||
expect(removeSecretsSpy).toHaveBeenCalledWith('uploaded-1');
|
||||
expect(pluginBridge.clearOAuthTokens).toHaveBeenCalledWith('uploaded-1');
|
||||
// Built-in plugins are not wiped by a cache clear, so their credentials are left alone.
|
||||
expect(removeSecretsSpy).not.toHaveBeenCalledWith('builtin-1');
|
||||
expect(pluginBridge.clearOAuthTokens).not.toHaveBeenCalledWith('builtin-1');
|
||||
});
|
||||
|
||||
it('disablePlugin persists isEnabled=false and revokes nodeExecution consent (Phase 2)', async () => {
|
||||
const pluginId = 'uploaded-node-plugin';
|
||||
(
|
||||
|
|
@ -559,6 +600,39 @@ describe('PluginService', () => {
|
|||
expect(service.getLoadedPlugins()).toEqual([]);
|
||||
});
|
||||
|
||||
describe('removeUploadedPlugin credential cleanup', () => {
|
||||
it('purges both secrets and OAuth tokens on uninstall', async () => {
|
||||
const secretService = TestBed.inject(PluginSecretService);
|
||||
const removeSecretsSpy = spyOn(
|
||||
secretService,
|
||||
'removeSecretsForPlugin',
|
||||
).and.resolveTo();
|
||||
|
||||
await service.removeUploadedPlugin('ghost-plugin');
|
||||
|
||||
expect(removeSecretsSpy).toHaveBeenCalledWith('ghost-plugin');
|
||||
expect(pluginBridge.clearOAuthTokens).toHaveBeenCalledWith('ghost-plugin');
|
||||
});
|
||||
|
||||
it('purges credentials even when a later cleanup step fails', async () => {
|
||||
const secretService = TestBed.inject(PluginSecretService);
|
||||
const removeSecretsSpy = spyOn(
|
||||
secretService,
|
||||
'removeSecretsForPlugin',
|
||||
).and.resolveTo();
|
||||
const cache = TestBed.inject(
|
||||
PluginCacheService,
|
||||
) as jasmine.SpyObj<PluginCacheService>;
|
||||
cache.removePlugin.and.rejectWith(new Error('cache failure'));
|
||||
|
||||
// The credential purges run before the failing step, so they still fire.
|
||||
await expectAsync(service.removeUploadedPlugin('ghost-plugin')).toBeRejected();
|
||||
|
||||
expect(removeSecretsSpy).toHaveBeenCalledWith('ghost-plugin');
|
||||
expect(pluginBridge.clearOAuthTokens).toHaveBeenCalledWith('ghost-plugin');
|
||||
});
|
||||
});
|
||||
|
||||
// chokepoint #1: the actual #8385 path (startup re-activation / reload both flow through
|
||||
// `activatePlugin`'s catch). Driven by making `_loadPluginLazy` reject with the denial
|
||||
// sentinel, since the real `_fireOnReady` grant block is gated on the un-mockable
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
import { take } from 'rxjs/operators';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { PluginCleanupService } from './plugin-cleanup.service';
|
||||
import { PluginSecretService } from './secret/plugin-secret.service';
|
||||
import { PluginLoaderService } from './plugin-loader.service';
|
||||
import { validatePluginManifest } from './util/validate-manifest.util';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
|
|
@ -113,6 +114,7 @@ export class PluginService implements OnDestroy {
|
|||
private readonly _pluginMetaPersistenceService = inject(PluginMetaPersistenceService);
|
||||
private readonly _pluginUserPersistenceService = inject(PluginUserPersistenceService);
|
||||
private readonly _pluginCacheService = inject(PluginCacheService);
|
||||
private readonly _pluginSecretService = inject(PluginSecretService);
|
||||
private readonly _cleanupService = inject(PluginCleanupService);
|
||||
private readonly _pluginLoader = inject(PluginLoaderService);
|
||||
private readonly _pluginBridge = inject(PluginBridgeService);
|
||||
|
|
@ -1635,6 +1637,21 @@ export class PluginService implements OnDestroy {
|
|||
this.unloadPlugin(pluginId);
|
||||
}
|
||||
|
||||
// Purge local-only credentials (secrets + OAuth tokens) FIRST so they
|
||||
// never outlive their plugin — even if a later cleanup step throws.
|
||||
// Best-effort: a purge failure is logged but must not abort the uninstall
|
||||
// (on IndexedDB failure the credentials orphan locally until a later purge).
|
||||
try {
|
||||
await this._pluginSecretService.removeSecretsForPlugin(pluginId);
|
||||
} catch (error) {
|
||||
PluginLog.err(`Failed to purge secrets for plugin ${pluginId}:`, error);
|
||||
}
|
||||
try {
|
||||
await this._pluginBridge.clearOAuthTokens(pluginId);
|
||||
} catch (error) {
|
||||
PluginLog.err(`Failed to purge OAuth tokens for plugin ${pluginId}:`, error);
|
||||
}
|
||||
|
||||
// Remove from cache
|
||||
await this._pluginCacheService.removePlugin(pluginId);
|
||||
|
||||
|
|
@ -1673,13 +1690,15 @@ export class PluginService implements OnDestroy {
|
|||
* Clear all uploaded plugins from memory. Called when the IndexedDB cache is cleared
|
||||
* so that in-memory state matches the empty cache.
|
||||
*
|
||||
* Also clears each uploaded plugin's main-owned PERSISTED nodeExecution consent
|
||||
* (issue #8512 Phase 2). The cache wipe removes the plugin code, but the consent lives
|
||||
* in the main process, so without this a later re-upload of the same id — potentially
|
||||
* *different* code — would be silently granted node execution with no prompt: the
|
||||
* post-clear upload has no `existingState`, so the re-upload clear in `loadPluginFromZip`
|
||||
* never fires. Mirrors `removeUploadedPlugin`, keeping "replacing code under an id always
|
||||
* re-asks" true on every removal path.
|
||||
* Also purges each uploaded plugin's local-only credentials (secrets + OAuth
|
||||
* tokens) and main-owned PERSISTED nodeExecution consent (issue #8512 Phase 2).
|
||||
* The cache wipe removes the plugin code, but credentials and consent live in
|
||||
* dedicated stores / the main process, so without this a later re-upload of the
|
||||
* same id — potentially *different* code — would silently inherit the previous
|
||||
* plugin's secrets, tokens, and node-execution grant with no prompt: the
|
||||
* post-clear upload has no `existingState`, so the re-upload clear in
|
||||
* `loadPluginFromZip` never fires. Mirrors `removeUploadedPlugin`, keeping
|
||||
* "replacing code under an id always re-asks" true on every removal path.
|
||||
*/
|
||||
async clearUploadedPluginsFromMemory(): Promise<void> {
|
||||
const states = this._pluginStates();
|
||||
|
|
@ -1704,11 +1723,24 @@ export class PluginService implements OnDestroy {
|
|||
return updated;
|
||||
});
|
||||
this._pluginIconsSignal.set(new Map(this._pluginIcons));
|
||||
// Drop persisted consent after teardown has released the live grants. Each clear is
|
||||
// fail-safe (worst case: a re-prompt) and idempotent, so a single failure can't leave
|
||||
// a different id's consent behind.
|
||||
// Purge local-only credentials + persisted consent after teardown has released the
|
||||
// live grants. Each purge is best-effort and idempotent, so a single failure can't
|
||||
// skip the rest or leave a different id's credentials/consent behind for a same-id
|
||||
// re-upload to inherit.
|
||||
await Promise.all(
|
||||
uploadedIds.map((pluginId) => this.clearNodeExecutionConsent(pluginId)),
|
||||
uploadedIds.map(async (pluginId) => {
|
||||
try {
|
||||
await this._pluginSecretService.removeSecretsForPlugin(pluginId);
|
||||
} catch (error) {
|
||||
PluginLog.err(`Failed to purge secrets for plugin ${pluginId}:`, error);
|
||||
}
|
||||
try {
|
||||
await this._pluginBridge.clearOAuthTokens(pluginId);
|
||||
} catch (error) {
|
||||
PluginLog.err(`Failed to purge OAuth tokens for plugin ${pluginId}:`, error);
|
||||
}
|
||||
await this.clearNodeExecutionConsent(pluginId);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
93
src/app/plugins/secret/plugin-secret-store.ts
Normal file
93
src/app/plugins/secret/plugin-secret-store.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { DBSchema, IDBPDatabase, openDB } from 'idb';
|
||||
import { PluginLog } from '../../core/log';
|
||||
|
||||
/**
|
||||
* Local-only IndexedDB store for plugin secrets (passwords, API tokens, …).
|
||||
*
|
||||
* Mirrors the plugin OAuth token store: secrets live in a dedicated database
|
||||
* ('sup-plugin-secrets') that is NOT part of the op-log sync system, exports,
|
||||
* or backups. Each device stores its own secrets independently.
|
||||
*
|
||||
* Values are stored verbatim (plaintext at rest), exactly like OAuth tokens
|
||||
* today. OS-keychain encryption is a future, optional upgrade behind the same
|
||||
* PluginAPI surface and does not change this store's contract.
|
||||
*/
|
||||
|
||||
const DB_NAME = 'sup-plugin-secrets';
|
||||
const DB_STORE_NAME = 'secrets';
|
||||
const DB_VERSION = 1;
|
||||
|
||||
interface PluginSecretDb extends DBSchema {
|
||||
[DB_STORE_NAME]: {
|
||||
key: string;
|
||||
value: string;
|
||||
};
|
||||
}
|
||||
|
||||
let db: IDBPDatabase<PluginSecretDb> | undefined;
|
||||
let initPromise: Promise<IDBPDatabase<PluginSecretDb>> | undefined;
|
||||
|
||||
const ensureDb = async (): Promise<IDBPDatabase<PluginSecretDb>> => {
|
||||
if (db) {
|
||||
return db;
|
||||
}
|
||||
if (!initPromise) {
|
||||
initPromise = openDB<PluginSecretDb>(DB_NAME, DB_VERSION, {
|
||||
upgrade: (database) => {
|
||||
if (!database.objectStoreNames.contains(DB_STORE_NAME)) {
|
||||
database.createObjectStore(DB_STORE_NAME);
|
||||
}
|
||||
},
|
||||
}).then((opened) => {
|
||||
db = opened;
|
||||
return opened;
|
||||
});
|
||||
// Don't cache a rejected open forever — clear it so a later call retries
|
||||
// instead of leaving the store bricked for the whole session.
|
||||
initPromise.catch(() => {
|
||||
initPromise = undefined;
|
||||
});
|
||||
}
|
||||
return initPromise;
|
||||
};
|
||||
|
||||
export const saveSecret = async (entityId: string, value: string): Promise<void> => {
|
||||
try {
|
||||
const store = await ensureDb();
|
||||
await store.put(DB_STORE_NAME, value, entityId);
|
||||
} catch (error) {
|
||||
PluginLog.err('PluginSecretStore: Failed to save secret:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const loadSecret = async (entityId: string): Promise<string | null> => {
|
||||
try {
|
||||
const store = await ensureDb();
|
||||
const result = await store.get(DB_STORE_NAME, entityId);
|
||||
return result ?? null;
|
||||
} catch (error) {
|
||||
PluginLog.err('PluginSecretStore: Failed to load secret:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteSecret = async (entityId: string): Promise<void> => {
|
||||
try {
|
||||
const store = await ensureDb();
|
||||
await store.delete(DB_STORE_NAME, entityId);
|
||||
} catch (error) {
|
||||
PluginLog.err('PluginSecretStore: Failed to delete secret:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getAllSecretKeys = async (): Promise<string[]> => {
|
||||
try {
|
||||
const store = await ensureDb();
|
||||
return await store.getAllKeys(DB_STORE_NAME);
|
||||
} catch (error) {
|
||||
PluginLog.err('PluginSecretStore: Failed to list secret keys:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
91
src/app/plugins/secret/plugin-secret.service.spec.ts
Normal file
91
src/app/plugins/secret/plugin-secret.service.spec.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { TestBed } from '@angular/core/testing';
|
||||
import { MAX_PLUGIN_SECRET_LENGTH, PluginSecretService } from './plugin-secret.service';
|
||||
import { deleteSecret, getAllSecretKeys } from './plugin-secret-store';
|
||||
|
||||
/**
|
||||
* Runs against the real `sup-plugin-secrets` IndexedDB (Karma uses real
|
||||
* Chrome). afterEach wipes the store so specs don't leak into each other.
|
||||
*/
|
||||
describe('PluginSecretService', () => {
|
||||
let service: PluginSecretService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(PluginSecretService);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const keys = await getAllSecretKeys();
|
||||
for (const key of keys) {
|
||||
await deleteSecret(key);
|
||||
}
|
||||
});
|
||||
|
||||
it('round-trips a secret', async () => {
|
||||
await service.setSecret('plugin-a', 'password', 's3cret');
|
||||
expect(await service.getSecret('plugin-a', 'password')).toBe('s3cret');
|
||||
});
|
||||
|
||||
it('returns null for a missing secret', async () => {
|
||||
expect(await service.getSecret('plugin-a', 'nope')).toBeNull();
|
||||
});
|
||||
|
||||
it('namespaces secrets per plugin', async () => {
|
||||
await service.setSecret('plugin-a', 'password', 'a-secret');
|
||||
await service.setSecret('plugin-b', 'password', 'b-secret');
|
||||
expect(await service.getSecret('plugin-a', 'password')).toBe('a-secret');
|
||||
expect(await service.getSecret('plugin-b', 'password')).toBe('b-secret');
|
||||
});
|
||||
|
||||
it('deletes a single secret without touching others', async () => {
|
||||
await service.setSecret('plugin-a', 'password', 'pw');
|
||||
await service.setSecret('plugin-a', 'token', 'tk');
|
||||
await service.deleteSecret('plugin-a', 'password');
|
||||
expect(await service.getSecret('plugin-a', 'password')).toBeNull();
|
||||
expect(await service.getSecret('plugin-a', 'token')).toBe('tk');
|
||||
});
|
||||
|
||||
it('removeSecretsForPlugin purges only the owner plugin', async () => {
|
||||
await service.setSecret('plugin-a', 'password', 'pw');
|
||||
await service.setSecret('plugin-a', 'token', 'tk');
|
||||
await service.setSecret('plugin-b', 'password', 'keep');
|
||||
await service.removeSecretsForPlugin('plugin-a');
|
||||
expect(await service.getSecret('plugin-a', 'password')).toBeNull();
|
||||
expect(await service.getSecret('plugin-a', 'token')).toBeNull();
|
||||
expect(await service.getSecret('plugin-b', 'password')).toBe('keep');
|
||||
});
|
||||
|
||||
it('removeSecretsForPlugin does not purge a prefix-colliding plugin', async () => {
|
||||
// 'plugin-a' must not match 'plugin-ab' during cleanup.
|
||||
await service.setSecret('plugin-a', 'k', 'a');
|
||||
await service.setSecret('plugin-ab', 'k', 'ab');
|
||||
await service.removeSecretsForPlugin('plugin-a');
|
||||
expect(await service.getSecret('plugin-a', 'k')).toBeNull();
|
||||
expect(await service.getSecret('plugin-ab', 'k')).toBe('ab');
|
||||
});
|
||||
|
||||
it('rejects an empty key', async () => {
|
||||
await expectAsync(service.setSecret('plugin-a', '', 'x')).toBeRejectedWithError(
|
||||
/non-empty string/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a non-string value', async () => {
|
||||
await expectAsync(
|
||||
service.setSecret('plugin-a', 'k', 123 as unknown as string),
|
||||
).toBeRejectedWithError(/must be a string/);
|
||||
});
|
||||
|
||||
it('rejects an oversized value', async () => {
|
||||
const huge = 'x'.repeat(MAX_PLUGIN_SECRET_LENGTH + 1);
|
||||
await expectAsync(service.setSecret('plugin-a', 'k', huge)).toBeRejectedWithError(
|
||||
/maximum length/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a pluginId containing the reserved delimiter', async () => {
|
||||
await expectAsync(service.setSecret('plugin:evil', 'k', 'x')).toBeRejectedWithError(
|
||||
/must not contain/,
|
||||
);
|
||||
});
|
||||
});
|
||||
85
src/app/plugins/secret/plugin-secret.service.ts
Normal file
85
src/app/plugins/secret/plugin-secret.service.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { PluginLog } from '../../core/log';
|
||||
import {
|
||||
assertPluginPersistenceKey,
|
||||
composeId,
|
||||
isPluginIdMatch,
|
||||
} from '../util/plugin-persistence-key.util';
|
||||
import {
|
||||
deleteSecret,
|
||||
getAllSecretKeys,
|
||||
loadSecret,
|
||||
saveSecret,
|
||||
} from './plugin-secret-store';
|
||||
|
||||
/**
|
||||
* Per-plugin local-only secret storage.
|
||||
*
|
||||
* Secrets are namespaced by `pluginId` (the host injects it — a plugin can
|
||||
* never address another plugin's keys) and persisted to a dedicated IndexedDB
|
||||
* that is never synced, exported, or backed up. Use for credentials that must
|
||||
* not leave the device (IMAP passwords, API tokens), NOT `persistDataSynced`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Upper bound on a single secret value. Credentials are small; this only
|
||||
* exists to stop a compromised iframe from writing megabytes into the store.
|
||||
* Measured in UTF-16 code units (string length) — exact bytes don't matter
|
||||
* for an abuse guard.
|
||||
*/
|
||||
export const MAX_PLUGIN_SECRET_LENGTH = 16 * 1024;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PluginSecretService {
|
||||
async setSecret(pluginId: string, key: string, value: string): Promise<void> {
|
||||
const entityId = this._entityId(pluginId, key);
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error('Plugin secret value must be a string');
|
||||
}
|
||||
if (value.length > MAX_PLUGIN_SECRET_LENGTH) {
|
||||
throw new Error(
|
||||
`Plugin secret exceeds maximum length of ${MAX_PLUGIN_SECRET_LENGTH} characters`,
|
||||
);
|
||||
}
|
||||
await saveSecret(entityId, value);
|
||||
}
|
||||
|
||||
async getSecret(pluginId: string, key: string): Promise<string | null> {
|
||||
return loadSecret(this._entityId(pluginId, key));
|
||||
}
|
||||
|
||||
async deleteSecret(pluginId: string, key: string): Promise<void> {
|
||||
await deleteSecret(this._entityId(pluginId, key));
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge every secret owned by a plugin. Called on uninstall so credentials
|
||||
* never outlive the plugin that owned them.
|
||||
*/
|
||||
async removeSecretsForPlugin(pluginId: string): Promise<void> {
|
||||
const allKeys = await getAllSecretKeys();
|
||||
const owned = allKeys.filter((entityId) => isPluginIdMatch(entityId, pluginId));
|
||||
for (const entityId of owned) {
|
||||
await deleteSecret(entityId);
|
||||
}
|
||||
if (owned.length > 0) {
|
||||
PluginLog.log('PluginSecretService: Removed secrets on cleanup', {
|
||||
pluginId,
|
||||
count: owned.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose + validate the storage id. `key` is required and non-empty for
|
||||
* secrets (unlike the optional persistence key), and `composeId` throws if
|
||||
* the pluginId itself contains the reserved ':' delimiter.
|
||||
*/
|
||||
private _entityId(pluginId: string, key: string): string {
|
||||
if (typeof key !== 'string' || key === '') {
|
||||
throw new Error('Plugin secret key must be a non-empty string');
|
||||
}
|
||||
assertPluginPersistenceKey(key);
|
||||
return composeId(pluginId, key);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue