mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c849bf6a73 | ||
|
|
0d9c4a6e02 | ||
|
|
acd5700284 | ||
|
|
05b33b5521 | ||
|
|
786e5b48ef | ||
|
|
6414c9e177 |
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']]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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", {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(), []);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"]]);
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user