Skip to content

Latest commit

 

History

History
495 lines (380 loc) · 20.5 KB

File metadata and controls

495 lines (380 loc) · 20.5 KB

Credential Management

Overview

Workglow provides a layered credential management system for storing and resolving sensitive values -- API keys, tokens, and passwords -- required by AI providers and other external services. The system is designed around three principles: pluggable backends (swap between in-memory, environment variable, or encrypted storage without changing task code), automatic resolution (credentials are looked up transparently during the input resolution phase before a task executes), and worker isolation (credentials are resolved on the main thread and passed as plain values into worker contexts, so workers never need access to credential stores or key material).

The credential subsystem spans several packages:

File Purpose
packages/util/src/credentials/ICredentialStore.ts ICredentialStore interface and CREDENTIAL_STORE service token
packages/util/src/credentials/InMemoryCredentialStore.ts Non-persistent in-memory store for development
packages/util/src/credentials/EnvCredentialStore.ts Read credentials from environment variables
packages/util/src/credentials/ChainedCredentialStore.ts Layered resolution across multiple stores
packages/util/src/credentials/CredentialStoreRegistry.ts Global store registration, resolveCredential(), and input resolver registration
packages/util/src/credentials/OtpPassphraseCache.ts XOR-masked passphrase cache with TTL
packages/util/src/credentials/CredentialProviderOptions.ts Provider enum values for metadata
packages/util/src/credentials/CredentialPutInputSchema.ts JSON Schema for credential storage forms
packages/util/src/crypto/WebCrypto.ts AES-256-GCM encryption/decryption via Web Crypto API
packages/storage/src/credentials/EncryptedKvCredentialStore.ts Encrypted-at-rest store backed by any IKvStorage
packages/storage/src/credentials/LazyEncryptedCredentialStore.ts Lock/unlock wrapper for EncryptedKvCredentialStore

Credential Resolution Strategy

When an AI provider needs an API key, the resolution follows a strict three-tier fallback chain: credential_key (looked up from the credential store), then api_key (inline literal), then environment variable (provider-specific). Every provider's getClient() function implements this pattern identically:

const apiKey =
  config?.credential_key ||  // 1. Resolved from credential store
  config?.api_key ||         // 2. Inline API key in provider_config
  process.env?.ANTHROPIC_API_KEY;  // 3. Environment variable fallback

The credential_key field in provider_config is annotated with format: "credential" in the model schema. During the resolveSchemaInputs() phase of task execution, the TaskRunner walks the input schema, finds properties with this format annotation, and invokes the registered "credential" input resolver. That resolver calls resolveCredential(id, registry) which queries the credential store for the key and returns the actual secret value. By the time the provider's getClient() function runs, credential_key already contains the resolved API key string -- not the store lookup key.

The full resolution sequence during task execution:

1. User configures model:
   { provider_config: { credential_key: "my-anthropic-key", model_name: "claude-sonnet-4-20250514" } }

2. TaskRunner calls resolveSchemaInputs() before execute():
   - Walks input schema, finds credential_key with format: "credential"
   - Calls registered "credential" resolver
   - Resolver calls resolveCredential("my-anthropic-key", registry)
   - Credential store returns "sk-ant-..." (the actual API key)
   - credential_key is now "sk-ant-..."

3. Provider getClient() receives the resolved config:
   config.credential_key === "sk-ant-..."  // Already the real API key

If the credential store does not contain the requested key, the resolver returns the original string unchanged, allowing getClient() to fall through to the api_key or environment variable checks.


ICredentialStore Interface

All credential stores implement the ICredentialStore interface, which provides a minimal key-value API for secret management:

interface ICredentialStore {
  get(key: string): Promise<string | undefined>;
  put(key: string, value: string, options?: CredentialPutOptions): Promise<void>;
  delete(key: string): Promise<boolean>;
  has(key: string): Promise<boolean>;
  keys(): Promise<readonly string[]>;
  deleteAll(): Promise<void>;
}

The keys() method returns only key names, never values. The get() method returns undefined both when a key does not exist and when it has expired. Implementations must not log or expose credential values in error messages.

Each credential can carry optional metadata via CredentialPutOptions:

interface CredentialPutOptions {
  readonly label?: string;      // Human-readable label
  readonly provider?: string;   // Associated provider name (e.g., "anthropic")
  readonly expiresAt?: Date;    // Expiration date (undefined = never)
}

The global credential store is registered through the dependency injection system under the CREDENTIAL_STORE service token:

import { CREDENTIAL_STORE, setGlobalCredentialStore } from "@workglow/util";

setGlobalCredentialStore(myStore);

