Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 c849bf6a73 fix(plugins): preserve overwrite stash across retries and prefix deletes
Do not replace an existing restore stash on later puts, and clear matching
stash entries when deleteByKeyPrefix wipes sync credentials on disconnect.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 11:36:59 +00:00
Cursor Agentand陈大猫 0d9c4a6e02 fix(plugins): keep overwrite stash until restore write succeeds
Only drop the prior plaintext after encrypt+upsert succeeds so a failed
restore can still retry and recover the saved SecretRef.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 11:32:08 +00:00
Cursor Agentand陈大猫 acd5700284 fix(plugins): host-own sync provider bindings and clear failed stashes
Move provider→plugin bindings into a dedicated DB table outside
plugin-writable secrets, validate provider namespace ownership, and drop
overwrite stashes when replacement writes fail.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 11:27:26 +00:00
Cursor Agentand陈大猫 05b33b5521 fix(plugins): make sync secret overwrite stash opt-in
Only the syncPutSecret path stashes prior plaintext/SecretRef so ordinary
secrets.set overwrites do not leave credentials in process memory.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 11:18:26 +00:00
Cursor Agentand陈大猫 786e5b48ef fix(plugins): restore overwritten sync secrets on rejected reconnect
Stash prior plaintext and SecretRef id on overwrite, restore them when
connect fails, and discard the stash after a successful reconnect so
saved provider credentials keep resolving.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 11:12:47 +00:00
Cursor Agentand陈大猫 6414c9e177 fix(plugins): address residual Codex sync secret and backup gaps
Retain provider→plugin secret bindings so disconnect can wipe credentials
after a plugin is disabled, keep overwritten secrets on failed reconnect,
and snapshot live-empty sidecars in protective backups.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 11:05:39 +00:00
14 changed files with 591 additions and 37 deletions

No files matched your search

