dpdp-erasure-cli 1.0.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 +55 -0
- package/Dockerfile +33 -0
- package/compliance.worker.yaml +64 -0
- package/package.json +41 -0
- package/src/constants/index.ts +1 -0
- package/src/errors/fail.ts +110 -0
- package/src/errors/index.ts +4 -0
- package/src/errors/inferer.ts +166 -0
- package/src/errors/registry.ts +122 -0
- package/src/errors/types.ts +65 -0
- package/src/errors/worker.ts +161 -0
- package/src/index.ts +328 -0
- package/src/lib/crypto/digest.ts +22 -0
- package/src/lib/crypto/encoding.ts +78 -0
- package/src/lib/crypto/index.ts +2 -0
- package/src/lib/index.ts +1 -0
- package/src/modules/bootstrap/index.ts +2 -0
- package/src/modules/bootstrap/integrity.ts +38 -0
- package/src/modules/bootstrap/preflight.ts +296 -0
- package/src/modules/cli/check-integrity.ts +48 -0
- package/src/modules/cli/dry-run.ts +90 -0
- package/src/modules/cli/graph.ts +87 -0
- package/src/modules/cli/index.ts +184 -0
- package/src/modules/cli/init.ts +115 -0
- package/src/modules/cli/inspect.ts +86 -0
- package/src/modules/cli/introspector.ts +117 -0
- package/src/modules/cli/keygen.ts +38 -0
- package/src/modules/cli/scan.ts +126 -0
- package/src/modules/cli/sign.ts +50 -0
- package/src/modules/cli/ui.ts +61 -0
- package/src/modules/cli/verify-schema.ts +31 -0
- package/src/modules/cli/verify.ts +85 -0
- package/src/modules/config/compatibility.ts +271 -0
- package/src/modules/config/index.ts +4 -0
- package/src/modules/config/reader.ts +149 -0
- package/src/modules/config/signature.ts +69 -0
- package/src/modules/config/validation.ts +658 -0
- package/src/modules/crypto/aes.ts +158 -0
- package/src/modules/crypto/envelope.ts +48 -0
- package/src/modules/crypto/hmac.ts +60 -0
- package/src/modules/crypto/index.ts +3 -0
- package/src/modules/db/drift.ts +36 -0
- package/src/modules/db/graph.ts +203 -0
- package/src/modules/db/index.ts +4 -0
- package/src/modules/db/migrations.ts +254 -0
- package/src/modules/db/sql-debug.ts +61 -0
- package/src/modules/engine/blob/index.ts +3 -0
- package/src/modules/engine/blob/s3.ts +455 -0
- package/src/modules/engine/blob/store.ts +236 -0
- package/src/modules/engine/blob/types.ts +44 -0
- package/src/modules/engine/helpers/identity.ts +47 -0
- package/src/modules/engine/helpers/index.ts +4 -0
- package/src/modules/engine/helpers/outbox.ts +118 -0
- package/src/modules/engine/helpers/runtime.ts +115 -0
- package/src/modules/engine/helpers/types.ts +61 -0
- package/src/modules/engine/index.ts +6 -0
- package/src/modules/engine/notifier/config.ts +147 -0
- package/src/modules/engine/notifier/dispatcher.ts +300 -0
- package/src/modules/engine/notifier/index.ts +3 -0
- package/src/modules/engine/notifier/payload.ts +51 -0
- package/src/modules/engine/notifier/reservation.ts +153 -0
- package/src/modules/engine/notifier/types.ts +38 -0
- package/src/modules/engine/shredder.ts +254 -0
- package/src/modules/engine/types.ts +146 -0
- package/src/modules/engine/vault/compiled-targets.ts +562 -0
- package/src/modules/engine/vault/context.ts +254 -0
- package/src/modules/engine/vault/dry-run.ts +94 -0
- package/src/modules/engine/vault/execution.ts +485 -0
- package/src/modules/engine/vault/index.ts +3 -0
- package/src/modules/engine/vault/purge.ts +82 -0
- package/src/modules/engine/vault/retention.ts +124 -0
- package/src/modules/engine/vault/satellite-mutation.ts +193 -0
- package/src/modules/engine/vault/satellite.ts +103 -0
- package/src/modules/engine/vault/shadow.ts +36 -0
- package/src/modules/engine/vault/static-plan.ts +116 -0
- package/src/modules/engine/vault/store.ts +34 -0
- package/src/modules/engine/vault/vault.ts +84 -0
- package/src/modules/introspector/classifier.ts +502 -0
- package/src/modules/introspector/dag.ts +276 -0
- package/src/modules/introspector/index.ts +7 -0
- package/src/modules/introspector/naming.ts +75 -0
- package/src/modules/introspector/report.ts +153 -0
- package/src/modules/introspector/run.ts +123 -0
- package/src/modules/introspector/s3-sampler.ts +227 -0
- package/src/modules/introspector/types.ts +131 -0
- package/src/modules/introspector/yaml.ts +101 -0
- package/src/modules/network/api/control-plane.ts +275 -0
- package/src/modules/network/api/index.ts +1 -0
- package/src/modules/network/api/validation.ts +71 -0
- package/src/modules/network/index.ts +4 -0
- package/src/modules/network/object-store/aws/client.ts +444 -0
- package/src/modules/network/object-store/aws/credentials.ts +271 -0
- package/src/modules/network/object-store/aws/index.ts +2 -0
- package/src/modules/network/object-store/aws/sigv4.ts +190 -0
- package/src/modules/network/object-store/aws/type.ts +6 -0
- package/src/modules/network/object-store/index.ts +1 -0
- package/src/modules/network/outbox/dispatcher.ts +183 -0
- package/src/modules/network/outbox/index.ts +3 -0
- package/src/modules/network/outbox/process.ts +133 -0
- package/src/modules/network/outbox/shared.ts +56 -0
- package/src/modules/network/outbox/store.ts +346 -0
- package/src/modules/network/outbox/types.ts +54 -0
- package/src/modules/network/request-signing.ts +61 -0
- package/src/modules/worker/index.ts +2 -0
- package/src/modules/worker/tasks.ts +58 -0
- package/src/modules/worker/types.ts +89 -0
- package/src/modules/worker/worker.ts +243 -0
- package/src/secrets/index.ts +4 -0
- package/src/secrets/kms/index.ts +2 -0
- package/src/secrets/kms/signature.ts +82 -0
- package/src/secrets/kms/validation.ts +64 -0
- package/src/secrets/reader.ts +42 -0
- package/src/secrets/repository/crypto.ts +89 -0
- package/src/secrets/repository/index.ts +2 -0
- package/src/secrets/repository/methods.ts +37 -0
- package/src/secrets/resolvers.ts +247 -0
- package/src/secrets/signature.ts +78 -0
- package/src/types/index.ts +1 -0
- package/src/types/types.ts +23 -0
- package/src/utils/identifiers.ts +48 -0
- package/src/utils/index.ts +3 -0
- package/src/utils/json.ts +35 -0
- package/src/utils/logger.ts +161 -0
- package/src/validation/zod.ts +70 -0
- package/tests/adversarial.test.ts +464 -0
- package/tests/blob-s3.test.ts +216 -0
- package/tests/config.test.ts +395 -0
- package/tests/control-plane-client.test.ts +108 -0
- package/tests/crypto.test.ts +106 -0
- package/tests/errors.test.ts +69 -0
- package/tests/fetch-dispatcher.test.ts +213 -0
- package/tests/graph.test.ts +84 -0
- package/tests/helpers/index.ts +101 -0
- package/tests/index-preflight.test.ts +168 -0
- package/tests/introspector-classifier.test.ts +62 -0
- package/tests/introspector-report.test.ts +85 -0
- package/tests/introspector.test.ts +394 -0
- package/tests/kms.test.ts +124 -0
- package/tests/logger.test.ts +61 -0
- package/tests/notifier.test.ts +303 -0
- package/tests/outbox.test.ts +478 -0
- package/tests/purge-policy.test.ts +124 -0
- package/tests/retention.test.ts +103 -0
- package/tests/s3-client.test.ts +110 -0
- package/tests/satellite.test.ts +119 -0
- package/tests/schema-compatibility.test.ts +237 -0
- package/tests/schema-integrity.test.ts +64 -0
- package/tests/shredder.test.ts +163 -0
- package/tests/vault.compiled-targets.test.ts +243 -0
- package/tests/vault.replica.test.ts +59 -0
- package/tests/vault.test.ts +279 -0
- package/tests/worker.retry.test.ts +291 -0
- package/tests/worker.test.ts +200 -0
- package/tsconfig.json +19 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import {
|
|
2
|
+
dispatchPreErasureNotice,
|
|
3
|
+
shredUser,
|
|
4
|
+
vaultUser,
|
|
5
|
+
type MockMailer,
|
|
6
|
+
type WorkerSecrets
|
|
7
|
+
} from "@modules/engine";
|
|
8
|
+
import type { WorkerConfig } from "@modules/config";
|
|
9
|
+
import { processOutbox, type ProcessOutboxResult, type S3Client } from "@modules/network";
|
|
10
|
+
import type { Sql } from "@/types";
|
|
11
|
+
import { fail, serializeWorkerError } from "@/errors";
|
|
12
|
+
import { logError, workerLogger } from "@/utils";
|
|
13
|
+
import type { ApiClient, ComplianceWorkerOptions, TaskExecutionResult, WorkerTask } from "./types";
|
|
14
|
+
import { acknowledgeTask, resolveTaskSubject } from "./tasks";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Orchestrates Control Plane tasks and enforces fail-closed execution semantics.
|
|
18
|
+
*/
|
|
19
|
+
export class ComplianceWorker {
|
|
20
|
+
private readonly sql: Sql;
|
|
21
|
+
private readonly sqlReplica?: Sql;
|
|
22
|
+
private readonly secrets: WorkerSecrets;
|
|
23
|
+
private readonly config: WorkerConfig;
|
|
24
|
+
private readonly apiClient: ApiClient;
|
|
25
|
+
private readonly mailer: MockMailer;
|
|
26
|
+
private readonly s3Client?: S3Client;
|
|
27
|
+
private readonly taskHeartbeatIntervalMs: number;
|
|
28
|
+
|
|
29
|
+
constructor(options: ComplianceWorkerOptions) {
|
|
30
|
+
this.sql = options.sql;
|
|
31
|
+
this.sqlReplica = options.sqlReplica;
|
|
32
|
+
this.secrets = options.secrets;
|
|
33
|
+
this.config = options.config;
|
|
34
|
+
this.apiClient = options.apiClient;
|
|
35
|
+
this.mailer = options.mailer;
|
|
36
|
+
this.s3Client = options.s3Client;
|
|
37
|
+
this.taskHeartbeatIntervalMs = Math.max(1_000, options.taskHeartbeatIntervalMs ?? 30_000);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private startTaskHeartbeat(task: WorkerTask): () => void {
|
|
41
|
+
if (!this.apiClient.heartbeatTask) {
|
|
42
|
+
return () => undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const heartbeat = this.apiClient.heartbeatTask;
|
|
46
|
+
const timer = setInterval(() => {
|
|
47
|
+
void heartbeat(task.id).catch((error) => {
|
|
48
|
+
logError(
|
|
49
|
+
workerLogger.child({ taskId: task.id, taskType: task.task_type }),
|
|
50
|
+
error,
|
|
51
|
+
"Task heartbeat failed"
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
}, this.taskHeartbeatIntervalMs);
|
|
55
|
+
|
|
56
|
+
return () => clearInterval(timer);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private async executeTask(task: WorkerTask, now: Date): Promise<TaskExecutionResult> {
|
|
60
|
+
switch (task.task_type) {
|
|
61
|
+
case "COMPILE_DAG":
|
|
62
|
+
return {
|
|
63
|
+
action: "compiled_dag",
|
|
64
|
+
userHash: null,
|
|
65
|
+
dryRun: false,
|
|
66
|
+
compiledTargetCount: this.config.rules?.[0]?.targets.length ?? 0,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
case "VAULT_USER":
|
|
70
|
+
return vaultUser(
|
|
71
|
+
this.sql,
|
|
72
|
+
task.payload.subject_opaque_id ?? task.payload.userId ?? "",
|
|
73
|
+
this.secrets,
|
|
74
|
+
{
|
|
75
|
+
appSchema: this.config.database.app_schema,
|
|
76
|
+
engineSchema: this.config.database.engine_schema,
|
|
77
|
+
defaultRetentionYears: this.config.compliance_policy.default_retention_years,
|
|
78
|
+
noticeWindowHours: this.config.compliance_policy.notice_window_hours,
|
|
79
|
+
graphMaxDepth: this.config.graph.max_depth,
|
|
80
|
+
rootTable: this.config.graph.root_table,
|
|
81
|
+
rootIdColumn: this.config.graph.root_id_column,
|
|
82
|
+
rootPiiColumns: this.config.graph.root_pii_columns,
|
|
83
|
+
satelliteTargets: this.config.satellite_targets,
|
|
84
|
+
blobTargets: this.config.blob_targets,
|
|
85
|
+
compiledTargets: this.config.rules?.[0]?.targets,
|
|
86
|
+
retentionRules: this.config.compliance_policy.retention_rules,
|
|
87
|
+
tenantId: task.payload.tenant_id,
|
|
88
|
+
requestId: task.payload.request_id,
|
|
89
|
+
subjectOpaqueId: task.payload.subject_opaque_id,
|
|
90
|
+
triggerSource: task.payload.trigger_source,
|
|
91
|
+
actorOpaqueId: task.payload.actor_opaque_id,
|
|
92
|
+
legalFramework: task.payload.legal_framework,
|
|
93
|
+
requestTimestamp: task.payload.request_timestamp,
|
|
94
|
+
shadowMode: task.payload.shadow_mode ?? task.payload.shadowMode,
|
|
95
|
+
sqlReplica: this.sqlReplica,
|
|
96
|
+
s3Client: this.s3Client,
|
|
97
|
+
now,
|
|
98
|
+
}
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
case "NOTIFY_USER":
|
|
102
|
+
return dispatchPreErasureNotice(this.sql, resolveTaskSubject(task), this.secrets, this.mailer, {
|
|
103
|
+
appSchema: this.config.database.app_schema,
|
|
104
|
+
engineSchema: this.config.database.engine_schema,
|
|
105
|
+
rootTable: this.config.graph.root_table,
|
|
106
|
+
notificationLeaseSeconds: this.config.security.notification_lease_seconds,
|
|
107
|
+
noticeEmailColumn: this.config.graph.notice_email_column,
|
|
108
|
+
noticeNameColumn: this.config.graph.notice_name_column,
|
|
109
|
+
rootPiiColumns: this.config.graph.root_pii_columns,
|
|
110
|
+
now,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
case "SHRED_USER":
|
|
114
|
+
return shredUser(this.sql, resolveTaskSubject(task), {
|
|
115
|
+
appSchema: this.config.database.app_schema,
|
|
116
|
+
engineSchema: this.config.database.engine_schema,
|
|
117
|
+
rootTable: this.config.graph.root_table,
|
|
118
|
+
hmacKey: this.secrets.hmacKey,
|
|
119
|
+
s3Client: this.s3Client,
|
|
120
|
+
now,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
default:
|
|
124
|
+
fail({
|
|
125
|
+
code: "TASK_TYPE_UNKNOWN",
|
|
126
|
+
title: "Unknown task type",
|
|
127
|
+
detail: `Unknown task type: ${task.task_type}.`,
|
|
128
|
+
category: "validation",
|
|
129
|
+
retryable: false,
|
|
130
|
+
context: {
|
|
131
|
+
taskId: task.id,
|
|
132
|
+
taskType: task.task_type,
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Processes at most one leased task from the Control Plane.
|
|
140
|
+
*
|
|
141
|
+
* Retryable/fatal errors are rethrown to preserve lease recovery behavior in the caller loop.
|
|
142
|
+
*
|
|
143
|
+
* @returns `true` when a task was claimed (completed or failed-ack), `false` when no task was pending.
|
|
144
|
+
* @throws {WorkerError} On retryable/fatal execution failures.
|
|
145
|
+
*/
|
|
146
|
+
async processNextTask(): Promise<boolean> {
|
|
147
|
+
const { pending, task } = await this.apiClient.syncTask();
|
|
148
|
+
if (!pending || !task) {
|
|
149
|
+
return false
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const taskLogger = workerLogger.child({
|
|
153
|
+
taskId: task.id,
|
|
154
|
+
taskType: task.task_type
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const now = task.payload.now ? new Date(task.payload.now) : new Date();
|
|
159
|
+
const stopHeartbeat = this.startTaskHeartbeat(task);
|
|
160
|
+
let result: TaskExecutionResult;
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
result = await this.executeTask(task, now);
|
|
164
|
+
} finally {
|
|
165
|
+
stopHeartbeat();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
await acknowledgeTask(this.apiClient, task.id, "completed", result)
|
|
169
|
+
taskLogger.info({ action: result.action, uesrHash: result.userHash })
|
|
170
|
+
|
|
171
|
+
return true;
|
|
172
|
+
} catch (error) {
|
|
173
|
+
const normalized = logError(taskLogger, error, 'Task execution failed');
|
|
174
|
+
|
|
175
|
+
if (normalized.fatal || normalized.retryable) {
|
|
176
|
+
throw normalized;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
await acknowledgeTask(this.apiClient, task.id, "failed", {
|
|
180
|
+
error: serializeWorkerError(normalized, `task:${task.id}`),
|
|
181
|
+
});
|
|
182
|
+
taskLogger.warn({ code: normalized.code }, "Task acknowledged as failed");
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Processes a bounded number of independent Control Plane tasks concurrently.
|
|
189
|
+
*
|
|
190
|
+
* Each task still owns its own database transaction, lease, heartbeat, and acknowledgement.
|
|
191
|
+
* Fatal failures are rethrown after all in-flight tasks settle so one bad task cannot orphan
|
|
192
|
+
* sibling leases in the same worker loop iteration.
|
|
193
|
+
*
|
|
194
|
+
* @param concurrency - Maximum task claims to attempt in this pass.
|
|
195
|
+
* @returns Number of claimed tasks that completed or were acknowledged as failed.
|
|
196
|
+
* @throws {WorkerError} If any in-flight task reports a fatal/retryable loop-level failure.
|
|
197
|
+
*/
|
|
198
|
+
async processTaskBatch(concurrency: number): Promise<number> {
|
|
199
|
+
const width = Math.max(1, Math.floor(concurrency));
|
|
200
|
+
if (width === 1) {
|
|
201
|
+
return await this.processNextTask() ? 1 : 0;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const results = await Promise.allSettled(
|
|
205
|
+
Array.from({ length: width }, () => this.processNextTask())
|
|
206
|
+
);
|
|
207
|
+
let processed = 0;
|
|
208
|
+
let firstFailure: unknown;
|
|
209
|
+
|
|
210
|
+
for (const result of results) {
|
|
211
|
+
if (result.status === "fulfilled") {
|
|
212
|
+
if (result.value) {
|
|
213
|
+
processed += 1;
|
|
214
|
+
}
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
firstFailure ??= result.reason;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (firstFailure) {
|
|
222
|
+
throw firstFailure;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return processed;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Flushes the local transactional outbox to the Control Plane endpoint.
|
|
230
|
+
*
|
|
231
|
+
* @returns Promise resolved after one outbox processing pass.
|
|
232
|
+
* @throws {WorkerError} When outbox processing detects fatal delivery/protocol errors.
|
|
233
|
+
*/
|
|
234
|
+
async flushOutbox(): Promise<ProcessOutboxResult> {
|
|
235
|
+
return processOutbox(this.sql, async (event) => this.apiClient.pushOutboxEvent(event), {
|
|
236
|
+
engineSchema: this.config.database.engine_schema,
|
|
237
|
+
batchSize: this.config.outbox.batch_size,
|
|
238
|
+
leaseSeconds: this.config.outbox.lease_seconds,
|
|
239
|
+
maxAttempts: this.config.outbox.max_attempts,
|
|
240
|
+
baseBackoffMs: this.config.outbox.base_backoff_ms,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { bytesToHex } from "@/lib";
|
|
2
|
+
import { hmacSha256, seedSecret, sha256Hex } from "../repository"
|
|
3
|
+
|
|
4
|
+
export interface KMSAwsCredentials {
|
|
5
|
+
accessKeyId: string;
|
|
6
|
+
secretAccessKey: string;
|
|
7
|
+
sessionToken?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function signAwsKmsRequest(
|
|
11
|
+
endpoint: URL,
|
|
12
|
+
region: string,
|
|
13
|
+
body: string,
|
|
14
|
+
credentials: KMSAwsCredentials,
|
|
15
|
+
now: Date = new Date()
|
|
16
|
+
): Promise<Headers> {
|
|
17
|
+
const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
18
|
+
const dateStamp = amzDate.slice(0, 8);
|
|
19
|
+
const payloadHash = await sha256Hex(body);
|
|
20
|
+
const headers = new Headers({
|
|
21
|
+
"content-type": "application/x-amz-json-1.1",
|
|
22
|
+
host: endpoint.host,
|
|
23
|
+
"x-amz-content-sha256": payloadHash,
|
|
24
|
+
"x-amz-date": amzDate,
|
|
25
|
+
"x-amz-target": "TrentService.Decrypt",
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
if (credentials.sessionToken) {
|
|
29
|
+
headers.set("x-amz-security-token", credentials.sessionToken);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const sortedHeaders = Array.from(headers.entries()).sort(([left], [right]) => left.localeCompare(right));
|
|
33
|
+
const canonicalHeaders = sortedHeaders
|
|
34
|
+
.map(([name, value]) => `${name.toLowerCase()}:${value.trim().replace(/\s+/g, " ")}\n`)
|
|
35
|
+
.join("");
|
|
36
|
+
const signedHeaders = sortedHeaders.map(([name]) => name.toLowerCase()).join(";");
|
|
37
|
+
const canonicalRequest = [
|
|
38
|
+
"POST",
|
|
39
|
+
endpoint.pathname || "/",
|
|
40
|
+
endpoint.search.length > 1 ? endpoint.search.slice(1) : "",
|
|
41
|
+
canonicalHeaders,
|
|
42
|
+
signedHeaders,
|
|
43
|
+
payloadHash,
|
|
44
|
+
].join("\n");
|
|
45
|
+
const credentialScope = `${dateStamp}/${region}/kms/aws4_request`;
|
|
46
|
+
const stringToSign = [
|
|
47
|
+
"AWS4-HMAC-SHA256",
|
|
48
|
+
amzDate,
|
|
49
|
+
credentialScope,
|
|
50
|
+
await sha256Hex(canonicalRequest),
|
|
51
|
+
].join("\n");
|
|
52
|
+
|
|
53
|
+
const secretSeed = seedSecret(credentials.secretAccessKey)
|
|
54
|
+
let dateKey: Uint8Array = new Uint8Array(0);
|
|
55
|
+
let regionKey: Uint8Array = new Uint8Array(0);
|
|
56
|
+
let serviceKey: Uint8Array = new Uint8Array(0);
|
|
57
|
+
let signingKey: Uint8Array = new Uint8Array(0);
|
|
58
|
+
let signatureBytes: Uint8Array = new Uint8Array(0);
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
dateKey = await hmacSha256(secretSeed, dateStamp);
|
|
62
|
+
regionKey = await hmacSha256(dateKey, region);
|
|
63
|
+
serviceKey = await hmacSha256(regionKey, "kms");
|
|
64
|
+
signingKey = await hmacSha256(serviceKey, "aws4_request");
|
|
65
|
+
signatureBytes = await hmacSha256(signingKey, stringToSign);
|
|
66
|
+
const signature = bytesToHex(signatureBytes);
|
|
67
|
+
|
|
68
|
+
headers.set(
|
|
69
|
+
"authorization",
|
|
70
|
+
`AWS4-HMAC-SHA256 Credential=${credentials.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`
|
|
71
|
+
);
|
|
72
|
+
} finally {
|
|
73
|
+
secretSeed.fill(0);
|
|
74
|
+
dateKey.fill(0);
|
|
75
|
+
regionKey.fill(0);
|
|
76
|
+
serviceKey.fill(0);
|
|
77
|
+
signingKey.fill(0);
|
|
78
|
+
signatureBytes.fill(0);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return headers;
|
|
82
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
const envSourceSchema = z
|
|
4
|
+
.object({
|
|
5
|
+
provider: z.literal("env"),
|
|
6
|
+
env: z.string().min(1),
|
|
7
|
+
})
|
|
8
|
+
.strict();
|
|
9
|
+
|
|
10
|
+
const fileSourceSchema = z
|
|
11
|
+
.object({
|
|
12
|
+
provider: z.literal("file"),
|
|
13
|
+
path: z.string().min(1),
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
const awsKmsSourceSchema = z
|
|
17
|
+
.object({
|
|
18
|
+
provider: z.literal("aws_kms"),
|
|
19
|
+
region: z.string().min(1),
|
|
20
|
+
ciphertext_blob_base64: z.string().min(1),
|
|
21
|
+
key_id: z.string().min(1).optional(),
|
|
22
|
+
encryption_context: z.record(z.string(), z.string()).optional(),
|
|
23
|
+
endpoint: z.url().optional(),
|
|
24
|
+
access_key_id_env: z.string().min(1).default("AWS_ACCESS_KEY_ID"),
|
|
25
|
+
secret_access_key_env: z.string().min(1).default("AWS_SECRET_ACCESS_KEY"),
|
|
26
|
+
session_token_env: z.string().min(1).default("AWS_SESSION_TOKEN"),
|
|
27
|
+
})
|
|
28
|
+
.strict();
|
|
29
|
+
|
|
30
|
+
const gcpSecretManagerSourceSchema = z
|
|
31
|
+
.object({
|
|
32
|
+
provider: z.literal("gcp_secret_manager"),
|
|
33
|
+
secret_version: z.string().min(1),
|
|
34
|
+
endpoint: z.url().optional(),
|
|
35
|
+
access_token_env: z.string().min(1).default("GCP_ACCESS_TOKEN"),
|
|
36
|
+
metadata_token_url: z
|
|
37
|
+
.url()
|
|
38
|
+
.default("http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token"),
|
|
39
|
+
})
|
|
40
|
+
.strict();
|
|
41
|
+
|
|
42
|
+
const vaultKvV2SourceSchema = z
|
|
43
|
+
.object({
|
|
44
|
+
provider: z.literal("hashicorp_vault"),
|
|
45
|
+
address: z.url().optional(),
|
|
46
|
+
address_env: z.string().min(1).default("VAULT_ADDR"),
|
|
47
|
+
token_env: z.string().min(1).default("VAULT_TOKEN"),
|
|
48
|
+
namespace_env: z.string().min(1).default("VAULT_NAMESPACE"),
|
|
49
|
+
mount: z.string().min(1),
|
|
50
|
+
path: z.string().min(1),
|
|
51
|
+
field: z.string().min(1),
|
|
52
|
+
version: z.number().int().positive().optional(),
|
|
53
|
+
})
|
|
54
|
+
.strict();
|
|
55
|
+
|
|
56
|
+
export const keySourceSchema = z.discriminatedUnion("provider", [
|
|
57
|
+
envSourceSchema,
|
|
58
|
+
fileSourceSchema,
|
|
59
|
+
awsKmsSourceSchema,
|
|
60
|
+
gcpSecretManagerSourceSchema,
|
|
61
|
+
vaultKvV2SourceSchema
|
|
62
|
+
])
|
|
63
|
+
|
|
64
|
+
export type KeySourceConfig = z.infer<typeof keySourceSchema>;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { EnvType } from "@/types";
|
|
2
|
+
import { decodeKeyMaterial } from "./repository";
|
|
3
|
+
import type { ResolveKeyOptions } from "./resolvers";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolves a runtime secret from an environment variable or it's moutned-file companion
|
|
7
|
+
*
|
|
8
|
+
* If `FOO_FILE` is present, the worker reads the secret from that path. This supports
|
|
9
|
+
* Kubernetes secret volumes, Vault agent injection, and CSI-mounted secret providers
|
|
10
|
+
* without changing the YAML contract that names logical secret identifiers.
|
|
11
|
+
*
|
|
12
|
+
* @param env - Raw environment variable
|
|
13
|
+
* @param envName - Logical environment variable name declared in yaml
|
|
14
|
+
* @returns Resolved secret value or an empty string when no source is configured
|
|
15
|
+
*/
|
|
16
|
+
export async function readRuntimeSecret(
|
|
17
|
+
env: EnvType,
|
|
18
|
+
envName: string
|
|
19
|
+
): Promise<string> {
|
|
20
|
+
const directValue = env[envName];
|
|
21
|
+
if (directValue && directValue.trim().length > 0) {
|
|
22
|
+
return directValue.trim()
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const filePath = env[`${envName}_FILE`];
|
|
26
|
+
if (!filePath || filePath.trim().length === 0) {
|
|
27
|
+
return "";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return (await Bun.file(filePath).text()).trim();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function readLegacyEnvKey(options: ResolveKeyOptions): Promise<Uint8Array> {
|
|
34
|
+
const value =
|
|
35
|
+
await readRuntimeSecret(options.env, options.legacyEnvName)
|
|
36
|
+
|| (
|
|
37
|
+
options.fallbackLegacyEnvName
|
|
38
|
+
? await readRuntimeSecret(options.env, options.fallbackLegacyEnvName)
|
|
39
|
+
: ""
|
|
40
|
+
);
|
|
41
|
+
return decodeKeyMaterial(value, options.keyName);
|
|
42
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { fail, CODE } from "@/errors";
|
|
2
|
+
import { base64ToBytes, bytesToHex, copyBytes, hexToBytes } from "@/lib";
|
|
3
|
+
|
|
4
|
+
const KEY_LENGTH = 32;
|
|
5
|
+
const textEncoder = new TextEncoder();
|
|
6
|
+
const textDecoder = new TextDecoder();
|
|
7
|
+
|
|
8
|
+
function ensureKeyLength(bytes: Uint8Array, keyName: string): Uint8Array {
|
|
9
|
+
if (bytes.length === KEY_LENGTH) {
|
|
10
|
+
return new Uint8Array(bytes);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
fail({
|
|
14
|
+
code: CODE.SECRET_ENV_INVALID,
|
|
15
|
+
data: { keyName, KEY_LENGTH },
|
|
16
|
+
context: { keyName },
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function normalizeBase64(value: string): string {
|
|
21
|
+
return value.trim().replace(/-/g, "+").replace(/_/g, "/");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Decodes configured key material from raw bytes or textual hex/base64.
|
|
26
|
+
*
|
|
27
|
+
* @param rawValue - Runtime key value returned by env, file, KMS, Secret Manager, or Vault.
|
|
28
|
+
* @param keyName - Human-readable key label used in fail-closed error details.
|
|
29
|
+
* @returns A defensive copy of the 32-byte key.
|
|
30
|
+
* @throws {WorkerError} If the value cannot be decoded into a 256-bit key.
|
|
31
|
+
*/
|
|
32
|
+
export function decodeKeyMaterial(rawValue: string | Uint8Array, keyName: string): Uint8Array {
|
|
33
|
+
if (rawValue instanceof Uint8Array) {
|
|
34
|
+
if (rawValue.length === KEY_LENGTH) {
|
|
35
|
+
return new Uint8Array(rawValue);
|
|
36
|
+
}
|
|
37
|
+
rawValue = textDecoder.decode(rawValue);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const value = rawValue.trim();
|
|
41
|
+
if (value.length === 0) {
|
|
42
|
+
fail({
|
|
43
|
+
code: CODE.SECRET_ENV_MISSING,
|
|
44
|
+
data: { keyName },
|
|
45
|
+
context: { keyName }
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const normalizedHex = value.startsWith("hex:") ? value.slice(4) : value;
|
|
50
|
+
if (/^[0-9a-fA-F]+$/.test(normalizedHex) && normalizedHex.length === KEY_LENGTH * 2) {
|
|
51
|
+
return hexToBytes(normalizedHex);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const normalizedBase64 = value.startsWith("base64:") ? value.slice(7) : value;
|
|
55
|
+
try {
|
|
56
|
+
return ensureKeyLength(base64ToBytes(normalizeBase64(normalizedBase64)), keyName)
|
|
57
|
+
} catch (error) {
|
|
58
|
+
fail({
|
|
59
|
+
code: CODE.SECRET_ENV_INVALID,
|
|
60
|
+
data: { keyName, KEY_LENGTH },
|
|
61
|
+
context: { keyName }
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function sha256Hex(value: string): Promise<string> {
|
|
67
|
+
const bytes = textEncoder.encode(value);
|
|
68
|
+
// Ensure we pass a regular ArrayBuffer, as SharedArrayBuffer is not allowed for crypto.
|
|
69
|
+
const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes.slice().buffer as ArrayBuffer);
|
|
70
|
+
return bytesToHex(new Uint8Array(digest));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function seedSecret(secretAccessKey: string): Uint8Array {
|
|
74
|
+
return copyBytes(textEncoder.encode(`AWS4${secretAccessKey}`));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function hmacSha256(key: Uint8Array, value: string | Uint8Array): Promise<Uint8Array> {
|
|
78
|
+
const keyBytes = key.slice();
|
|
79
|
+
const cryptoKey = await globalThis.crypto.subtle.importKey(
|
|
80
|
+
"raw",
|
|
81
|
+
keyBytes.buffer as ArrayBuffer,
|
|
82
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
83
|
+
false,
|
|
84
|
+
["sign"]
|
|
85
|
+
);
|
|
86
|
+
const data = typeof value === "string" ? textEncoder.encode(value).slice() : value.slice();
|
|
87
|
+
const signature = await globalThis.crypto.subtle.sign("HMAC", cryptoKey, data.buffer as ArrayBuffer);
|
|
88
|
+
return new Uint8Array(signature);
|
|
89
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { fail } from "@/errors";
|
|
2
|
+
|
|
3
|
+
export const fetchJson = async (
|
|
4
|
+
fetchFn: typeof fetch,
|
|
5
|
+
url: string | URL,
|
|
6
|
+
init: RequestInit,
|
|
7
|
+
provider: string
|
|
8
|
+
): Promise<unknown> => {
|
|
9
|
+
const response = await fetchFn(url, {
|
|
10
|
+
...init,
|
|
11
|
+
redirect: "error"
|
|
12
|
+
});
|
|
13
|
+
if (!response.ok) {
|
|
14
|
+
fail({
|
|
15
|
+
code: "KMS_PROVIDER_FAILED",
|
|
16
|
+
title: "Key provider request failed",
|
|
17
|
+
detail: `${provider} responded with HTTP ${response.status}.`,
|
|
18
|
+
category: "external",
|
|
19
|
+
retryable: response.status >= 500 || response.status === 429,
|
|
20
|
+
fatal: response.status >= 400 && response.status < 500 && response.status !== 429,
|
|
21
|
+
context: { provider, status: response.status },
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
return response.json();
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const isRecord = (value: unknown): value is Record<string, unknown> => {
|
|
28
|
+
return typeof value === "object" && value !== null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function encodeVaultPathSegment(value: string): string {
|
|
32
|
+
return value
|
|
33
|
+
.split("/")
|
|
34
|
+
.filter((part) => part.length > 0)
|
|
35
|
+
.map((part) => encodeURIComponent(part))
|
|
36
|
+
.join("/");
|
|
37
|
+
}
|