Files
NetMesh/electron/plugins/secretStore.cjs
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

349 lines
13 KiB
JavaScript

"use strict";
const { randomBytes } = require("node:crypto");
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
const MAX_SECRET_BYTES = 64 * 1024;
function assertSecretKey(key) {
if (typeof key !== "string" || key.length < 1 || key.length > 256 || key.includes("\0")) {
throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Plugin secret key is invalid");
}
return key;
}
function assertSecretRef(secret) {
if (
!secret
|| typeof secret !== "object"
|| Array.isArray(secret)
|| secret.kind !== "secret"
|| typeof secret.id !== "string"
|| secret.id.length < 16
|| secret.id.length > 256
|| typeof secret.key !== "string"
|| secret.key.length < 1
|| secret.key.length > 256
|| secret.key.includes("\0")
) throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Plugin secret reference is invalid");
return { id: secret.id, key: secret.key };
}
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 (
!this.safeStorage?.isEncryptionAvailable?.()
|| backend === "basic_text"
|| typeof this.safeStorage.encryptString !== "function"
|| typeof this.safeStorage.decryptString !== "function"
) {
throw new PluginRpcError(
RPC_ERRORS.unavailable,
"Secure OS-backed encryption is unavailable for plugin secrets",
);
}
}
getReference(pluginId, key) {
assertSecretKey(key);
const record = this.database.getSecretByKey(pluginId, key);
return record ? Object.freeze({ kind: "secret", id: record.secretRef, key: record.key }) : undefined;
}
getRecordByReference(pluginId, secret) {
const reference = assertSecretRef(secret);
const record = this.database.getSecretByRef(pluginId, reference.id);
if (!record || record.key !== reference.key) {
throw new PluginRpcError(RPC_ERRORS.notFound, "Plugin secret reference was not found");
}
return record;
}
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 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: 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);
deleted = 1;
} else {
deleted = 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.
* Overwrites any prior empty-plugin_id unbind tombstone.
*/
bindSyncProviderPlugin(pluginId, providerId) {
if (typeof pluginId !== "string" || pluginId.length < 1) {
throw new TypeError("Plugin id is invalid");
}
assertSecretKey(providerId);
// Contribution provider ids live under the owning plugin namespace
// (`pluginId.`…); they are never equal to the plugin id itself.
if (!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") {
const row = this.database.getSyncProviderBinding(providerId);
const pluginId = row?.pluginId;
if (
typeof pluginId === "string"
&& pluginId.length > 0
&& providerId.startsWith(`${pluginId}.`)
) {
return pluginId;
}
}
// Do not infer ownership from sync-credential* key prefixes: a parent
// plugin id may namespace-prefix another plugin's provider and would cause
// disconnect to delete the wrong secrets. Pre-binding installs recover via
// (1) backfillSyncProviderBindingsFromLegacySecrets for intermediate map
// rows, (2) backfillSyncProviderBindingsFromLiveProviders using host
// contribution metadata at startup, or (3) the next successful put.
return undefined;
}
/**
* Seed missing provider→plugin bindings from contribution metadata.
*
* Real schema-2 installs only stored plugin-scoped `sync-credential*` rows
* (no per-provider ownership). After upgrade, seed a binding only when a
* plugin with credentials has exactly one sync provider id across the
* catalog: binding every historical/active provider would let disconnecting
* a stale provider wipe the shared credential prefix used by the live one.
* Multi-provider plugins wait for the next successful put (or map backfill).
*
* @param {Array<{ pluginId?: string, id?: string, provider?: { id?: string } }>} providers
* @returns {number} number of newly written bindings
*/
backfillSyncProviderBindingsFromLiveProviders(providers) {
if (!Array.isArray(providers)) return 0;
if (typeof this.database.listPluginIdsWithSecretKeyPrefix !== "function") return 0;
const credentialOwners = new Set(
this.database.listPluginIdsWithSecretKeyPrefix("sync-credential")
.filter((id) => typeof id === "string" && id.length > 0),
);
if (credentialOwners.size === 0) return 0;
/** @type {Map<string, Set<string>>} pluginId -> provider ids it declares */
const providersByPlugin = new Map();
/** @type {Map<string, Set<string>>} providerId -> plugins claiming it */
const pluginsByProvider = new Map();
for (const entry of providers) {
const pluginId = entry?.pluginId;
const providerId = entry?.provider?.id ?? entry?.id;
if (typeof pluginId !== "string" || pluginId.length < 1) continue;
if (typeof providerId !== "string" || providerId.length < 1) continue;
if (!providerId.startsWith(`${pluginId}.`)) continue;
// Count every manifest claim for conflict detection — including live
// plugins that have not stored credentials yet. Binding only a
// credential-backed legacy parent would orphan credentials when a later
// disconnect prefers the live nested owner (Codex P2 on 8d64ea20).
const claimants = pluginsByProvider.get(providerId) || new Set();
claimants.add(pluginId);
pluginsByProvider.set(providerId, claimants);
// Only credential-backed plugins are eligible to become the binding owner.
if (!credentialOwners.has(pluginId)) continue;
const set = providersByPlugin.get(pluginId) || new Set();
set.add(providerId);
providersByPlugin.set(pluginId, set);
}
let promoted = 0;
for (const [pluginId, providerIds] of providersByPlugin) {
if (providerIds.size !== 1) continue;
const providerId = [...providerIds][0];
// Two credential-backed plugins can both declare the same provider via
// nested namespaces (legacy disabled parent + live child). Binding the
// first writer would make a later missing-provider disconnect delete the
// wrong sync-credential* rows. Skip any cross-plugin claim conflict.
const claimants = pluginsByProvider.get(providerId);
if (!claimants || claimants.size !== 1) continue;
// Skip active binds and explicit-unbind tombstones alike.
if (typeof this.database.getSyncProviderBinding === "function"
&& this.database.getSyncProviderBinding(providerId)) {
continue;
}
try {
this.bindSyncProviderPlugin(pluginId, providerId);
promoted += 1;
} catch {
/* invalid contribution ids stay unbound */
}
}
return promoted;
}
unbindSyncProviderPlugin(pluginId, providerId) {
assertSecretKey(providerId);
if (typeof this.database.upsertSyncProviderBinding !== "function") return;
const existing = typeof this.database.getSyncProviderBinding === "function"
? this.database.getSyncProviderBinding(providerId)
: null;
// Do not steal another plugin's active binding. Empty plugin_id is an
// unbind tombstone and may be refreshed idempotently.
if (
existing
&& typeof existing.pluginId === "string"
&& existing.pluginId.length > 0
&& existing.pluginId !== pluginId
) {
return;
}
// Consume leftover intermediate-build map markers so they cannot re-seed
// a binding if the tombstone is ever cleared. Delete under this plugin and
// any residual map rows for the same provider across plugins.
const mapKey = `sync-provider-map:${providerId}`;
if (typeof this.database.deleteSecret === "function") {
try {
this.database.deleteSecret(pluginId, mapKey);
} catch {
/* ignore */
}
}
if (typeof this.database.findSecretsByKey === "function" && typeof this.database.deleteSecret === "function") {
for (const row of this.database.findSecretsByKey(mapKey) || []) {
if (typeof row?.pluginId !== "string" || typeof row?.key !== "string") continue;
try {
this.database.deleteSecret(row.pluginId, row.key);
} catch {
/* ignore */
}
}
}
// Tombstone with empty plugin_id so leftover sync-provider-map:* secret
// rows cannot re-promote this provider on the next database open.
this.database.upsertSyncProviderBinding(providerId, "");
}
resolve(pluginId, secret) {
this.#assertAvailable();
const record = this.getRecordByReference(pluginId, secret);
try {
return this.safeStorage.decryptString(record.ciphertext);
} catch {
throw new PluginRpcError(RPC_ERRORS.dataLoss, "Plugin secret could not be decrypted");
}
}
}
module.exports = {
MAX_SECRET_BYTES,
PluginSecretStore,
assertSecretKey,
assertSecretRef,
};