kadi-deploy 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +6 -0
- package/.prettierrc +6 -0
- package/README.md +589 -0
- package/agent.json +23 -0
- package/index.js +11 -0
- package/package.json +42 -0
- package/quick-command.txt +92 -0
- package/scripts/preflight.js +458 -0
- package/scripts/preflight.sh +300 -0
- package/src/cli/bid-selector.ts +222 -0
- package/src/cli/colors.ts +216 -0
- package/src/cli/index.ts +11 -0
- package/src/cli/prompts.ts +190 -0
- package/src/cli/spinners.ts +165 -0
- package/src/commands/deploy-local.ts +475 -0
- package/src/commands/deploy.ts +1342 -0
- package/src/commands/down.ts +679 -0
- package/src/commands/index.ts +10 -0
- package/src/commands/lock.ts +571 -0
- package/src/config/agent-loader.ts +177 -0
- package/src/config/index.ts +9 -0
- package/src/display/deployment-info.ts +220 -0
- package/src/display/pricing.ts +137 -0
- package/src/display/resources.ts +234 -0
- package/src/enhanced-registry-manager.ts +892 -0
- package/src/index.ts +307 -0
- package/src/infrastructure/registry.ts +269 -0
- package/src/schemas/profiles.ts +529 -0
- package/src/secrets/broker-urls.ts +109 -0
- package/src/secrets/handshake.ts +407 -0
- package/src/secrets/index.ts +69 -0
- package/src/secrets/inject-env.ts +171 -0
- package/src/secrets/nonce.ts +31 -0
- package/src/secrets/normalize.ts +204 -0
- package/src/secrets/prepare.ts +152 -0
- package/src/secrets/validate.ts +243 -0
- package/src/secrets/vault.ts +80 -0
- package/src/types/akash.ts +116 -0
- package/src/types/container-registry-ability.d.ts +158 -0
- package/src/types/external.ts +49 -0
- package/src/types.ts +211 -0
- package/src/utils/akt-price.ts +74 -0
- package/tests/agent-loader.test.ts +239 -0
- package/tests/autonomous.test.ts +244 -0
- package/tests/down.test.ts +1143 -0
- package/tests/lock.test.ts +1148 -0
- package/tests/nonce.test.ts +34 -0
- package/tests/normalize.test.ts +270 -0
- package/tests/secrets-schema.test.ts +301 -0
- package/tests/types.test.ts +198 -0
- package/tsconfig.json +18 -0
- package/vitest.config.ts +9 -0
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret Handshake for Broker Delivery Mode
|
|
3
|
+
*
|
|
4
|
+
* After deployment, the deployer stays connected to the broker and waits
|
|
5
|
+
* for the deployed agent to request secrets. This module handles:
|
|
6
|
+
*
|
|
7
|
+
* 1. Connecting to broker
|
|
8
|
+
* 2. Subscribing to secrets.request channel
|
|
9
|
+
* 3. Verifying the nonce matches what was injected
|
|
10
|
+
* 4. Prompting user for approval (or auto-approving)
|
|
11
|
+
* 5. Encrypting secrets with agent's public key (E2E encryption)
|
|
12
|
+
* 6. Sharing encrypted secrets via broker pub/sub
|
|
13
|
+
*
|
|
14
|
+
* ## Encryption
|
|
15
|
+
*
|
|
16
|
+
* Secrets are encrypted using sealed boxes (X25519 + XSalsa20-Poly1305).
|
|
17
|
+
* The agent's Ed25519 public key is converted to X25519 for encryption.
|
|
18
|
+
* Only the agent can decrypt using its private key.
|
|
19
|
+
*
|
|
20
|
+
* @module secrets/handshake
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { KadiClient, convertToEncryptionKey, type BrokerEvent } from '@kadi.build/core';
|
|
24
|
+
import nacl from 'tweetnacl';
|
|
25
|
+
// @ts-expect-error - tweetnacl-sealedbox-js doesn't have type definitions
|
|
26
|
+
import sealedbox from 'tweetnacl-sealedbox-js';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Secret request from deployed agent
|
|
30
|
+
*/
|
|
31
|
+
export interface SecretRequest {
|
|
32
|
+
/** Nonce injected during deployment (for verification) */
|
|
33
|
+
nonce: string;
|
|
34
|
+
/** Agent's unique ID */
|
|
35
|
+
agentId: string;
|
|
36
|
+
/** Agent's public key (for future E2E encryption) */
|
|
37
|
+
publicKey?: string;
|
|
38
|
+
/** List of required secrets */
|
|
39
|
+
required: string[];
|
|
40
|
+
/** List of optional secrets */
|
|
41
|
+
optional?: string[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Options for waiting for secret requests
|
|
46
|
+
*/
|
|
47
|
+
export interface WaitForSecretsOptions {
|
|
48
|
+
/** Broker URL to connect to */
|
|
49
|
+
brokerUrl: string;
|
|
50
|
+
/** Expected nonce (must match request) */
|
|
51
|
+
expectedNonce: string;
|
|
52
|
+
/** Timeout in milliseconds (default: 5 minutes) */
|
|
53
|
+
timeout?: number;
|
|
54
|
+
/** Logger for status updates */
|
|
55
|
+
logger?: {
|
|
56
|
+
log: (msg: string) => void;
|
|
57
|
+
error: (msg: string) => void;
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Result of waiting for secrets
|
|
63
|
+
*/
|
|
64
|
+
export interface WaitForSecretsResult {
|
|
65
|
+
success: boolean;
|
|
66
|
+
/** The verified request (if successful) */
|
|
67
|
+
request?: SecretRequest;
|
|
68
|
+
/** Error message (if failed) */
|
|
69
|
+
error?: string;
|
|
70
|
+
/** Whether timeout occurred */
|
|
71
|
+
timedOut?: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Options for sharing secrets
|
|
76
|
+
*/
|
|
77
|
+
export interface ShareSecretsOptions {
|
|
78
|
+
/** Broker URL */
|
|
79
|
+
brokerUrl: string;
|
|
80
|
+
/** Agent ID to share with */
|
|
81
|
+
agentId: string;
|
|
82
|
+
/** Agent's Ed25519 public key (base64 SPKI DER format) for encryption */
|
|
83
|
+
agentPublicKey: string;
|
|
84
|
+
/** Secrets to share (key-value pairs) */
|
|
85
|
+
secrets: Record<string, string>;
|
|
86
|
+
/** Logger for status updates */
|
|
87
|
+
logger?: {
|
|
88
|
+
log: (msg: string) => void;
|
|
89
|
+
error: (msg: string) => void;
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Wait for deployed agent to request secrets
|
|
95
|
+
*
|
|
96
|
+
* Connects to broker, subscribes to secrets.request, and waits for
|
|
97
|
+
* a request with matching nonce.
|
|
98
|
+
*
|
|
99
|
+
* @param options - Configuration options
|
|
100
|
+
* @returns Result with verified request or error
|
|
101
|
+
*/
|
|
102
|
+
export async function waitForSecretRequest(
|
|
103
|
+
options: WaitForSecretsOptions
|
|
104
|
+
): Promise<WaitForSecretsResult> {
|
|
105
|
+
const {
|
|
106
|
+
brokerUrl,
|
|
107
|
+
expectedNonce,
|
|
108
|
+
timeout = 5 * 60 * 1000, // 5 minutes default
|
|
109
|
+
logger = { log: console.log, error: console.error },
|
|
110
|
+
} = options;
|
|
111
|
+
|
|
112
|
+
// Create a minimal KadiClient for broker connection
|
|
113
|
+
const client = new KadiClient({
|
|
114
|
+
name: 'kadi-deploy-handshake',
|
|
115
|
+
version: '1.0.0',
|
|
116
|
+
brokers: {
|
|
117
|
+
default: { url: brokerUrl },
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
return new Promise(async (resolve) => {
|
|
122
|
+
let resolved = false;
|
|
123
|
+
let timeoutHandle: NodeJS.Timeout | undefined;
|
|
124
|
+
|
|
125
|
+
// Cleanup function
|
|
126
|
+
const cleanup = async () => {
|
|
127
|
+
if (timeoutHandle) {
|
|
128
|
+
clearTimeout(timeoutHandle);
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
await client.disconnect();
|
|
132
|
+
} catch {
|
|
133
|
+
// Ignore disconnect errors
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// Set timeout
|
|
138
|
+
timeoutHandle = setTimeout(async () => {
|
|
139
|
+
if (!resolved) {
|
|
140
|
+
resolved = true;
|
|
141
|
+
await cleanup();
|
|
142
|
+
resolve({
|
|
143
|
+
success: false,
|
|
144
|
+
timedOut: true,
|
|
145
|
+
error: `Timeout waiting for agent to request secrets (${timeout / 1000}s)`,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}, timeout);
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
// Connect to broker
|
|
152
|
+
logger.log('Connecting to broker...');
|
|
153
|
+
await client.connect();
|
|
154
|
+
logger.log('Connected. Waiting for agent to request secrets...');
|
|
155
|
+
|
|
156
|
+
// Subscribe to secret requests
|
|
157
|
+
await client.subscribe('secrets.request', async (event: BrokerEvent) => {
|
|
158
|
+
if (resolved) return;
|
|
159
|
+
|
|
160
|
+
// The actual request payload is in event.data
|
|
161
|
+
const request = event.data as SecretRequest;
|
|
162
|
+
|
|
163
|
+
// Verify nonce
|
|
164
|
+
if (request.nonce !== expectedNonce) {
|
|
165
|
+
logger.log(`Received request with invalid nonce (ignoring)`);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Valid request - capture and return it
|
|
170
|
+
// Caller will handle approval and sharing
|
|
171
|
+
resolved = true;
|
|
172
|
+
await cleanup();
|
|
173
|
+
resolve({
|
|
174
|
+
success: true,
|
|
175
|
+
request,
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
} catch (err) {
|
|
179
|
+
if (!resolved) {
|
|
180
|
+
resolved = true;
|
|
181
|
+
await cleanup();
|
|
182
|
+
resolve({
|
|
183
|
+
success: false,
|
|
184
|
+
error: `Failed to connect to broker: ${(err as Error).message}`,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Share secrets with deployed agent via broker
|
|
193
|
+
*
|
|
194
|
+
* Encrypts secrets using the agent's public key (sealed box) and publishes
|
|
195
|
+
* them to the agent's approval channel. Only the agent can decrypt.
|
|
196
|
+
*
|
|
197
|
+
* ## Encryption Flow
|
|
198
|
+
*
|
|
199
|
+
* 1. Convert agent's Ed25519 public key to X25519 (encryption key)
|
|
200
|
+
* 2. Serialize secrets as JSON
|
|
201
|
+
* 3. Encrypt with sealed box (X25519 + XSalsa20-Poly1305)
|
|
202
|
+
* 4. Send base64-encoded encrypted blob via broker
|
|
203
|
+
*
|
|
204
|
+
* @param options - Configuration options
|
|
205
|
+
*/
|
|
206
|
+
export async function shareSecrets(options: ShareSecretsOptions): Promise<void> {
|
|
207
|
+
const {
|
|
208
|
+
brokerUrl,
|
|
209
|
+
agentId,
|
|
210
|
+
agentPublicKey,
|
|
211
|
+
secrets,
|
|
212
|
+
logger = { log: console.log, error: console.error },
|
|
213
|
+
} = options;
|
|
214
|
+
|
|
215
|
+
// Step 1: Convert agent's Ed25519 public key to X25519 encryption key
|
|
216
|
+
// This uses kadi-core's convertToEncryptionKey which handles the
|
|
217
|
+
// Ed25519 -> X25519 conversion (both use Curve25519 underneath)
|
|
218
|
+
const encryptionKey = convertToEncryptionKey(agentPublicKey);
|
|
219
|
+
|
|
220
|
+
// Step 2: Serialize secrets as JSON
|
|
221
|
+
const secretsJson = JSON.stringify(secrets);
|
|
222
|
+
const secretsBytes = new TextEncoder().encode(secretsJson);
|
|
223
|
+
|
|
224
|
+
// Step 3: Encrypt with sealed box
|
|
225
|
+
// Sealed box = anonymous encryption where only recipient can decrypt
|
|
226
|
+
// Uses ephemeral keypair internally, so sender cannot decrypt their own message
|
|
227
|
+
const encrypted = sealedbox.seal(secretsBytes, encryptionKey);
|
|
228
|
+
|
|
229
|
+
// Step 4: Encode as base64 for transport
|
|
230
|
+
const encryptedBase64 = Buffer.from(encrypted).toString('base64');
|
|
231
|
+
|
|
232
|
+
// Create a minimal KadiClient for broker connection
|
|
233
|
+
const client = new KadiClient({
|
|
234
|
+
name: 'kadi-deploy-share',
|
|
235
|
+
version: '1.0.0',
|
|
236
|
+
brokers: {
|
|
237
|
+
default: { url: brokerUrl },
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
// Connect to broker
|
|
243
|
+
await client.connect();
|
|
244
|
+
|
|
245
|
+
// Publish encrypted secrets to agent's response channel
|
|
246
|
+
// Channel format: secrets.response.{agentId}
|
|
247
|
+
const channel = `secrets.response.${agentId}`;
|
|
248
|
+
|
|
249
|
+
await client.publish(channel, {
|
|
250
|
+
status: 'approved',
|
|
251
|
+
encrypted: encryptedBase64,
|
|
252
|
+
sharedAt: new Date().toISOString(),
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
logger.log('Encrypted secrets shared successfully');
|
|
256
|
+
} finally {
|
|
257
|
+
try {
|
|
258
|
+
await client.disconnect();
|
|
259
|
+
} catch {
|
|
260
|
+
// Ignore disconnect errors
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Options for sending rejection
|
|
267
|
+
*/
|
|
268
|
+
export interface SendRejectionOptions {
|
|
269
|
+
/** Broker URL */
|
|
270
|
+
brokerUrl: string;
|
|
271
|
+
/** Agent ID to notify */
|
|
272
|
+
agentId: string;
|
|
273
|
+
/** Reason for rejection */
|
|
274
|
+
reason: string;
|
|
275
|
+
/** Logger for status updates */
|
|
276
|
+
logger?: {
|
|
277
|
+
log: (msg: string) => void;
|
|
278
|
+
error: (msg: string) => void;
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Send rejection to deployed agent via broker
|
|
284
|
+
*
|
|
285
|
+
* Notifies the agent that secrets will not be shared, allowing it to
|
|
286
|
+
* fail immediately instead of waiting for timeout.
|
|
287
|
+
*
|
|
288
|
+
* @param options - Configuration options
|
|
289
|
+
*/
|
|
290
|
+
export async function sendRejection(options: SendRejectionOptions): Promise<void> {
|
|
291
|
+
const {
|
|
292
|
+
brokerUrl,
|
|
293
|
+
agentId,
|
|
294
|
+
reason,
|
|
295
|
+
logger = { log: console.log, error: console.error },
|
|
296
|
+
} = options;
|
|
297
|
+
|
|
298
|
+
// Create a minimal KadiClient for broker connection
|
|
299
|
+
const client = new KadiClient({
|
|
300
|
+
name: 'kadi-deploy-reject',
|
|
301
|
+
version: '1.0.0',
|
|
302
|
+
brokers: {
|
|
303
|
+
default: { url: brokerUrl },
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
try {
|
|
308
|
+
// Connect to broker
|
|
309
|
+
await client.connect();
|
|
310
|
+
|
|
311
|
+
// Publish rejection to agent's response channel
|
|
312
|
+
const channel = `secrets.response.${agentId}`;
|
|
313
|
+
|
|
314
|
+
await client.publish(channel, {
|
|
315
|
+
status: 'rejected',
|
|
316
|
+
reason,
|
|
317
|
+
rejectedAt: new Date().toISOString(),
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
logger.log('Rejection sent to agent');
|
|
321
|
+
} finally {
|
|
322
|
+
try {
|
|
323
|
+
await client.disconnect();
|
|
324
|
+
} catch {
|
|
325
|
+
// Ignore disconnect errors
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Complete secret handshake flow
|
|
332
|
+
*
|
|
333
|
+
* Convenience function that combines waiting and sharing.
|
|
334
|
+
*
|
|
335
|
+
* @param options - Configuration options
|
|
336
|
+
* @param getSecrets - Function to retrieve secret values by name
|
|
337
|
+
*/
|
|
338
|
+
export async function performSecretHandshake(
|
|
339
|
+
options: WaitForSecretsOptions & {
|
|
340
|
+
/** Function to get secret value by name */
|
|
341
|
+
getSecret: (name: string) => Promise<string | null>;
|
|
342
|
+
}
|
|
343
|
+
): Promise<{ success: boolean; error?: string }> {
|
|
344
|
+
const { getSecret, ...waitOptions } = options;
|
|
345
|
+
const logger = options.logger || { log: console.log, error: console.error };
|
|
346
|
+
|
|
347
|
+
// Step 1: Wait for agent to request secrets
|
|
348
|
+
const waitResult = await waitForSecretRequest(waitOptions);
|
|
349
|
+
|
|
350
|
+
if (!waitResult.success || !waitResult.request) {
|
|
351
|
+
return {
|
|
352
|
+
success: false,
|
|
353
|
+
error: waitResult.error,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const request = waitResult.request;
|
|
358
|
+
|
|
359
|
+
// Step 2: Retrieve secrets from local vault
|
|
360
|
+
const secrets: Record<string, string> = {};
|
|
361
|
+
const missingSecrets: string[] = [];
|
|
362
|
+
|
|
363
|
+
// Get required secrets
|
|
364
|
+
for (const name of request.required) {
|
|
365
|
+
const value = await getSecret(name);
|
|
366
|
+
if (value !== null) {
|
|
367
|
+
secrets[name] = value;
|
|
368
|
+
} else {
|
|
369
|
+
missingSecrets.push(name);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Get optional secrets (no error if missing)
|
|
374
|
+
for (const name of request.optional || []) {
|
|
375
|
+
const value = await getSecret(name);
|
|
376
|
+
if (value !== null) {
|
|
377
|
+
secrets[name] = value;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Check for missing required secrets
|
|
382
|
+
if (missingSecrets.length > 0) {
|
|
383
|
+
return {
|
|
384
|
+
success: false,
|
|
385
|
+
error: `Missing required secrets: ${missingSecrets.join(', ')}`,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Step 3: Verify agent provided public key for encryption
|
|
390
|
+
if (!request.publicKey) {
|
|
391
|
+
return {
|
|
392
|
+
success: false,
|
|
393
|
+
error: 'Agent did not provide public key for encryption',
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Step 4: Share encrypted secrets with agent
|
|
398
|
+
await shareSecrets({
|
|
399
|
+
brokerUrl: options.brokerUrl,
|
|
400
|
+
agentId: request.agentId,
|
|
401
|
+
agentPublicKey: request.publicKey,
|
|
402
|
+
secrets,
|
|
403
|
+
logger,
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
return { success: true };
|
|
407
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secrets Module
|
|
3
|
+
*
|
|
4
|
+
* Handles secrets validation and injection for secure secret
|
|
5
|
+
* sharing with deployed agents.
|
|
6
|
+
*
|
|
7
|
+
* @module secrets
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// Validation (Phase 2.2)
|
|
11
|
+
export {
|
|
12
|
+
validateSecrets,
|
|
13
|
+
validateSecretsOrFail,
|
|
14
|
+
formatMissingSecretsError,
|
|
15
|
+
formatMissingOptionalWarning,
|
|
16
|
+
type SecretsValidationResult,
|
|
17
|
+
} from './validate.js';
|
|
18
|
+
|
|
19
|
+
// Nonce generation (Phase 2.3)
|
|
20
|
+
export { generateNonce } from './nonce.js';
|
|
21
|
+
|
|
22
|
+
// Broker URL resolution (Phase 2.3)
|
|
23
|
+
export {
|
|
24
|
+
resolveBrokerUrls,
|
|
25
|
+
formatMissingBrokerUrlsError,
|
|
26
|
+
type BrokerUrlsResult,
|
|
27
|
+
} from './broker-urls.js';
|
|
28
|
+
|
|
29
|
+
// Secrets injection
|
|
30
|
+
export {
|
|
31
|
+
injectSecretsEnv,
|
|
32
|
+
type SecretsInjectionEnv,
|
|
33
|
+
} from './inject-env.js';
|
|
34
|
+
|
|
35
|
+
// Secret handshake (Phase 2.4)
|
|
36
|
+
export {
|
|
37
|
+
waitForSecretRequest,
|
|
38
|
+
shareSecrets,
|
|
39
|
+
sendRejection,
|
|
40
|
+
performSecretHandshake,
|
|
41
|
+
type SecretRequest,
|
|
42
|
+
type WaitForSecretsOptions,
|
|
43
|
+
type WaitForSecretsResult,
|
|
44
|
+
type ShareSecretsOptions,
|
|
45
|
+
type SendRejectionOptions,
|
|
46
|
+
} from './handshake.js';
|
|
47
|
+
|
|
48
|
+
// Local vault helpers
|
|
49
|
+
export { readSecretFromCli, readSecretsFromCli } from './vault.js';
|
|
50
|
+
|
|
51
|
+
// Secrets preparation (shared logic for deploy commands)
|
|
52
|
+
export {
|
|
53
|
+
prepareSecretsForDeployment,
|
|
54
|
+
type PrepareSecretsOptions,
|
|
55
|
+
type PrepareSecretsResult,
|
|
56
|
+
} from './prepare.js';
|
|
57
|
+
|
|
58
|
+
// Secrets normalization (multi-vault support)
|
|
59
|
+
export {
|
|
60
|
+
normalizeSecrets,
|
|
61
|
+
hasAnySecrets,
|
|
62
|
+
allRequiredKeys,
|
|
63
|
+
allOptionalKeys,
|
|
64
|
+
allSecretKeys,
|
|
65
|
+
buildVaultSourcesEnv,
|
|
66
|
+
type NormalizedVaultSource,
|
|
67
|
+
type NormalizedSecrets,
|
|
68
|
+
type RawSecretsConfig,
|
|
69
|
+
} from './normalize.js';
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secrets Injection for Deployment
|
|
3
|
+
*
|
|
4
|
+
* Injects KADI environment variables into all services in a deploy profile.
|
|
5
|
+
* Supports two delivery modes:
|
|
6
|
+
*
|
|
7
|
+
* - "env" (default): Secrets injected as plain environment variables.
|
|
8
|
+
* Works with any container. Secrets are visible in SDL/compose.
|
|
9
|
+
*
|
|
10
|
+
* - "broker": E2E encrypted handshake via broker. Requires KADI CLI or SDK
|
|
11
|
+
* in the container. Secrets never appear in SDL/compose.
|
|
12
|
+
*
|
|
13
|
+
* Injected variables (both modes):
|
|
14
|
+
* - KADI_DEPLOY_NONCE: Cryptographic nonce to verify request authenticity
|
|
15
|
+
* - KADI_REQUIRED_SECRETS: Comma-separated list of secrets the agent needs
|
|
16
|
+
* - KADI_SECRET_DELIVERY: Delivery mode ("env" or "broker")
|
|
17
|
+
* - KADI_VAULT_SOURCES: JSON array mapping vaults to keys (only for multi-vault)
|
|
18
|
+
*
|
|
19
|
+
* Additional variables for "broker" mode:
|
|
20
|
+
* - KADI_BROKER_URLS: Comma-separated broker URLs
|
|
21
|
+
* - KADI_RENDEZVOUS_BROKER: The broker URL where secrets will be delivered
|
|
22
|
+
*
|
|
23
|
+
* Additional variables for "env" mode:
|
|
24
|
+
* - The actual secret values (e.g., API_KEY=xxx, DB_URL=xxx)
|
|
25
|
+
*
|
|
26
|
+
* @module secrets/inject-env
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { Profile } from '../schemas/profiles.js';
|
|
30
|
+
import { buildVaultSourcesEnv, type NormalizedVaultSource } from './normalize.js';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Secret delivery mode
|
|
34
|
+
*/
|
|
35
|
+
export type DeliveryMode = 'env' | 'broker';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Environment variables to inject into deployed containers
|
|
39
|
+
*/
|
|
40
|
+
export interface SecretsInjectionEnv {
|
|
41
|
+
/** Comma-separated broker URLs (required for broker mode) */
|
|
42
|
+
brokerUrls?: string;
|
|
43
|
+
/** Cryptographic nonce for verification */
|
|
44
|
+
nonce: string;
|
|
45
|
+
/** Comma-separated required secret names */
|
|
46
|
+
requiredSecrets: string[];
|
|
47
|
+
/** Comma-separated optional secret names */
|
|
48
|
+
optionalSecrets: string[];
|
|
49
|
+
/** Delivery mode */
|
|
50
|
+
delivery: DeliveryMode;
|
|
51
|
+
/** Actual secret values (only for env mode) */
|
|
52
|
+
secrets?: Record<string, string>;
|
|
53
|
+
/** Vault sources for multi-vault routing (optional; omit for legacy single-vault) */
|
|
54
|
+
vaultSources?: NormalizedVaultSource[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Normalize service env to array format
|
|
59
|
+
*
|
|
60
|
+
* The profile schema allows env as either:
|
|
61
|
+
* - Array of strings: ["KEY=value", "KEY2=value2"]
|
|
62
|
+
* - Object: { KEY: "value", KEY2: "value2" }
|
|
63
|
+
*
|
|
64
|
+
* This normalizes to array format for consistent handling.
|
|
65
|
+
*
|
|
66
|
+
* @param env - Service env in either format
|
|
67
|
+
* @returns Array of "KEY=value" strings
|
|
68
|
+
*/
|
|
69
|
+
function normalizeEnvToArray(env: string[] | Record<string, string> | undefined): string[] {
|
|
70
|
+
if (!env) {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (Array.isArray(env)) {
|
|
75
|
+
return [...env];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Convert object to array
|
|
79
|
+
return Object.entries(env).map(([key, value]) => `${key}=${value}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Inject environment variables for secret delivery into a deploy profile
|
|
84
|
+
*
|
|
85
|
+
* Mutates the profile in place, adding KADI_* env vars to all services.
|
|
86
|
+
* Works with both Akash and Local profiles.
|
|
87
|
+
*
|
|
88
|
+
* @param profile - Deployment profile (will be mutated)
|
|
89
|
+
* @param env - Environment variables to inject
|
|
90
|
+
*
|
|
91
|
+
* @example
|
|
92
|
+
* ```typescript
|
|
93
|
+
* // Broker mode (E2E encrypted)
|
|
94
|
+
* injectSecretsEnv(profile, {
|
|
95
|
+
* brokerUrls: 'ws://broker:8080/kadi',
|
|
96
|
+
* nonce: generateNonce(),
|
|
97
|
+
* requiredSecrets: ['API_KEY', 'DB_URL'],
|
|
98
|
+
* optionalSecrets: [],
|
|
99
|
+
* delivery: 'broker',
|
|
100
|
+
* });
|
|
101
|
+
*
|
|
102
|
+
* // Env mode (plain env vars)
|
|
103
|
+
* injectSecretsEnv(profile, {
|
|
104
|
+
* nonce: generateNonce(),
|
|
105
|
+
* requiredSecrets: ['API_KEY', 'DB_URL'],
|
|
106
|
+
* optionalSecrets: [],
|
|
107
|
+
* delivery: 'env',
|
|
108
|
+
* secrets: { API_KEY: 'xxx', DB_URL: 'postgres://...' },
|
|
109
|
+
* });
|
|
110
|
+
*
|
|
111
|
+
* // Multi-vault broker mode
|
|
112
|
+
* injectSecretsEnv(profile, {
|
|
113
|
+
* brokerUrls: 'ws://broker:8080/kadi',
|
|
114
|
+
* nonce: generateNonce(),
|
|
115
|
+
* requiredSecrets: ['API_KEY', 'TUNNEL_TOKEN'],
|
|
116
|
+
* optionalSecrets: [],
|
|
117
|
+
* delivery: 'broker',
|
|
118
|
+
* vaultSources: [
|
|
119
|
+
* { vault: 'app', required: ['API_KEY'], optional: [] },
|
|
120
|
+
* { vault: 'infra', required: ['TUNNEL_TOKEN'], optional: [] },
|
|
121
|
+
* ],
|
|
122
|
+
* });
|
|
123
|
+
* ```
|
|
124
|
+
*/
|
|
125
|
+
export function injectSecretsEnv(
|
|
126
|
+
profile: Profile,
|
|
127
|
+
env: SecretsInjectionEnv
|
|
128
|
+
): void {
|
|
129
|
+
const kadiEnvVars: string[] = [
|
|
130
|
+
`KADI_DEPLOY_NONCE=${env.nonce}`,
|
|
131
|
+
`KADI_SECRET_DELIVERY=${env.delivery}`,
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
// Add secrets list
|
|
135
|
+
const allSecrets = [...env.requiredSecrets, ...env.optionalSecrets];
|
|
136
|
+
if (allSecrets.length > 0) {
|
|
137
|
+
kadiEnvVars.push(`KADI_REQUIRED_SECRETS=${allSecrets.join(',')}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Multi-vault: inject KADI_VAULT_SOURCES so the receiver knows which vault each key belongs to
|
|
141
|
+
if (env.vaultSources) {
|
|
142
|
+
const vaultSourcesJson = buildVaultSourcesEnv(env.vaultSources);
|
|
143
|
+
if (vaultSourcesJson) {
|
|
144
|
+
kadiEnvVars.push(`KADI_VAULT_SOURCES=${vaultSourcesJson}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Mode-specific env vars
|
|
149
|
+
if (env.delivery === 'broker') {
|
|
150
|
+
if (!env.brokerUrls) {
|
|
151
|
+
throw new Error('brokerUrls is required for broker delivery mode');
|
|
152
|
+
}
|
|
153
|
+
const rendezvousBroker = env.brokerUrls.split(',')[0]!;
|
|
154
|
+
kadiEnvVars.push(`KADI_BROKER_URLS=${env.brokerUrls}`);
|
|
155
|
+
kadiEnvVars.push(`KADI_RENDEZVOUS_BROKER=${rendezvousBroker}`);
|
|
156
|
+
} else if (env.delivery === 'env' && env.secrets) {
|
|
157
|
+
// Inject actual secret values
|
|
158
|
+
for (const [key, value] of Object.entries(env.secrets)) {
|
|
159
|
+
kadiEnvVars.push(`${key}=${value}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Inject into all services
|
|
164
|
+
for (const serviceName of Object.keys(profile.services)) {
|
|
165
|
+
const service = profile.services[serviceName];
|
|
166
|
+
|
|
167
|
+
// Normalize existing env to array and add KADI vars
|
|
168
|
+
const existingEnv = normalizeEnvToArray(service.env);
|
|
169
|
+
service.env = [...existingEnv, ...kadiEnvVars];
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nonce Generation for Secrets Injection
|
|
3
|
+
*
|
|
4
|
+
* Generates cryptographically secure nonces used to verify that
|
|
5
|
+
* secret requests come from the deployed agent (not an impersonator).
|
|
6
|
+
*
|
|
7
|
+
* @module secrets/nonce
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { randomBytes } from 'node:crypto';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Generate a cryptographically secure nonce
|
|
14
|
+
*
|
|
15
|
+
* The nonce is:
|
|
16
|
+
* 1. Injected into the container via KADI_DEPLOY_NONCE env var
|
|
17
|
+
* 2. Included in the agent's secret request message
|
|
18
|
+
* 3. Verified by the deployer before sharing secrets
|
|
19
|
+
*
|
|
20
|
+
* @param length - Number of random bytes (default: 32 = 256 bits)
|
|
21
|
+
* @returns Hex-encoded nonce string
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```typescript
|
|
25
|
+
* const nonce = generateNonce();
|
|
26
|
+
* // nonce = "a1b2c3d4e5f6..." (64 hex characters)
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
export function generateNonce(length: number = 32): string {
|
|
30
|
+
return randomBytes(length).toString('hex');
|
|
31
|
+
}
|