// Or register directly in a ServiceRegistry
registry.registerInstance(CREDENTIAL_STORE, myStore);

Credential Store Implementations

InMemoryCredentialStore

A plain Map-backed store for development and testing. Credentials are stored in memory and lost when the process exits. It respects expiration dates -- expired entries are cleaned up lazily on get(), has(), and keys() calls.

import { InMemoryCredentialStore } from "@workglow/util";

const store = new InMemoryCredentialStore();
await store.put("openai-api-key", "sk-...", { provider: "openai" });
const key = await store.get("openai-api-key"); // "sk-..."

EnvCredentialStore

Reads credentials from environment variables using either explicit key-to-variable mappings or a prefix-based naming convention. When no explicit mapping exists for a key, the store converts the key to uppercase, replaces hyphens with underscores, and optionally prepends a prefix.

import { EnvCredentialStore } from "@workglow/util";

// Explicit mapping
const store = new EnvCredentialStore({
  "anthropic-api-key": "ANTHROPIC_API_KEY",
  "openai-api-key": "OPENAI_API_KEY",
});
const key = await store.get("anthropic-api-key"); // reads process.env.ANTHROPIC_API_KEY

// Prefix convention
const prefixed = new EnvCredentialStore({}, "WORKGLOW");
// "my-api-key" resolves to process.env.WORKGLOW_MY_API_KEY

The put() method sets the environment variable for the current process lifetime. The environment variable check uses typeof process !== "undefined" to avoid crashing in browser environments where process is not available.

EncryptedKvCredentialStore

Encrypts credential values with AES-256-GCM before persisting them to any IKvStorage backend (SQLite, PostgreSQL, IndexedDB, in-memory). This is the recommended store for production deployments. Encryption uses the Web Crypto API, available in Node 20+, Bun, and all modern browsers.

import { EncryptedKvCredentialStore } from "@workglow/storage";
import { SqliteKvStorage } from "@workglow/storage";

const kv = new SqliteKvStorage(":memory:");
const store = new EncryptedKvCredentialStore(kv, "my-encryption-passphrase");

await store.put("openai-api-key", "sk-...", { provider: "openai" });
const key = await store.get("openai-api-key"); // "sk-..."

Each stored credential record contains: the encrypted ciphertext (base64), the initialization vector (base64), and plaintext metadata (label, provider, timestamps, expiration). The passphrase is never stored -- it must be provided at construction time.

LazyEncryptedCredentialStore

A lock/unlock wrapper around EncryptedKvCredentialStore. The store starts in a locked state where reads silently return undefined and writes throw. This is useful in applications where the user provides a passphrase after startup:

import { LazyEncryptedCredentialStore } from "@workglow/storage";

const lazy = new LazyEncryptedCredentialStore(kvStorage);
await lazy.get("key"); // undefined (locked)

lazy.unlock("user-passphrase");
await lazy.get("key"); // decrypted value

lazy.lock(); // discards inner store and derived key cache

ChainedCredentialStore

Combines multiple stores into a single ICredentialStore with cascading lookup. Reads try each store in order, returning the first match. Writes always go to the first (primary) store. This enables layered resolution patterns:

import {
  ChainedCredentialStore,
  InMemoryCredentialStore,
  EnvCredentialStore,
} from "@workglow/util";
import { EncryptedKvCredentialStore } from "@workglow/storage";

const store = new ChainedCredentialStore([
  new InMemoryCredentialStore(),                        // Runtime overrides
  new EncryptedKvCredentialStore(kv, passphrase),       // Persistent encrypted
  new EnvCredentialStore({ "openai": "OPENAI_API_KEY" }), // Environment fallback
]);

Provider Config Fields

Every model configuration includes a provider_config object. The base ModelConfigSchema declares the common credential_key field, while each provider extends it with provider-specific fields:

Field Type Description
credential_key string Key to look up in the credential store. Annotated with format: "credential" for automatic resolution.
api_key string Inline API key (fallback when credential_key is absent). Not part of the persisted schema -- used only in getClient() resolution.
base_url string Override the provider's default API endpoint. Useful for proxies, Azure OpenAI, or self-hosted instances.
model_name string The provider-specific model identifier (e.g., "claude-sonnet-4-20250514", "gpt-4o").
max_tokens integer Default max tokens for responses (Anthropic-specific).
organization string Organization ID (OpenAI-specific).

The credential_key field is marked with "x-ui-hidden": true in provider schemas, keeping it out of end-user form UIs while still participating in the automatic resolution system.


Provider-Specific Patterns

Each provider's getClient() function follows the same resolution pattern but checks different environment variables as the final fallback:

