159 lines · 5.7 KB · TypeScript
RawDownload
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";