@@ -103,4 +103,35 @@ describe('storePluginSyncSecretsThenConnect', () => {
);
assert.deepEqual(deletedKeys, [['sync-credential']]);
});
it('keeps overwritten secrets when reconnect connect rejects', async () => {
const deletedKeys: string[][] = [];
const restoredKeys: string[][] = [];
await assert.rejects(
() => storePluginSyncSecretsThenConnect({
providerId: 'plugin:sync.example',
secrets: [{ secretKey: 'sync-credential', value: 'bad-password' }],
putSecret: async ({ key }) => ({
kind: 'secret',
id: 'ref-overwritten',
key,
created: false,
}),
deleteSecrets: async ({ keys }) => {
deletedKeys.push([...keys]);
return { deleted: keys.length };
},
restoreSecrets: async ({ keys }) => {
restoredKeys.push([...keys]);
return { restored: keys.length };
},
connect: async () => {
throw new Error('auth failed');
},
}),
/auth failed/,
);
assert.deepEqual(deletedKeys, []);
assert.deepEqual(restoredKeys, [['sync-credential']]);
});
});
+44 -2
View File
@@ -1,6 +1,8 @@
/**
* Persist plugin sync secrets then connect; roll back just-created secrets if
* connect (or a later put) fails so bad passwords are not left in OS storage.
* Overwritten keys are restored from the host-side overwrite stash so a
* rejected reconnect does not leave a broken SecretRef / rejected credential.
*/
export type PluginSyncSecretRef = { kind: 'secret'; id: string; key: string };
@@ -10,6 +12,11 @@ export interface PluginSyncConnectSecretInput {
value: string;
}
export interface PluginSyncPutSecretResult extends PluginSyncSecretRef {
/** False when the durable (plugin,key) row already existed and was overwritten. */
created?: boolean;
}
export interface StorePluginSyncSecretsThenConnectParams {
providerId: string;
secrets: readonly PluginSyncConnectSecretInput[];
@@ -19,11 +26,20 @@ export interface StorePluginSyncSecretsThenConnectParams {
providerId: string;
key: string;
value: string;
}) => Promise<PluginSyncSecretRef>;
}) => Promise<PluginSyncPutSecretResult>;
deleteSecrets: (params: {
providerId: string;
keys: string[];
}) => Promise<unknown>;
/**
* Restore host-stashed plaintext for overwritten keys (`discard: false`),
* or drop the stash after a successful connect (`discard: true`).
*/
restoreSecrets?: (params: {
providerId: string;
keys: string[];
discard?: boolean;
}) => Promise<unknown>;
connect: (credential: PluginSyncSecretRef | undefined) => Promise<void>;
}
@@ -31,6 +47,7 @@ export async function storePluginSyncSecretsThenConnect(
params: StorePluginSyncSecretsThenConnectParams,
): Promise<void> {
const createdKeys: string[] = [];
const overwrittenKeys: string[] = [];
let credential: PluginSyncSecretRef | undefined = params.existingCredential;
try {
@@ -42,13 +59,28 @@ export async function storePluginSyncSecretsThenConnect(
key: secret.secretKey,
value: secret.value,
});
createdKeys.push(secret.secretKey);
if (ref.created === false) {
overwrittenKeys.push(secret.secretKey);
} else {
createdKeys.push(secret.secretKey);
}
// SyncConnectPayload.credential carries the primary (first) secret;
// additional secrets remain addressable via secrets.get(key).
if (!credential) credential = ref;
}
}
await params.connect(credential);
if (overwrittenKeys.length > 0 && params.restoreSecrets) {
try {
await params.restoreSecrets({
providerId: params.providerId,
keys: [...overwrittenKeys],
discard: true,
});
} catch {
/* best-effort stash cleanup */
}
}
} catch (error) {
if (createdKeys.length > 0) {
try {
@@ -60,6 +92,16 @@ export async function storePluginSyncSecretsThenConnect(
/* best-effort; surface the original connect/put error */
}
}
if (overwrittenKeys.length > 0 && params.restoreSecrets) {
try {
await params.restoreSecrets({
providerId: params.providerId,
keys: [...overwrittenKeys],
});
} catch {
/* best-effort; surface the original connect/put error */
}
}
throw error;
}
}
+2 -3
View File
@@ -819,7 +819,7 @@ test("buildLocalVaultPayload includes last-known plugin sidecars for protective
}
});
test("buildLocalVaultPayloadAsync keeps last-known when live collect is empty", async () => {
test("buildLocalVaultPayloadAsync prefers live empty over last-known for backups", async () => {
const previousWindow = (globalThis as { window?: unknown }).window;
Object.defineProperty(globalThis, "window", {
value: {
@@ -847,8 +847,7 @@ test("buildLocalVaultPayloadAsync keeps last-known when live collect is empty",
try {
const { buildLocalVaultPayloadAsync } = await import("./syncPayload.ts");
const payload = await buildLocalVaultPayloadAsync(vault([]));
assert.equal(payload.pluginSidecars?.entries?.length, 1);
assert.equal(payload.pluginSidecars?.entries?.[0].value, "dark");
assert.equal(payload.pluginSidecars?.entries?.length, 0);
} finally {
localStorage.removeItem("netcatty_plugin_sidecars_last_known_v1");
Object.defineProperty(globalThis, "window", {
+3 -9
View File
@@ -1049,15 +1049,9 @@ export async function buildLocalVaultPayloadAsync(
try {
const live = await collectPluginSyncSidecarsFromHost();
if (live) {
// Live collect may return authoritative-empty while still deferring the
// last-known cache wipe until a successful sync commit. Protective
// backups must keep the non-empty last-known already attached to `base`.
const liveEmpty = !Array.isArray(live.entries) || live.entries.length === 0;
const baseHasEntries = Array.isArray(base.pluginSidecars?.entries)
&& base.pluginSidecars.entries.length > 0;
if (liveEmpty && baseHasEntries) {
return base;
}
// Uploads may still defer wiping last-known until a successful sync
// commit, but protective/version-change backups must snapshot the live
// authoritative-empty bundle so restore cannot resurrect cleared plugin data.
return withPluginSyncSidecars(base, live);
}
} catch (error) {
@@ -181,11 +181,12 @@ export const CloudSyncDashboardTabs: React.FC<CloudSyncDashboardTabsProps> = ({
? stored as { kind: 'secret'; id: string; key: string }
: undefined;
if (credentialPlan.secrets.length > 0) {
const { putPluginSyncSecret, deletePluginSyncSecrets } = await import(
const { putPluginSyncSecret, deletePluginSyncSecrets, restorePluginSyncSecrets } = await import(
'../../infrastructure/services/adapters/pluginSyncIpcHost'
);
// Put secrets before connect; roll back just-created keys if connect fails
// so a rejected password/token is not left readable in plugin_secrets.
// Overwrites restore the previous plaintext from the host stash.
await storePluginSyncSecretsThenConnect({
providerId,
secrets: credentialPlan.secrets.map((secret) => ({
@@ -194,6 +195,7 @@ export const CloudSyncDashboardTabs: React.FC<CloudSyncDashboardTabsProps> = ({
})),
putSecret: putPluginSyncSecret,
deleteSecrets: deletePluginSyncSecrets,
restoreSecrets: restorePluginSyncSecrets,
connect: async (credential) => {
await sync.connectPluginProvider(
providerId,
+70 -4
View File
@@ -5,7 +5,7 @@ const fs = require("node:fs");
const path = require("node:path");
const { DatabaseSync } = require("node:sqlite");
const SCHEMA_VERSION = 2;
const SCHEMA_VERSION = 3;
const MAX_SECURITY_AUDIT_DETAILS_BYTES = 16 * 1024;
const MAX_SECURITY_AUDIT_RECORDS_PER_PLUGIN = 1_000;
const REQUIRED_SCHEMA_COLUMNS = Object.freeze({
@@ -21,6 +21,8 @@ const REQUIRED_SCHEMA_COLUMNS = Object.freeze({
plugin_security_audit: ["id", "plugin_id", "event", "details_json", "created_at"],
// User-owned encrypted-sync sidecars: no FK cascade on package uninstall.
plugin_sync_sidecars: ["plugin_id", "kind", "key", "value_json", "updated_at"],
// Host-owned sync provider→plugin bindings (not plugin-writable secrets).
plugin_sync_provider_bindings: ["provider_id", "plugin_id", "created_at", "updated_at"],
});
function parseJson(text, label) {
@@ -164,12 +166,18 @@ class PluginDatabase {
);
CREATE INDEX plugin_sync_sidecars_lookup
ON plugin_sync_sidecars(plugin_id, kind, key);
PRAGMA user_version = 2;
CREATE TABLE plugin_sync_provider_bindings (
provider_id TEXT PRIMARY KEY,
plugin_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
PRAGMA user_version = 3;
`);
});
} else if (version === 1) {
// Pre-sidecar schema-1 databases only created the original tables.
// Migrate in place to schema 2 with the non-cascade sidecar table.
// Migrate in place to schema 3 with sidecar + provider binding tables.
this.transaction(() => {
this.db.exec(`
CREATE TABLE IF NOT EXISTS plugin_sync_sidecars (
@@ -182,7 +190,25 @@ class PluginDatabase {
);
CREATE INDEX IF NOT EXISTS plugin_sync_sidecars_lookup
ON plugin_sync_sidecars(plugin_id, kind, key);
PRAGMA user_version = 2;
CREATE TABLE IF NOT EXISTS plugin_sync_provider_bindings (
provider_id TEXT PRIMARY KEY,
plugin_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
PRAGMA user_version = 3;
`);
});
} else if (version === 2) {
this.transaction(() => {
this.db.exec(`
CREATE TABLE IF NOT EXISTS plugin_sync_provider_bindings (
provider_id TEXT PRIMARY KEY,
plugin_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
PRAGMA user_version = 3;
`);
});
}
@@ -759,6 +785,46 @@ class PluginDatabase {
return Number(result?.changes) || 0;
}
/** Find secret rows by exact key across all plugins (sync provider cleanup). */
findSecretsByKey(key) {
return this.db.prepare(`
SELECT plugin_id, key, secret_ref, ciphertext, created_at, updated_at
FROM plugin_secrets WHERE key = ?
`).all(key).map((row) => this.#mapSecret(row));
}
upsertSyncProviderBinding(providerId, pluginId) {
const now = this.clock();
this.db.prepare(`
INSERT INTO plugin_sync_provider_bindings(provider_id, plugin_id, created_at, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(provider_id) DO UPDATE SET
plugin_id = excluded.plugin_id,
updated_at = excluded.updated_at
`).run(providerId, pluginId, now, now);
}
getSyncProviderBinding(providerId) {
const row = this.db.prepare(`
SELECT provider_id, plugin_id, created_at, updated_at
FROM plugin_sync_provider_bindings WHERE provider_id = ?
`).get(providerId);
if (!row) return null;
return {
providerId: row.provider_id,
pluginId: row.plugin_id,
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
};
}
deleteSyncProviderBinding(providerId) {
const result = this.db.prepare(`
DELETE FROM plugin_sync_provider_bindings WHERE provider_id = ?
`).run(providerId);
return Number(result?.changes) || 0;
}
recordSecurityAudit(pluginId, event, details) {
let detailsJson = JSON.stringify(details ?? {});
const originalBytes = Buffer.byteLength(detailsJson);
+11 -1
View File
@@ -59,7 +59,7 @@ test("obsolete unpublished v1 layouts fail with an explicit reset instruction",
);
});
test("complete schema-1 databases migrate in place to schema 2 with sidecar table", (context) => {
test("complete schema-1 databases migrate in place to schema 3 with sidecar and binding tables", (context) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-v1-migrate-"));
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
const file = path.join(root, "plugins.sqlite");
@@ -164,6 +164,10 @@ test("complete schema-1 databases migrate in place to schema 2 with sidecar tabl
database.db.prepare("PRAGMA table_info(plugin_sync_sidecars)").all().map(({ name }) => name),
["plugin_id", "kind", "key", "value_json", "updated_at"],
);
assert.deepEqual(
database.db.prepare("PRAGMA table_info(plugin_sync_provider_bindings)").all().map(({ name }) => name),
["provider_id", "plugin_id", "created_at", "updated_at"],
);
// Existing rows survive the in-place migration.
assert.equal(database.db.prepare("SELECT id FROM plugins").get().id, "com.example.v1");
database.setSyncSidecar("com.example.v1", "settings", "theme\0application\0application", "dark", 2);
@@ -171,6 +175,8 @@ test("complete schema-1 databases migrate in place to schema 2 with sidecar tabl
database.getSyncSidecar("com.example.v1", "settings", "theme\0application\0application")?.value,
"dark",
);
database.upsertSyncProviderBinding("com.example.v1.sync", "com.example.v1");
assert.equal(database.getSyncProviderBinding("com.example.v1.sync")?.pluginId, "com.example.v1");
database.close();
});
@@ -212,6 +218,10 @@ test("initial schema scopes runtime and crash state to immutable plugin versions
database.db.prepare("PRAGMA table_info(plugin_sync_sidecars)").all().map(({ name }) => name),
["plugin_id", "kind", "key", "value_json", "updated_at"],
);
assert.deepEqual(
database.db.prepare("PRAGMA table_info(plugin_sync_provider_bindings)").all().map(({ name }) => name),
["provider_id", "plugin_id", "created_at", "updated_at"],
);
assert.deepEqual(database.db.prepare("PRAGMA foreign_key_list(plugin_settings)").all(), []);
assert.deepEqual(database.db.prepare("PRAGMA foreign_key_list(plugin_view_state)").all(), []);
assert.deepEqual(database.db.prepare("PRAGMA foreign_key_list(plugin_sync_sidecars)").all(), []);
+77 -6
View File
@@ -46,6 +46,7 @@ const CHANNELS = Object.freeze({
syncDeleteObject: "netcatty:plugins:sync-delete-object",
syncPutSecret: "netcatty:plugins:sync-put-secret",
syncDeleteSecrets: "netcatty:plugins:sync-delete-secrets",
syncRestoreSecrets: "netcatty:plugins:sync-restore-secrets",
syncSidecarsCollect: "netcatty:plugins:sync-sidecars-collect",
syncSidecarsApply: "netcatty:plugins:sync-sidecars-apply",
hostAvailableSync: "netcatty:plugins:host-available-sync",
@@ -1054,7 +1055,25 @@ function registerPluginBridge(ipcMain, options) {
if (!match || typeof match.pluginId !== "string") {
throw new Error(`Plugin sync provider is unavailable: ${providerId}`);
}
return secretStore.set(match.pluginId, key, value);
const existing = typeof secretStore.getReference === "function"
? secretStore.getReference(match.pluginId, key)
: null;
const stored = typeof secretStore.set === "function"
? secretStore.set(match.pluginId, key, value, { stashPrevious: true })
: null;
if (!stored) {
throw new Error("Plugin sync secret storage is unavailable");
}
// Remember which plugin owns this provider so disconnect can clean secrets
// after the contribution is gone (disabled/uninstalled).
if (typeof secretStore.bindSyncProviderPlugin === "function") {
try {
secretStore.bindSyncProviderPlugin(match.pluginId, providerId);
} catch {
/* binding is best-effort; credential write already succeeded */
}
}
return Object.freeze({ ...stored, created: !existing });
});
ipcMain.handle(CHANNELS.syncDeleteSecrets, async (event, payload) => {
@@ -1071,8 +1090,12 @@ function registerPluginBridge(ipcMain, options) {
const match = Array.isArray(providers)
? providers.find((entry) => entry?.provider?.id === providerId || entry?.id === providerId)
: null;
if (!match || typeof match.pluginId !== "string") {
// Provider may already be uninstalled; treat as no-op cleanup.
let pluginId = match && typeof match.pluginId === "string" ? match.pluginId : null;
if (!pluginId && typeof secretStore.resolveSyncProviderPlugin === "function") {
pluginId = secretStore.resolveSyncProviderPlugin(providerId) ?? null;
}
if (!pluginId) {
// No live contribution and no retained binding — nothing to delete.
return { deleted: 0 };
}
const keys = Array.isArray(payload?.keys) && payload.keys.length > 0
@@ -1080,10 +1103,14 @@ function registerPluginBridge(ipcMain, options) {
: null;
if (!keys) {
// Wipe every sync-credential* key (well-known + schema writeOnly fields).
let deleted = 0;
if (typeof secretStore.deleteByKeyPrefix === "function") {
const deleted = secretStore.deleteByKeyPrefix(match.pluginId, "sync-credential");
return { deleted: Number(deleted) || 0 };
deleted = Number(secretStore.deleteByKeyPrefix(pluginId, "sync-credential")) || 0;
}
if (typeof secretStore.unbindSyncProviderPlugin === "function") {
secretStore.unbindSyncProviderPlugin(pluginId, providerId);
}
return { deleted };
}
let deleted = 0;
for (const rawKey of keys ?? [
@@ -1096,7 +1123,7 @@ function registerPluginBridge(ipcMain, options) {
if (typeof rawKey !== "string" || rawKey.length < 1) continue;
const key = assertSecretKey(rawKey);
try {
secretStore.delete(match.pluginId, key);
secretStore.delete(pluginId, key);
deleted += 1;
} catch {
/* ignore missing / unavailable keys */
@@ -1105,6 +1132,50 @@ function registerPluginBridge(ipcMain, options) {
return { deleted };
});
ipcMain.handle(CHANNELS.syncRestoreSecrets, async (event, payload) => {
if (!isTrustedSender(event)) throw new Error("Untrusted plugin management sender");
if (!secretStore) {
throw new Error("Plugin sync secret restore is unavailable");
}
if (!extensionProviderService) throw new Error("Plugin sync Providers are unavailable");
const providerId = payload?.providerId;
if (typeof providerId !== "string" || providerId.length < 1) {
throw new TypeError("Sync provider ID is invalid");
}
const keys = Array.isArray(payload?.keys) ? payload.keys : [];
if (keys.length < 1) return { restored: 0, discarded: 0 };
const providers = extensionProviderService.listProviders({ kind: "sync" });
const match = Array.isArray(providers)
? providers.find((entry) => entry?.provider?.id === providerId || entry?.id === providerId)
: null;
let pluginId = match && typeof match.pluginId === "string" ? match.pluginId : null;
if (!pluginId && typeof secretStore.resolveSyncProviderPlugin === "function") {
pluginId = secretStore.resolveSyncProviderPlugin(providerId) ?? null;
}
if (!pluginId) return { restored: 0, discarded: 0 };
const discard = payload?.discard === true;
let restored = 0;
let discarded = 0;
for (const rawKey of keys) {
if (typeof rawKey !== "string" || rawKey.length < 1) continue;
const key = assertSecretKey(rawKey);
try {
if (discard) {
if (typeof secretStore.clearOverwriteStash === "function") {
secretStore.clearOverwriteStash(pluginId, key);
discarded += 1;
}
} else if (typeof secretStore.restoreOverwrite === "function"
&& secretStore.restoreOverwrite(pluginId, key)) {
restored += 1;
}
} catch {
/* best-effort restore/discard */
}
}
return { restored, discarded };
});
handlePassive(CHANNELS.credentialCatalogUpdate, 0, async (_activeManager, payload) => {
if (!credentialResolver || typeof credentialResolver.update !== "function") {
throw new Error("Plugin Vault credential catalog is unavailable");
+50 -1
View File
@@ -1548,5 +1548,54 @@ test("sync read/write shuttle Uint8Array inline and stream large writes", async
key: "sync-credential",
value: "pw",
});
assert.deepEqual(secret, { kind: "secret", id: "sec-1", key: "sync-credential", pluginId: "com.example", value: "pw" });
assert.deepEqual(secret, {
kind: "secret",
id: "sec-1",
key: "sync-credential",
pluginId: "com.example",
value: "pw",
created: true,
});
});
test("syncDeleteSecrets uses retained provider binding when contribution is missing", async () => {
const deleted = [];
const unbound = [];
const ipcMain = createIpcMain();
registerPluginBridge(ipcMain, {
manager: { async initialize() {} },
extensionProviderService: {
listProviders() {
return [];
},
},
secretStore: {
set() {
throw new Error("set should not run");
},
delete(pluginId, key) {
deleted.push([pluginId, key]);
},
deleteByKeyPrefix(pluginId, prefix) {
deleted.push([pluginId, `prefix:${prefix}`]);
return 2;
},
resolveSyncProviderPlugin(providerId) {
assert.equal(providerId, "com.example.sync");
return "com.example";
},
unbindSyncProviderPlugin(pluginId, providerId) {
unbound.push([pluginId, providerId]);
},
},
env: { NETCATTY_PLUGIN_DEV: "1" },
isTrustedSender: () => true,
});
const event = { sender: { id: 99, once() {}, isDestroyed: () => false } };
const result = await ipcMain.handlers.get(CHANNELS.syncDeleteSecrets)(event, {
providerId: "com.example.sync",
});
assert.deepEqual(result, { deleted: 2 });
assert.deepEqual(deleted, [["com.example", "prefix:sync-credential"]]);
assert.deepEqual(unbound, [["com.example", "com.example.sync"]]);
});
+127 -7
View File
@@ -31,12 +31,19 @@ function assertSecretRef(secret) {
}
class PluginSecretStore {
/** @type {Map<string, { value: string, secretRef: string }>} */
#overwriteStash = new Map();
constructor(options) {
this.database = options.database;
this.safeStorage = options.safeStorage ?? null;
this.randomBytes = options.randomBytes ?? randomBytes;
}
#stashKey(pluginId, key) {
return `${pluginId}\0${key}`;
}
#assertAvailable() {
const backend = this.safeStorage?.getSelectedStorageBackend?.();
if (
@@ -67,33 +74,146 @@ class PluginSecretStore {
return record;
}
set(pluginId, key, value) {
set(pluginId, key, value, options = {}) {
this.#assertAvailable();
assertSecretKey(key);
if (typeof value !== "string" || Buffer.byteLength(value, "utf8") > MAX_SECRET_BYTES) {
throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Plugin secret value is invalid or too large");
}
const secretRef = this.randomBytes(24).toString("base64url");
const ciphertext = this.safeStorage.encryptString(value);
const stashPrevious = options.stashPrevious === true;
const stashKey = this.#stashKey(pluginId, key);
let stashed = false;
if (stashPrevious && !this.#overwriteStash.has(stashKey)) {
// Keep an existing stash (e.g. after a failed restore) so a later retry
// still recovers the original SecretRef, not a rejected replacement.
const existing = this.getReference(pluginId, key);
if (existing) {
try {
this.#overwriteStash.set(stashKey, {
value: this.resolve(pluginId, existing),
// Keep the prior SecretRef id so saved provider connections still resolve.
secretRef: existing.id,
});
stashed = true;
} catch {
/* keep going; restore may be unavailable for this key */
}
}
}
try {
const secretRef = this.randomBytes(24).toString("base64url");
const ciphertext = this.safeStorage.encryptString(value);
if (!Buffer.isBuffer(ciphertext) || ciphertext.byteLength < 1) {
throw new PluginRpcError(RPC_ERRORS.unavailable, "OS-backed plugin secret encryption failed");
}
this.database.upsertSecret({ pluginId, key, secretRef, ciphertext });
return Object.freeze({ kind: "secret", id: secretRef, key });
} catch (error) {
// Failed replacement must not leave prior plaintext stranded in memory.
if (stashed) this.#overwriteStash.delete(stashKey);
throw error;
}
}
/**
* Restore the plaintext (and SecretRef id) stashed by the last overwrite.
* Returns true when a stashed value was written back.
*/
restoreOverwrite(pluginId, key) {
this.#assertAvailable();
assertSecretKey(key);
const stashKey = this.#stashKey(pluginId, key);
const previous = this.#overwriteStash.get(stashKey);
if (!previous || typeof previous.value !== "string" || typeof previous.secretRef !== "string") {
return false;
}
const ciphertext = this.safeStorage.encryptString(previous.value);
if (!Buffer.isBuffer(ciphertext) || ciphertext.byteLength < 1) {
throw new PluginRpcError(RPC_ERRORS.unavailable, "OS-backed plugin secret encryption failed");
}
this.database.upsertSecret({ pluginId, key, secretRef, ciphertext });
return Object.freeze({ kind: "secret", id: secretRef, key });
this.database.upsertSecret({
pluginId,
key,
secretRef: previous.secretRef,
ciphertext,
});
// Drop the stash only after the prior value is durably written so a failed
// encrypt/upsert can still be retried.
this.#overwriteStash.delete(stashKey);
return true;
}
clearOverwriteStash(pluginId, key) {
assertSecretKey(key);
this.#overwriteStash.delete(this.#stashKey(pluginId, key));
}
delete(pluginId, key) {
assertSecretKey(key);
this.#overwriteStash.delete(this.#stashKey(pluginId, key));
this.database.deleteSecret(pluginId, key);
}
deleteByKeyPrefix(pluginId, prefix) {
assertSecretKey(prefix);
let deleted = 0;
if (typeof this.database.deleteSecretsByKeyPrefix !== "function") {
this.delete(pluginId, prefix);
return 1;
deleted = 1;
} else {
deleted = this.database.deleteSecretsByKeyPrefix(pluginId, prefix);
}
return this.database.deleteSecretsByKeyPrefix(pluginId, prefix);
const pluginPrefix = `${pluginId}\0`;
for (const stashKey of [...this.#overwriteStash.keys()]) {
if (!stashKey.startsWith(pluginPrefix)) continue;
const secretKey = stashKey.slice(pluginPrefix.length);
if (secretKey === prefix || secretKey.startsWith(`${prefix}:`)) {
this.#overwriteStash.delete(stashKey);
}
}
return deleted;
}
/**
* Durable providerId → pluginId binding so disconnect can wipe sync secrets
* after the contribution disappears (disabled/uninstalled plugin).
* Stored in a host-owned table, not plugin-writable secrets.
*/
bindSyncProviderPlugin(pluginId, providerId) {
if (typeof pluginId !== "string" || pluginId.length < 1) {
throw new TypeError("Plugin id is invalid");
}
assertSecretKey(providerId);
if (
providerId !== pluginId
&& !providerId.startsWith(`${pluginId}.`)
) {
throw new TypeError("Sync provider id is outside the plugin namespace");
}
if (typeof this.database.upsertSyncProviderBinding !== "function") {
throw new Error("Plugin sync provider binding storage is unavailable");
}
this.database.upsertSyncProviderBinding(providerId, pluginId);
}
resolveSyncProviderPlugin(providerId) {
assertSecretKey(providerId);
if (typeof this.database.getSyncProviderBinding !== "function") return undefined;
const row = this.database.getSyncProviderBinding(providerId);
const pluginId = row?.pluginId;
if (typeof pluginId !== "string" || pluginId.length < 1) return undefined;
if (providerId !== pluginId && !providerId.startsWith(`${pluginId}.`)) {
return undefined;
}
return pluginId;
}
unbindSyncProviderPlugin(pluginId, providerId) {
assertSecretKey(providerId);
if (typeof this.database.deleteSyncProviderBinding !== "function") return;
const existing = this.database.getSyncProviderBinding(providerId);
if (existing && existing.pluginId !== pluginId) return;
this.database.deleteSyncProviderBinding(providerId);
}
resolve(pluginId, secret) {
+142
View File
@@ -301,3 +301,145 @@ test("credential lease resolution cannot return plaintext after operation cancel
leaseStore.shutdown();
database.close();
});
test("overwrite stash restores previous plaintext and discard clears it", (context) => {
const database = createDatabase(context);
let random = 0;
const store = new PluginSecretStore({
database,
safeStorage: fakeSafeStorage(),
randomBytes: () => Buffer.alloc(24, ++random),
});
const first = store.set("com.example.one", "sync-credential", "good-password");
assert.equal(store.resolve("com.example.one", first), "good-password");
const second = store.set("com.example.one", "sync-credential", "bad-password", { stashPrevious: true });
assert.equal(store.resolve("com.example.one", second), "bad-password");
assert.equal(store.restoreOverwrite("com.example.one", "sync-credential"), true);
const restored = store.getReference("com.example.one", "sync-credential");
assert.equal(restored.id, first.id, "restore must keep the prior SecretRef id");
assert.equal(store.resolve("com.example.one", first), "good-password");
assert.equal(store.restoreOverwrite("com.example.one", "sync-credential"), false);
store.set("com.example.one", "sync-credential", "another-bad", { stashPrevious: true });
store.clearOverwriteStash("com.example.one", "sync-credential");
assert.equal(store.restoreOverwrite("com.example.one", "sync-credential"), false);
assert.equal(
store.resolve("com.example.one", store.getReference("com.example.one", "sync-credential")),
"another-bad",
);
// Ordinary secrets.set-style overwrites must not stash plaintext by default.
store.set("com.example.one", "api-key", "one");
store.set("com.example.one", "api-key", "two");
assert.equal(store.restoreOverwrite("com.example.one", "api-key"), false);
database.close();
});
test("failed overwrite clears stashed plaintext", (context) => {
const database = createDatabase(context);
let failEncrypt = false;
const safeStorage = {
isEncryptionAvailable: () => true,
encryptString: (value) => {
if (failEncrypt) return Buffer.alloc(0);
return Buffer.from(`sealed:${Buffer.from(value).toString("base64")}`);
},
decryptString: (value) => {
const encoded = value.toString().slice("sealed:".length);
return Buffer.from(encoded, "base64").toString();
},
};
const store = new PluginSecretStore({ database, safeStorage });
store.set("com.example", "sync-credential", "good-password");
failEncrypt = true;
assert.throws(
() => store.set("com.example", "sync-credential", "bad-password", { stashPrevious: true }),
(error) => error.code === RPC_ERRORS.unavailable,
);
assert.equal(store.restoreOverwrite("com.example", "sync-credential"), false);
failEncrypt = false;
const ref = store.getReference("com.example", "sync-credential");
assert.equal(store.resolve("com.example", ref), "good-password");
database.close();
});
test("sync provider bindings live outside plugin secrets and reject namespace mismatches", (context) => {
const database = createDatabase(context);
const store = new PluginSecretStore({
database,
safeStorage: fakeSafeStorage(),
});
store.bindSyncProviderPlugin("com.example", "com.example.sync");
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), "com.example");
assert.throws(
() => store.bindSyncProviderPlugin("com.example", "com.other.sync"),
/outside the plugin namespace/,
);
store.set("com.attacker", "sync-provider-map:com.example.sync", "com.attacker");
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), "com.example");
store.unbindSyncProviderPlugin("com.example", "com.example.sync");
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), undefined);
database.close();
});
test("failed restoreOverwrite keeps stash for retry", (context) => {
const database = createDatabase(context);
let failEncrypt = false;
const safeStorage = {
isEncryptionAvailable: () => true,
encryptString: (value) => {
if (failEncrypt) return Buffer.alloc(0);
return Buffer.from(`sealed:${Buffer.from(value).toString("base64")}`);
},
decryptString: (value) => {
const encoded = value.toString().slice("sealed:".length);
return Buffer.from(encoded, "base64").toString();
},
};
const store = new PluginSecretStore({ database, safeStorage });
const first = store.set("com.example", "sync-credential", "good-password");
store.set("com.example", "sync-credential", "bad-password", { stashPrevious: true });
failEncrypt = true;
assert.throws(
() => store.restoreOverwrite("com.example", "sync-credential"),
(error) => error.code === RPC_ERRORS.unavailable,
);
failEncrypt = false;
assert.equal(store.restoreOverwrite("com.example", "sync-credential"), true);
assert.equal(store.resolve("com.example", first), "good-password");
database.close();
});
test("existing overwrite stash is preserved across retry puts and cleared by prefix delete", (context) => {
const database = createDatabase(context);
let failEncrypt = false;
const safeStorage = {
isEncryptionAvailable: () => true,
encryptString: (value) => {
if (failEncrypt) return Buffer.alloc(0);
return Buffer.from(`sealed:${Buffer.from(value).toString("base64")}`);
},
decryptString: (value) => {
const encoded = value.toString().slice("sealed:".length);
return Buffer.from(encoded, "base64").toString();
},
};
const store = new PluginSecretStore({ database, safeStorage });
const first = store.set("com.example", "sync-credential", "good-password");
store.set("com.example", "sync-credential", "bad-password", { stashPrevious: true });
failEncrypt = true;
assert.throws(
() => store.restoreOverwrite("com.example", "sync-credential"),
(error) => error.code === RPC_ERRORS.unavailable,
);
failEncrypt = false;
// Retry put must not replace the original good-password stash with bad-password.
store.set("com.example", "sync-credential", "worse-password", { stashPrevious: true });
assert.equal(store.restoreOverwrite("com.example", "sync-credential"), true);
assert.equal(store.resolve("com.example", first), "good-password");
store.set("com.example", "sync-credential", "temp", { stashPrevious: true });
store.deleteByKeyPrefix("com.example", "sync-credential");
assert.equal(store.restoreOverwrite("com.example", "sync-credential"), false);
assert.equal(store.getReference("com.example", "sync-credential"), undefined);
database.close();
});
+1
View File
@@ -183,6 +183,7 @@ function createPreloadApi(ctx) {
},
pluginSyncPutSecret: (request) => ipcRenderer.invoke("netcatty:plugins:sync-put-secret", request ?? {}),
pluginSyncDeleteSecrets: (request) => ipcRenderer.invoke("netcatty:plugins:sync-delete-secrets", request ?? {}),
pluginSyncRestoreSecrets: (request) => ipcRenderer.invoke("netcatty:plugins:sync-restore-secrets", request ?? {}),
collectPluginSyncSidecars: () => ipcRenderer.invoke("netcatty:plugins:sync-sidecars-collect", {}),
applyPluginSyncSidecars: (bundle) => ipcRenderer.invoke("netcatty:plugins:sync-sidecars-apply", bundle ?? { version: 1, entries: [] }),
/** True only when the main-process plugin host wired a sidecar service. */
Vendored
+6 -1
View File
@@ -434,11 +434,16 @@ declare global {
providerId: string;
key: string;
value: string;
}): Promise<{ kind: 'secret'; id: string; key: string }>;
}): Promise<{ kind: 'secret'; id: string; key: string; created?: boolean }>;
pluginSyncDeleteSecrets?(request: {
providerId: string;
keys?: string[];
}): Promise<{ deleted: number }>;
pluginSyncRestoreSecrets?(request: {
providerId: string;
keys: string[];
discard?: boolean;
}): Promise<{ restored: number; discarded?: number }>;
collectPluginSyncSidecars?(): Promise<unknown>;
applyPluginSyncSidecars?(bundle: unknown): Promise<{ applied: boolean; count?: number; entries?: unknown }>;
pluginHostReady?(): boolean;
@@ -103,11 +103,16 @@ type ElectronPluginSyncApi = {
providerId: string;
key: string;
value: string;
}) => Promise<{ kind: 'secret'; id: string; key: string }>;
}) => Promise<{ kind: 'secret'; id: string; key: string; created?: boolean }>;
pluginSyncDeleteSecrets?: (params: {
providerId: string;
keys?: string[];
}) => Promise<{ deleted: number }>;
pluginSyncRestoreSecrets?: (params: {
providerId: string;
keys: string[];
discard?: boolean;
}) => Promise<{ restored: number; discarded?: number }>;
};
function getPluginSyncApi(): ElectronPluginSyncApi | null {
@@ -387,7 +392,7 @@ export async function putPluginSyncSecret(params: {
providerId: string;
key?: string;
value: string;
}): Promise<{ kind: 'secret'; id: string; key: string }> {
}): Promise<{ kind: 'secret'; id: string; key: string; created?: boolean }> {
const api = getPluginSyncApi();
if (typeof api?.pluginSyncPutSecret !== 'function') {
throw new Error('Plugin sync secret storage is unavailable');
@@ -413,3 +418,20 @@ export async function deletePluginSyncSecrets(params: {
...(params.keys ? { keys: [...params.keys] } : {}),
});
}
/** Restore host-stashed plaintext after a rejected overwrite during reconnect. */
export async function restorePluginSyncSecrets(params: {
providerId: string;
keys: string[];
discard?: boolean;
}): Promise<{ restored: number; discarded?: number }> {
const api = getPluginSyncApi();
if (typeof api?.pluginSyncRestoreSecrets !== 'function') {
return { restored: 0, discarded: 0 };
}
return api.pluginSyncRestoreSecrets({
providerId: params.providerId,
keys: [...params.keys],
...(params.discard === true ? { discard: true } : {}),
});
}