Provider Environment Variable(s) Client Constructor
Anthropic ANTHROPIC_API_KEY new Anthropic({ apiKey, baseURL })
OpenAI OPENAI_API_KEY new OpenAI({ apiKey, baseURL, organization })
Google Gemini GOOGLE_API_KEY, GEMINI_API_KEY new GoogleGenerativeAI(apiKey)
Hugging Face HF_TOKEN new InferenceClient(apiKey)
Ollama (none -- local service) new Ollama({ host })

Ollama is the exception: it does not require an API key because it runs as a local service. Its getClient() only reads base_url from provider_config, defaulting to http://localhost:11434.

Providers that support browser usage (Anthropic, OpenAI) pass dangerouslyAllowBrowser: true when they detect a browser or web worker context via typeof globalThis.document !== "undefined" || "WorkerGlobalScope" in globalThis.


Worker Isolation

Workers run in an isolated runtime with a separate globalServiceRegistry. They do not have access to the main thread's credential store, model repository, or other registered services. This is a deliberate security boundary: credentials are resolved on the main thread and passed into workers as plain string values through the serialized job input.

The flow works as follows:

  1. The TaskRunner on the main thread calls resolveSchemaInputs(), which resolves credential_key values from the credential store into actual API key strings.
  2. The resolved ModelConfig (now containing the real API key in credential_key) is passed as part of the AiJobInput to the worker via structured cloning.
  3. Inside the worker, the provider's getClient() reads config.credential_key directly -- it receives the already-resolved API key, not a store lookup key.

This design means:

  • Workers never access credential stores. They receive only the specific credential values they need for a single execution.
  • Passphrase material stays on the main thread. The encryption passphrase for EncryptedKvCredentialStore never crosses the worker boundary.
  • The blast radius is minimized. A compromised worker can only see the credentials passed to it for the current job, not the entire credential store.

Security Model

Encryption at Rest

EncryptedKvCredentialStore encrypts each credential value with AES-256-GCM using the Web Crypto API. Key derivation uses PBKDF2 with 600,000 iterations and SHA-256, following current OWASP recommendations. Each encryption operation generates a random 16-byte salt and 12-byte IV. The salt is prepended to the ciphertext so that decryption can reconstruct the derived key. Derived CryptoKey objects are cached per-salt in memory to avoid redundant PBKDF2 work.

Metadata (label, provider, timestamps) is stored in plaintext alongside the encrypted value, since it contains no secret material.

Passphrase Caching with OtpPassphraseCache

For applications that prompt the user for a passphrase, OtpPassphraseCache provides a time-limited in-memory cache that avoids storing the plaintext passphrase directly. It XOR-masks the passphrase with a random one-time pad and stores only the masked value and the pad as Uint8Array instances. The plaintext is reconstructed on each retrieve() call by XOR-ing the two arrays back together.

import { OtpPassphraseCache } from "@workglow/util";

const cache = new OtpPassphraseCache({
  hardTtlMs: 6 * 60 * 60 * 1000, // 6 hours absolute expiry
  idleTtlMs: 30 * 60 * 1000,     // 30 minutes idle expiry
  onExpiry: () => lazyStore.lock(),
});

cache.store("user-entered-passphrase");
const passphrase = cache.retrieve(); // Reconstructs plaintext
cache.clear(); // Zeroes both buffers

The cache supports two TTL modes: a hard TTL (unconditional expiry) and an idle TTL (resets on each retrieve() call). When either fires, both buffers are zeroed and the onExpiry callback is invoked -- typically used to lock a LazyEncryptedCredentialStore.

Minimal Exposure Principles

  • Never log credential values. The ICredentialStore contract explicitly prohibits implementations from including secret values in error messages.
  • keys() never returns values. Only key names are listed, never the secrets themselves.
  • Scoped resolution. resolveCredential() accepts an optional ServiceRegistry parameter, allowing different parts of an application to use different credential stores. A registry-scoped store takes precedence over the global store.
  • Expired credentials are garbage-collected. All store implementations check expiresAt on reads and silently remove expired entries.

Platform Differences

Server (Node.js / Bun)

  • EnvCredentialStore reads process.env directly.
  • EncryptedKvCredentialStore typically uses SqliteKvStorage or PostgresKvStorage as its backend.
  • OtpPassphraseCache timers are .unref()-ed so they do not keep the process alive.

