import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto"; import { Effect } from "effect"; import { definePlugin, ownerForItemId, ProviderItemId, ProviderKey, StorageError, type CredentialProvider, type OwnerBinding, type PluginCtx,} from "@executor-js/sdk"; // ---------------------------------------------------------------------------// Encrypted DB-backed credential provider for self-host.//// Credential values are stored AES-256-GCM-encrypted in the executor's// plugin-storage table — never in plaintext, unlike the file-secrets provider.// The master key comes from the host (EXECUTOR_SECRET_KEY or a persisted key// file); a random per-value IV + auth tag are stored alongside the ciphertext.// Only node:crypto is used.//// This is the multi-tenant-safe default writable provider for the self-hosted// server, replacing the OS-keychain/plaintext-file providers that assume a// single desktop user.//// v2: the provider sees only an opaque `ProviderItemId` — there is NO scope// arg. The connection row that references the id owns the (tenant, owner,// subject) partition; the encrypted value is keyed solely by the opaque id.// Plugin storage writes carry an `owner`: the one embedded in the item id// (`oauth:org:…` → org-shared) when present, else the executor's binding.// --------------------------------------------------------------------------- type PluginStorage = PluginCtx<unknown>["pluginStorage"]; const COLLECTION = "secrets";const KEY_SALT = "executor-encrypted-secrets/v1";const PAYLOAD_VERSION = "v1"; /** Derive a 32-byte AES key from an arbitrary-length master key string. */const deriveKey = (master: string): Buffer => scryptSync(master, KEY_SALT, 32); const encryptSecret = (key: Buffer, plaintext: string): Effect.Effect<string, StorageError> => Effect.try({ try: () => { const iv = randomBytes(12); const cipher = createCipheriv("aes-256-gcm", key, iv); const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); const tag = cipher.getAuthTag(); return [ PAYLOAD_VERSION, iv.toString("base64"), tag.toString("base64"), ciphertext.toString("base64"), ].join("."); }, catch: (cause) => new StorageError({ message: "Failed to encrypt secret", cause }), }); const decryptSecret = (key: Buffer, payload: string): Effect.Effect<string, StorageError> => Effect.try({ // A malformed payload, a wrong key, or tampered bytes all surface here: // GCM verification fails in `decipher.final()`, and bad base64/arity throws // before that — both land in the StorageError channel. try: () => { const parts = payload.split("."); const iv = Buffer.from(parts[1] ?? "", "base64"); const tag = Buffer.from(parts[2] ?? "", "base64"); const ciphertext = Buffer.from(parts[3] ?? "", "base64"); const decipher = createDecipheriv("aes-256-gcm", key, iv); decipher.setAuthTag(tag); return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8"); }, catch: (cause) => new StorageError({ message: "Failed to decrypt secret", cause }), }); const ENCRYPTED_PROVIDER_KEY = ProviderKey.make("encrypted"); const makeEncryptedProvider = ( key: Buffer, storage: PluginStorage, binding: OwnerBinding,): CredentialProvider => ({ key: ENCRYPTED_PROVIDER_KEY, writable: true, get: (id: ProviderItemId) => storage .get<string>({ collection: COLLECTION, key: id }) .pipe( Effect.flatMap((entry) => (entry ? decryptSecret(key, entry.data) : Effect.succeed(null))), ), has: (id: ProviderItemId) => storage.get({ collection: COLLECTION, key: id }).pipe(Effect.map((entry) => entry !== null)), set: (id: ProviderItemId, value: string) => encryptSecret(key, value).pipe( Effect.flatMap((payload) => storage.put({ collection: COLLECTION, key: id, owner: ownerForItemId(id, binding), data: payload, }), ), Effect.asVoid, ), delete: (id: ProviderItemId) => storage.remove({ collection: COLLECTION, key: id, owner: ownerForItemId(id, binding) }), list: () => storage .list<string>({ collection: COLLECTION }) .pipe( Effect.map((entries) => entries.map((entry) => ({ id: ProviderItemId.make(entry.key), name: entry.key })), ), ),}); export interface EncryptedSecretsPluginConfig { /** * Master key (any non-empty string) — derived to 32 bytes via scrypt. The * host is responsible for supplying a strong, persistent key * (EXECUTOR_SECRET_KEY or a generated key file); a secret store with no key * is unsafe, so this is required. */ readonly key: string;} export const encryptedSecretsPlugin = definePlugin((options?: EncryptedSecretsPluginConfig) => { const master = options?.key; if (!master) { // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: a secret store with no master key is unsafe; fail loud at construction throw new Error("encryptedSecretsPlugin requires a non-empty `key`"); } const derivedKey = deriveKey(master); return { id: "encryptedSecrets" as const, storage: () => ({}), credentialProviders: (ctx: PluginCtx<unknown>): readonly CredentialProvider[] => [ makeEncryptedProvider(derivedKey, ctx.pluginStorage, ctx.owner), ], };}); // Exported for host-side tests / reuse.export { deriveKey, encryptSecret, decryptSecret }; // Boot-time repair for rows the pre-fix provider filed under the acting// caller's partition (issue #1453).export { encryptedSecretsRepartitionDataMigration, runSqliteEncryptedSecretsRepartition,} from "./repartition-migration";
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
import { Effect } from "effect";
import {
definePlugin,
ownerForItemId,
ProviderItemId,
ProviderKey,
StorageError,
type CredentialProvider,
type OwnerBinding,
type PluginCtx,
} from "@executor-js/sdk";
// ---------------------------------------------------------------------------
// Encrypted DB-backed credential provider for self-host.
//
// Credential values are stored AES-256-GCM-encrypted in the executor's
// plugin-storage table — never in plaintext, unlike the file-secrets provider.
// The master key comes from the host (EXECUTOR_SECRET_KEY or a persisted key
// file); a random per-value IV + auth tag are stored alongside the ciphertext.
// Only node:crypto is used.
//
// This is the multi-tenant-safe default writable provider for the self-hosted
// server, replacing the OS-keychain/plaintext-file providers that assume a
// single desktop user.
//
// v2: the provider sees only an opaque `ProviderItemId` — there is NO scope
// arg. The connection row that references the id owns the (tenant, owner,
// subject) partition; the encrypted value is keyed solely by the opaque id.
// Plugin storage writes carry an `owner`: the one embedded in the item id
// (`oauth:org:…` → org-shared) when present, else the executor's binding.
// ---------------------------------------------------------------------------
type PluginStorage = PluginCtx<unknown>["pluginStorage"];
const COLLECTION = "secrets";
const KEY_SALT = "executor-encrypted-secrets/v1";
const PAYLOAD_VERSION = "v1";
/** Derive a 32-byte AES key from an arbitrary-length master key string. */
const deriveKey = (master: string): Buffer => scryptSync(master, KEY_SALT, 32);
const encryptSecret = (key: Buffer, plaintext: string): Effect.Effect<string, StorageError> =>
Effect.try({
try: () => {
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", key, iv);
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return [
PAYLOAD_VERSION,
iv.toString("base64"),
tag.toString("base64"),
ciphertext.toString("base64"),
].join(".");
},
catch: (cause) => new StorageError({ message: "Failed to encrypt secret", cause }),
});
const decryptSecret = (key: Buffer, payload: string): Effect.Effect<string, StorageError> =>
Effect.try({
// A malformed payload, a wrong key, or tampered bytes all surface here:
// GCM verification fails in `decipher.final()`, and bad base64/arity throws
// before that — both land in the StorageError channel.
try: () => {
const parts = payload.split(".");
const iv = Buffer.from(parts[1] ?? "", "base64");
const tag = Buffer.from(parts[2] ?? "", "base64");
const ciphertext = Buffer.from(parts[3] ?? "", "base64");
const decipher = createDecipheriv("aes-256-gcm", key, iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
},
catch: (cause) => new StorageError({ message: "Failed to decrypt secret", cause }),
});
const ENCRYPTED_PROVIDER_KEY = ProviderKey.make("encrypted");
const makeEncryptedProvider = (
key: Buffer,
storage: PluginStorage,
binding: OwnerBinding,
): CredentialProvider => ({
key: ENCRYPTED_PROVIDER_KEY,
writable: true,
get: (id: ProviderItemId) =>
storage
.get<string>({ collection: COLLECTION, key: id })
.pipe(
Effect.flatMap((entry) => (entry ? decryptSecret(key, entry.data) : Effect.succeed(null))),
),
has: (id: ProviderItemId) =>
storage.get({ collection: COLLECTION, key: id }).pipe(Effect.map((entry) => entry !== null)),
set: (id: ProviderItemId, value: string) =>
encryptSecret(key, value).pipe(
Effect.flatMap((payload) =>
storage.put({
collection: COLLECTION,
key: id,
owner: ownerForItemId(id, binding),
data: payload,
}),
),
Effect.asVoid,
),
delete: (id: ProviderItemId) =>
storage.remove({ collection: COLLECTION, key: id, owner: ownerForItemId(id, binding) }),
list: () =>
storage
.list<string>({ collection: COLLECTION })
.pipe(
Effect.map((entries) =>
entries.map((entry) => ({ id: ProviderItemId.make(entry.key), name: entry.key })),
),
),
});
export interface EncryptedSecretsPluginConfig {
/**
* Master key (any non-empty string) — derived to 32 bytes via scrypt. The
* host is responsible for supplying a strong, persistent key
* (EXECUTOR_SECRET_KEY or a generated key file); a secret store with no key
* is unsafe, so this is required.
*/
readonly key: string;
}
export const encryptedSecretsPlugin = definePlugin((options?: EncryptedSecretsPluginConfig) => {
const master = options?.key;
if (!master) {
// oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: a secret store with no master key is unsafe; fail loud at construction
throw new Error("encryptedSecretsPlugin requires a non-empty `key`");
}
const derivedKey = deriveKey(master);
return {
id: "encryptedSecrets" as const,
storage: () => ({}),
credentialProviders: (ctx: PluginCtx<unknown>): readonly CredentialProvider[] => [
makeEncryptedProvider(derivedKey, ctx.pluginStorage, ctx.owner),
],
};
});
// Exported for host-side tests / reuse.
export { deriveKey, encryptSecret, decryptSecret };
// Boot-time repair for rows the pre-fix provider filed under the acting
// caller's partition (issue #1453).
export {
encryptedSecretsRepartitionDataMigration,
runSqliteEncryptedSecretsRepartition,
} from "./repartition-migration";