[支持云网关BYOK(自带密钥)] - Support BYOK (Bring Your Own Key) for Cloud Gateways
实施指南,允许用户为云部署的网关提供自己的AI提供商API密钥(Anthropic、OpenAI),从而实现自定义计费和配额管理。
🔍 症状
表明需要 BYOK 支持的使用场景
用户在操作云端部署的网关时会遇到以下情况:
场景 1:账单集中管理
用户希望 AI API 使用费用显示在其自有企业账户下,而非应用程序的计费系统中。
# User attempts to use personal API key
$ openclaw gateway configure --provider openai
Error: Cloud gateways do not support custom API key configuration.
Bundled credentials will be used instead.
场景 2:配额管理
企业用户需要利用其现有的 API 速率限制和配额分配,而不是受限于应用程序的合并限制。
# Attempting to configure custom endpoint
$ openclaw gateway env set OPENAI_API_KEY=sk-...
Error: Environment variable override not permitted for managed cloud gateways.
场景 3:多提供商环境
需要同时访问多个 AI 提供商(Anthropic 提供 Claude,OpenAI 提供 GPT 模型)的组织无法将其使用账单分配到各自的账户。
# Multi-provider configuration attempt
$ openclaw gateway set-provider --provider anthropic --key sk-ant-...
ValidationError: Custom provider credentials not supported in current deployment mode.
场景 4:合规和审计要求
具有严格合规要求的公司需要所有 API 调用都使用其自有凭证进行,以满足审计跟踪的目的。
# Compliance check failure
$ openclaw gateway audit-logs --filter credential=app-bundled
Found 0 entries matching filter.
All requests used bundled application credentials.
🧠 根因分析
架构差距分析
云端网关缺少 BYOK 支持源于三个基本架构限制:
1. 凭证注入模型
当前的云端网关部署使用捆绑凭证模型,其中 API 密钥在容器/构建时嵌入。部署管道在 CI/CD 过程中注入共享的应用程序凭证:
# Current deployment architecture
┌─────────────┐ ┌──────────────────┐ ┌─────────────┐
│ Build Time │───▶│ Baked-in Keys │───▶│ Runtime │
│ Config │ │ (immutable) │ │ Gateway │
└─────────────┘ └──────────────────┘ └─────────────┘
# No runtime override capability
gateway-config.yaml 缺少一个能够启用运行时密钥注入的 credential_mode 字段。
2. 安全层约束
当前安全架构不包含用于云部署的密钥链或密钥管理集成层:
# lib/gateway/security/index.ts
interface GatewaySecurityConfig {
// Current state - no BYOK support
bundledCredentials: true;
runtimeOverride: false; // ← This is the gap
}
密钥注入发生在部署时,而非运行时,这阻止了用户在部署后更新凭证。
3. 提供商配置架构
网关配置架构仅支持静态提供商定义:
# config/schema/gateway-config.json
{
"providers": {
"type": "object",
"properties": {
"name": { "type": "string" },
"endpoint": { "type": "string" }, // ← No credential field
"model": { "type": "string" }
}
}
// Missing: credential_mode, user_provided_key field
}
4. UI/UX 组件缺失
设置界面缺少捕获和验证用户提供的 API 密钥的必要组件:
# ui/components/settings/api-key-manager.tsx
// Current implementation - missing BYOK UI components
export function ApiKeyManager() {
return null; // Not implemented
}
🛠️ 逐步修复
BYOK 支持实施路线图
第一阶段:配置架构更新
步骤 1.1:更新网关配置架构以支持运行时凭证注入。
# config/schema/gateway-config.json
{
"$schema": "openclaw://schema/gateway/v2",
"credential_mode": "user_provided | bundled | hybrid",
"providers": {
"anthropic": {
"credential_mode": "user_provided",
"user_key_ref": "vault://anthropic-api-key", // Reference to secure storage
"fallback_key": "env:ANTHROPIC_API_KEY"
},
"openai": {
"credential_mode": "user_provided",
"user_key_ref": "keychain://openai-primary",
"fallback_key": "env:OPENAI_API_KEY"
}
}
}
步骤 1.2:创建凭证提供商接口:
# lib/credentials/provider.ts
export interface CredentialProvider {
getCredential(provider: AIProvider): Promise;
setCredential(provider: AIProvider, key: string): Promise;
deleteCredential(provider: AIProvider): Promise;
validateCredential(provider: AIProvider, key: string): Promise;
}
export type AIProvider = 'anthropic' | 'openai' | 'custom';
export enum CredentialMode {
USER_PROVIDED = 'user_provided',
BUNDLED = 'bundled',
HYBRID = 'hybrid'
}
第二阶段:安全存储实现
步骤 2.1:实现客户端安全存储(Keychain/Keystore):
# lib/credentials/storage/keychain.ts
import { KeychainStorage } from '@openclaw/keychain';
export class SecureKeyStorage implements CredentialProvider {
private storage: KeychainStorage;
async setCredential(provider: AIProvider, key: string): Promise {
const sanitizedKey = this.sanitizeKey(key);
await this.storage.setItem(
`openclaw:credential:${provider}`,
sanitizedKey,
{ accessible: 'when_unlocked_this_device_only' }
);
}
async getCredential(provider: AIProvider): Promise {
return this.storage.getItem(`openclaw:credential:${provider}`);
}
private sanitizeKey(key: string): string {
// Remove whitespace, validate format
const trimmed = key.trim();
if (!this.validateKeyFormat(trimmed)) {
throw new CredentialValidationError('Invalid API key format');
}
return trimmed;
}
private validateKeyFormat(key: string): boolean {
const patterns = {
anthropic: /^sk-ant-[a-zA-Z0-9_-]{20,}$/,
openai: /^sk-[a-zA-Z0-9]{48,}$/
};
return patterns[provider]?.test(key) ?? false;
}
}
步骤 2.2:实现网关端安全存储(环境变量注入):
# lib/credentials/storage/gateway-secrets.ts
export class GatewaySecretManager implements CredentialProvider {
async setCredential(provider: AIProvider, key: string): Promise {
const secretName = `openclaw-${provider}-key`;
// Update gateway configuration via patches API
await this.configPatchService.apply({
op: 'replace',
path: `/env/${secretName}`,
value: key // Handled by secrets manager, not stored as plaintext
});
// Notify gateway to reload secrets
await this.gatewayService.signalReload(ReloadTrigger.SECRET_UPDATE);
}
}
第三阶段:设置 UI 实现
步骤 3.1:创建 API 密钥管理组件:
# ui/components/settings/api-key-manager.tsx
import { CredentialProvider, AIProvider } from '@openclaw/credentials';
interface ApiKeyManagerProps {
onCredentialSaved?: (provider: AIProvider) => void;
}
export function ApiKeyManager({ onCredentialSaved }: ApiKeyManagerProps) {
const [activeProvider, setActiveProvider] = useState('openai');
const [apiKey, setApiKey] = useState('');
const [showKey, setShowKey] = useState(false);
const [validationStatus, setValidationStatus] = useState('idle');
const handleSave = async () => {
setValidationStatus('validating');
try {
const isValid = await credentialProvider.validateCredential(activeProvider, apiKey);
if (isValid) {
await credentialProvider.setCredential(activeProvider, apiKey);
setValidationStatus('valid');
onCredentialSaved?.(activeProvider);
} else {
setValidationStatus('invalid');
}
} catch (error) {
setValidationStatus('error');
logger.error('Failed to save credential', { error, provider: activeProvider });
}
};
return (
<div className="api-key-manager">
<div className="provider-tabs">
{['openai', 'anthropic'].map(p => (
<button
key={p}
className={activeProvider === p ? 'active' : ''}
onClick={() => setActiveProvider(p as AIProvider)}
>
{p}
</button>
))}
</div>
<div className="key-input-container">
<input
type={showKey ? 'text' : 'password'}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={`Enter ${activeProvider} API key`}
/>
<button onClick={() => setShowKey(!showKey)}>
{showKey ? 'Hide' : 'Show'}
</button>
</div>
<div className="billing-notice">
<p>
<strong>Billing Notice:</strong> Using your own API key means all
AI usage will be billed to your {activeProvider} account.
Ensure your account has sufficient quota.
</p>
</div>
<button onClick={handleSave} disabled={!apiKey}>
Save {activeProvider} Key
</button>
</div>
);
}
步骤 3.2:添加 BYOK 说明组件:
# ui/components/settings/byok-disclosure.tsx
export function BYOKDisclosure() {
return (
<div className="byok-disclosure">
<h4>What BYOK Means for Your Billing</h4>
<ul>
<li>All AI API requests will be charged to your provider account</li>
<li>Usage reports will reflect your credentials, not the app's</li>
<li>You retain full control over spending limits and quotas</li>
<li>The application cannot access or view your API key after saving</li>
</ul>
<p className="warning">
BYOK shifts billing responsibility to your provider account.
Monitor usage at your provider's dashboard.
</p>
</div>
);
}
第四阶段:网关运行时支持
步骤 4.1:更新网关以支持运行时凭证注入:
# lib/gateway/runtime/credential-loader.ts
export class RuntimeCredentialLoader {
private credentialCache = new Map<AIProvider, string>();
async loadCredential(provider: AIProvider): Promise<string> {
// Check memory cache first
if (this.credentialCache.has(provider)) {
return this.credentialCache.get(provider)!;
}
// Check environment variables (set by secrets manager)
const envKey = this.getEnvVarName(provider);
const envValue = process.env[envKey];
if (envValue) {
this.credentialCache.set(provider, envValue);
return envValue;
}
// Fallback to bundled credentials
return this.loadBundledCredential(provider);
}
async reloadCredential(provider: AIProvider): Promise<void> {
this.credentialCache.delete(provider);
return this.loadCredential(provider);
}
private getEnvVarName(provider: AIProvider): string {
const mapping = {
anthropic: 'OPENCLAW_ANTHROPIC_KEY',
openai: 'OPENCLAW_OPENAI_KEY'
};
return mapping[provider];
}
}
步骤 4.2:实现配置补丁端点:
# lib/gateway/api/patch-endpoint.ts
router.patch('/gateway/config', async (req, res) => {
const { operation, path, value } = req.body;
// Validate operation is allowed for BYOK
if (path.startsWith('/env/OPENCLAW_')) {
const secretName = path.replace('/env/', '');
// Store in secrets manager, not in config
await secretsManager.set(secretName, value);
// Signal gateway to reload
await gatewayRuntime.reloadSecret(secretName);
return res.json({ success: true, message: 'Secret updated' });
}
return res.status(403).json({
error: 'Patch operation not permitted for this path'
});
});
🧪 验证
BYOK 实现的测试流程
测试用例 1:API 密钥存储和检索
# Test: Store and retrieve API key
$ cd /path/to/openclaw
# Create test script
$ cat > test-byok-storage.ts << 'EOF'
import { SecureKeyStorage } from './lib/credentials/storage/keychain';
const storage = new SecureKeyStorage();
async function testStorage() {
// Test OpenAI key storage
await storage.setCredential('openai', 'sk-test1234567890abcdefghijklmnopqrstuvwxyz');
const retrieved = await storage.getCredential('openai');
console.log('OpenAI key stored:', retrieved === 'sk-test1234567890...' ? 'PASS' : 'FAIL');
// Test Anthropic key storage
await storage.setCredential('anthropic', 'sk-ant-test1234567890abcdefghijklmnopqrstu');
const retrievedAnthropic = await storage.getCredential('anthropic');
console.log('Anthropic key stored:', retrievedAnthropic ? 'PASS' : 'FAIL');
// Verify key format validation
try {
await storage.setCredential('openai', 'invalid-key');
console.log('Invalid key validation: FAIL - should have rejected');
} catch (e) {
console.log('Invalid key validation: PASS - correctly rejected');
}
}
testStorage().catch(console.error);
EOF
$ npx ts-node test-byok-storage.ts
OpenAI key stored: PASS
Anthropic key stored: PASS
Invalid key validation: PASS
测试用例 2:网关配置补丁
# Test: Gateway configuration patch for secrets
$ curl -X PATCH http://localhost:3000/gateway/config \
-H "Content-Type: application/json" \
-d '{"op":"replace","path":"/env/OPENCLAW_OPENAI_KEY","value":"sk-test-key"}'
# Expected response
{
"success": true,
"message": "Secret updated",
"secretName": "openclaw-openai-key"
}
# Verify gateway reloaded the secret
$ curl http://localhost:3000/gateway/status | jq '.secrets'
{
"openai_key": "loaded",
"anthropic_key": "bundled"
}
测试用例 3:UI 组件验证
# Test: Settings UI BYOK components
$ cd ui && npm run test -- --grep "ApiKeyManager"
# Expected output
PASS ApiKeyManager renders provider tabs
PASS ApiKeyManager handles key input
PASS ApiKeyManager shows billing disclosure
PASS ApiKeyManager validates on save
$ npm run test -- --grep "BYOKDisclosure"
# Expected output
PASS BYOKDisclosure displays billing notice
PASS BYOKDisclosure renders warning message
测试用例 4:端到端 BYOK 流程
# Complete integration test
$ cat > test-byok-e2e.ts << 'EOF'
import { createTestingEnvironment } from '@openclaw/test-utils';
async function testBYOKFlow() {
const env = await createTestingEnvironment();
// Step 1: Configure BYOK via UI
await env.ui.navigateToSettings();
await env.ui.click('[data-testid="api-key-manager"]');
await env.ui.selectProvider('openai');
await env.ui.enterApiKey('sk-test-e2e-key-1234567890abcdefghijklmnop');
await env.ui.click('Save');
// Step 2: Verify key stored securely
const stored = await env.credentialProvider.getCredential('openai');
console.assert(stored === 'sk-test-e2e-key-...', 'Key stored correctly');
// Step 3: Deploy to cloud gateway
await env.cli.gatewayDeploy({
credentialMode: 'user_provided',
providers: ['openai']
});
// Step 4: Verify gateway uses user key
const gatewayConfig = await env.gateway.getConfig();
console.assert(
gatewayConfig.providers.openai.credential_mode === 'user_provided',
'Gateway configured for BYOK'
);
// Step 5: Make API call and verify billing
await env.gateway.sendRequest({ model: 'gpt-4' });
const usage = await env.billing.getUsage({ provider: 'openai' });
console.assert(usage.account === 'user-provided', 'Usage billed to user');
console.log('BYOK E2E Test: PASS');
}
testBYOKFlow().catch(e => {
console.error('BYOK E2E Test: FAIL', e);
process.exit(1);
});
EOF
$ npx ts-node test-byok-e2e.ts
BYOK E2E Test: PASS
⚠️ 常见陷阱
边缘情况和实施陷阱
- 网关重启前的密钥轮换:当用户轮换其 API 密钥时,确保网关能够在不需要完全重新部署的情况下获取更改。实现具有适当锁定机制的基于信号的重新加载机制。
- 密钥格式验证差异:API 密钥格式在不同提供商之间存在差异,且可能会更改。创建一个可以在不发布新版本应用程序的情况下进行更新的验证服务。
- 容器中的环境变量注入:某些容器编排平台(如 AWS ECS、GKE)处理密钥注入的方式不同。在所有支持的平台上测试网关。
- 静态加密的凭证存储加密:存储在 Keychain/Keystore 中的密钥必须加密。验证加密是否符合合规文档中指定的安全要求。
- 日志中的内存暴露:确保 API 密钥永远不会出现在应用程序日志中。实现一个编辑层,在任何日志输出中屏蔽密钥。
- 多账户场景:同一提供商拥有多个账户的用户(例如开发和生产 OpenAI 账户)需要清晰的 UI 来管理两个密钥而不会混淆。
- 回退行为歧义:当用户提供的密钥失败(过期、撤销)时,网关应该清楚地发出失败信号,而不是静默回退到捆绑凭证。
- 现有部署的迁移路径:拥有现有云网关的用户需要一个迁移路径来启用 BYOK,同时不会丢失当前配置。
安全注意事项
# Pitfall: Logging sensitive data
// ❌ WRONG
logger.info('User configured API key', { key: apiKey });
// ✅ CORRECT
logger.info('User configured API key', {
keyPrefix: apiKey.substring(0, 8) + '...',
keyLength: apiKey.length
});
# Pitfall: Storing plaintext in config files
// ❌ WRONG - config persisted with actual key
{
"openai_key": "sk-actualkeyvaluehere"
}
// ✅ CORRECT - reference to secrets manager
{
"openai_key_ref": "keychain://openai-primary"
}
🔗 相关错误
上下文相关问题及历史背景
| 参考编号 | 描述 | 关联关系 |
|---|---|---|
| #221 | 通过设置向导实现的本地网关 BYOK 支持 | 此功能的先前实现。本地网关已经具备所需的 UI 和存储模式;此问题将其适配到云端部署。 |
| #189 | API 密钥安全存储规范 | 定义了 BYOK 必须利用的安全存储架构(Keychain/Keystore),用于客户端凭证管理。 |
| #215 | 网关配置架构 v2 | 更新配置架构以支持 BYOK 所需的运行时凭证注入模式。 |
| #98 | 多提供商网关支持 | 处理多个 AI 提供商的基础工作;BYOK 在此基础上实现按提供商密钥管理。 |
| #178 | 托管服务的密钥注入 | 提供 BYOK 用于网关端安全存储的密钥管理基础设施。 |
文档中的类似模式
docs/cloud-gateways/security.md— 云端部署的安全架构(包括 BYOK 要求)docs/providers/anthropic-setup.md— 提供商特定的密钥配置docs/providers/openai-setup.md— 提供商特定的密钥配置docs/settings/byok-settings-reference.md— BYOK 组件的设置 UI 规范