Browser

  • EnvCredentialStore returns undefined for all lookups (no process.env). Browser applications should use InMemoryCredentialStore or EncryptedKvCredentialStore backed by IndexedDbKvStorage.
  • The Web Crypto API (crypto.subtle) is available natively -- no polyfills needed.
  • Provider clients (Anthropic, OpenAI) set dangerouslyAllowBrowser: true when they detect a browser or web worker context (see packages/ai-provider/src/provider-anthropic/common/Anthropic_Client.ts:48 and packages/ai-provider/src/provider-openai/common/OpenAI_Client.ts:49). This is not automatically safe: any API key loaded into a browser context — even one decrypted from EncryptedKvCredentialStore at runtime — is reachable from the page and should be treated as exposed to the end user. Use browser-side provider calls only with short-lived, user-scoped keys, a proxy that injects credentials server-side, or local providers (Ollama, Transformers.js, MediaPipe) that do not require a secret at all.
  • Web Workers receive credentials via structured cloning, same as Node/Bun workers.

Configuration Reference

Setting Up a Production Credential Store

import { ChainedCredentialStore, InMemoryCredentialStore, EnvCredentialStore,
         setGlobalCredentialStore } from "@workglow/util";
import { EncryptedKvCredentialStore, SqliteKvStorage } from "@workglow/storage";

// 1. Create the encrypted backend
const kv = new SqliteKvStorage("credentials.db");
const passphrase = process.env.CREDENTIAL_PASSPHRASE;
if (!passphrase) {
  throw new Error("CREDENTIAL_PASSPHRASE environment variable is required");
}
const encrypted = new EncryptedKvCredentialStore(kv, passphrase);

// 2. Chain with environment fallback
const store = new ChainedCredentialStore([
  encrypted,
  new EnvCredentialStore({
    "anthropic-api-key": "ANTHROPIC_API_KEY",
    "openai-api-key": "OPENAI_API_KEY",
    "google-api-key": "GOOGLE_API_KEY",
    "hf-token": "HF_TOKEN",
  }),
]);

// 3. Register globally
setGlobalCredentialStore(store);

Configuring a Model with Credential Key

import { ModelRepository } from "@workglow/ai";

const repo = new ModelRepository(storage);
await repo.upsert({
  model_id: "claude-sonnet",
  title: "Claude Sonnet",
  description: "Anthropic Claude Sonnet model",
  provider: "anthropic",
  tasks: ["TextGenerationTask", "TextSummaryTask"],
  provider_config: {
    model_name: "claude-sonnet-4-20250514",
    credential_key: "anthropic-api-key",  // Resolved from credential store at runtime
    max_tokens: 4096,
  },
  metadata: {},
});

Using Scoped Credential Stores

import { ServiceRegistry, CREDENTIAL_STORE, InMemoryCredentialStore } from "@workglow/util";

// Create a scoped registry with its own credential store
const registry = new ServiceRegistry();
const scopedStore = new InMemoryCredentialStore();
await scopedStore.put("api-key", "sk-scoped-...");
registry.registerInstance(CREDENTIAL_STORE, scopedStore);

// resolveCredential checks the registry first, then falls back to global
import { resolveCredential } from "@workglow/util";
const key = await resolveCredential("api-key", registry); // "sk-scoped-..."

API Reference

Core Functions

Function Package Description
getGlobalCredentialStore() @workglow/util Returns the current global ICredentialStore instance.
setGlobalCredentialStore(store) @workglow/util Replaces the global credential store.
resolveCredential(key, registry?) @workglow/util Resolves a credential by key, checking the registry-scoped store first, then the global store.

Store Classes

Class Package Description
InMemoryCredentialStore @workglow/util Map-backed store for development.
EnvCredentialStore @workglow/util Environment variable-backed store with explicit or prefix-based key mapping.
ChainedCredentialStore @workglow/util Cascading lookup across multiple stores; writes to the first.
EncryptedKvCredentialStore @workglow/storage AES-256-GCM encrypted store backed by any IKvStorage.
LazyEncryptedCredentialStore @workglow/storage Lock/unlock wrapper that defers passphrase until needed.

Security Utilities

Class / Function Package Description
OtpPassphraseCache @workglow/util XOR-masked passphrase cache with hard and idle TTLs.
encrypt(plaintext, passphrase, keyCache) @workglow/util AES-256-GCM encryption returning { encrypted, iv }.
decrypt(encrypted, iv, passphrase, keyCache) @workglow/util AES-256-GCM decryption returning plaintext.
deriveKey(passphrase, salt) @workglow/util PBKDF2 key derivation (600,000 iterations, SHA-256).

Service Tokens

Token Type Description
CREDENTIAL_STORE ServiceToken<ICredentialStore> DI token for the credential store. Registered with a default InMemoryCredentialStore factory.