@opengeni/vaults-keeper 0.0.0-stage → 3.3.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/src/sdk.ts ADDED
@@ -0,0 +1,282 @@
1
+ import * as nodeCrypto from "node:crypto";
2
+ import { constants as fsConstants, readFileSync } from "node:fs";
3
+ import { createRequire } from "node:module";
4
+ import * as nodePath from "node:path";
5
+ import type * as KeeperSdkModule from "@keeper-security/secrets-manager-core";
6
+ import { VaultProviderError } from "./contract";
7
+
8
+ /** The official SDK release this adapter is written and tested against. */
9
+ export const KEEPER_SDK_VERSION = "17.6.0";
10
+ export const KEEPER_SDK_PACKAGE = "@keeper-security/secrets-manager-core";
11
+ /** SHA-256 of `dist/index.cjs.js` in @keeper-security/secrets-manager-core 17.6.0. */
12
+ export const KEEPER_SDK_SHA256 = "94787ce51324f36c1b62d19ac29fbe3b9b206305695e508b76585a4d6e39635c";
13
+
14
+ export type KeeperSdk = typeof KeeperSdkModule;
15
+ export type KeeperRecord = KeeperSdkModule.KeeperRecord;
16
+ export type KeeperFolder = KeeperSdkModule.KeeperFolder;
17
+ export type KeeperFile = KeeperSdkModule.KeeperFile;
18
+ export type KeeperSecrets = KeeperSdkModule.KeeperSecrets;
19
+ export type KeyValueStorage = KeeperSdkModule.KeyValueStorage;
20
+ export type SecretManagerOptions = KeeperSdkModule.SecretManagerOptions;
21
+ export type KeeperQueryFunction = NonNullable<SecretManagerOptions["queryFunction"]>;
22
+ export type KeeperHttpResponse = Awaited<ReturnType<KeeperQueryFunction>>;
23
+
24
+ /**
25
+ * Bounded log capture for one SDK instance. The SDK reports skipped records,
26
+ * folders and files only through `console.error`; those lines carry record
27
+ * UIDs and error text, so they are inspected here and never forwarded.
28
+ */
29
+ export class CapturedConsole {
30
+ readonly lines: string[] = [];
31
+ private static readonly MAX_LINES = 200;
32
+ private static readonly MAX_CHARS = 500;
33
+ private push(args: unknown[]) {
34
+ if (this.lines.length >= CapturedConsole.MAX_LINES) return;
35
+ const text = args
36
+ .map((arg) => (arg instanceof Error ? `${arg.name}: ${arg.message}` : String(arg)))
37
+ .join(" ");
38
+ this.lines.push(text.slice(0, CapturedConsole.MAX_CHARS));
39
+ }
40
+ readonly error = (...args: unknown[]) => this.push(args);
41
+ readonly warn = (...args: unknown[]) => this.push(args);
42
+ readonly log = (...args: unknown[]) => this.push(args);
43
+ readonly info = (...args: unknown[]) => this.push(args);
44
+ readonly debug = (...args: unknown[]) => this.push(args);
45
+
46
+ /** True when the SDK dropped a record, folder or file it could not decrypt. */
47
+ get skippedSomething(): boolean {
48
+ return this.lines.some((line) => /skipped due to error|could not be decrypted/.test(line));
49
+ }
50
+ }
51
+
52
+ function denied(module: string): never {
53
+ throw new Error(`The Keeper SDK may not use ${module} inside Opengeni`);
54
+ }
55
+
56
+ /**
57
+ * Modules handed to each SDK instance. `fs` exposes only its constants (read
58
+ * at load for symlink-safe flags); every function is absent, so the SDK's
59
+ * on-disk config and response cache cannot write anything. `https` and `os`
60
+ * are empty: all network goes through the adapter's own transport.
61
+ */
62
+ const FS_STUB = Object.freeze({ constants: fsConstants });
63
+ const HTTPS_STUB = Object.freeze({});
64
+ const OS_STUB = Object.freeze({});
65
+
66
+ function sdkRequire(name: string): unknown {
67
+ switch (name) {
68
+ case "crypto":
69
+ return nodeCrypto;
70
+ case "path":
71
+ return nodePath;
72
+ case "fs":
73
+ return FS_STUB;
74
+ case "https":
75
+ return HTTPS_STUB;
76
+ case "os":
77
+ return OS_STUB;
78
+ default:
79
+ return denied(name);
80
+ }
81
+ }
82
+
83
+ type SdkFactory = (
84
+ module: { exports: Record<string, unknown> },
85
+ exports: Record<string, unknown>,
86
+ require: (name: string) => unknown,
87
+ console: CapturedConsole,
88
+ ) => void;
89
+
90
+ let compiled: { sha256: string; factory: SdkFactory } | null = null;
91
+
92
+ /** Reads the installed SDK's CommonJS build (a read only; nothing is executed here). */
93
+ export function readInstalledKeeperSdkSource(): string {
94
+ const require = createRequire(import.meta.url);
95
+ let path: string;
96
+ try {
97
+ path = require.resolve(KEEPER_SDK_PACKAGE);
98
+ } catch {
99
+ throw new VaultProviderError(
100
+ "provider_unavailable",
101
+ `${KEEPER_SDK_PACKAGE} ${KEEPER_SDK_VERSION} is not installed on this Opengeni server`,
102
+ );
103
+ }
104
+ return readFileSync(path, "utf8");
105
+ }
106
+
107
+ /**
108
+ * Compiles the pinned SDK once. The source must match the 17.6.0 build byte
109
+ * for byte; any other version is refused rather than run with unknown
110
+ * semantics.
111
+ */
112
+ function factoryFor(source: string, expectedSha256: string): SdkFactory {
113
+ const sha256 = nodeCrypto.createHash("sha256").update(source).digest("hex");
114
+ if (compiled && compiled.sha256 === sha256) return compiled.factory;
115
+ if (sha256 !== expectedSha256) {
116
+ throw new VaultProviderError(
117
+ "provider_unavailable",
118
+ `The installed Keeper SDK is not ${KEEPER_SDK_PACKAGE} ${KEEPER_SDK_VERSION}`,
119
+ );
120
+ }
121
+ // eslint-disable-next-line no-new-func -- a fresh module scope per instance is the isolation boundary.
122
+ const factory = new Function("module", "exports", "require", "console", source) as SdkFactory;
123
+ compiled = { sha256, factory };
124
+ return factory;
125
+ }
126
+
127
+ export interface KeeperSdkInstance {
128
+ sdk: KeeperSdk;
129
+ console: CapturedConsole;
130
+ }
131
+
132
+ export interface KeeperSdkLoader {
133
+ /** A new, private SDK module instance (its own key cache and platform). */
134
+ instance(): KeeperSdkInstance;
135
+ }
136
+
137
+ /**
138
+ * The SDK keeps decrypted application, folder and record keys in a
139
+ * module-global cache and reads keys from that cache before storage, so one
140
+ * module instance must never serve two connections. Each operation evaluates
141
+ * the pinned CommonJS build in a fresh scope, which gives it its own cache,
142
+ * platform and storage serialization, and is discarded afterwards.
143
+ */
144
+ export function createKeeperSdkLoader(
145
+ options: { source?: string | (() => string); expectedSha256?: string } = {},
146
+ ): KeeperSdkLoader {
147
+ let factory: SdkFactory | null = null;
148
+ return {
149
+ instance() {
150
+ if (!factory) {
151
+ const source =
152
+ typeof options.source === "function"
153
+ ? options.source()
154
+ : (options.source ?? readInstalledKeeperSdkSource());
155
+ factory = factoryFor(source, options.expectedSha256 ?? KEEPER_SDK_SHA256);
156
+ }
157
+ const captured = new CapturedConsole();
158
+ const module = { exports: {} as Record<string, unknown> };
159
+ factory(module, module.exports, sdkRequire, captured);
160
+ return { sdk: module.exports as unknown as KeeperSdk, console: captured };
161
+ },
162
+ };
163
+ }
164
+
165
+ /** Default transport limits. */
166
+ export const KEEPER_REQUEST_TIMEOUT_MS = 15_000;
167
+ export const KEEPER_MAX_RESPONSE_BYTES = 32 * 1024 * 1024;
168
+
169
+ async function readBounded(response: Response, maxBytes: number): Promise<Uint8Array> {
170
+ const declared = Number(response.headers.get("content-length") ?? "NaN");
171
+ if (Number.isFinite(declared) && declared > maxBytes) {
172
+ await response.body?.cancel().catch(() => {});
173
+ throw new VaultProviderError(
174
+ "provider_unavailable",
175
+ "Keeper returned more data than Opengeni reads",
176
+ );
177
+ }
178
+ if (!response.body) return new Uint8Array();
179
+ const reader = response.body.getReader();
180
+ const chunks: Uint8Array[] = [];
181
+ let total = 0;
182
+ for (;;) {
183
+ const { done, value } = await reader.read();
184
+ if (done) break;
185
+ total += value.byteLength;
186
+ if (total > maxBytes) {
187
+ await reader.cancel().catch(() => {});
188
+ throw new VaultProviderError(
189
+ "provider_unavailable",
190
+ "Keeper returned more data than Opengeni reads",
191
+ );
192
+ }
193
+ chunks.push(value);
194
+ }
195
+ const out = new Uint8Array(total);
196
+ let offset = 0;
197
+ for (const chunk of chunks) {
198
+ out.set(chunk, offset);
199
+ offset += chunk.byteLength;
200
+ }
201
+ return out;
202
+ }
203
+
204
+ function withDeadline(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
205
+ const timeout = AbortSignal.timeout(timeoutMs);
206
+ return signal ? AbortSignal.any([signal, timeout]) : timeout;
207
+ }
208
+
209
+ /**
210
+ * The `fetch` the adapter's transports call. Servers pass a DNS-pinned fetch
211
+ * (for example `pinnedFetch` from `@opengeni/network`); the adapter still sets
212
+ * the method, headers, body, abort signal and `redirect: "error"`, and refuses
213
+ * any redirect status a manual-redirect fetch returns.
214
+ */
215
+ export type KeeperFetch = (input: string | URL, init: RequestInit) => Promise<Response>;
216
+
217
+ async function refuseRedirect(response: Response): Promise<void> {
218
+ if (response.status >= 300 && response.status < 400) {
219
+ await response.body?.cancel().catch(() => {});
220
+ throw new VaultProviderError("provider_unavailable", "Keeper answered with a redirect");
221
+ }
222
+ }
223
+
224
+ /**
225
+ * The SDK's request function, reimplemented over `fetch` so requests follow no
226
+ * redirects, honour the caller's abort signal, and read bounded bodies. Headers
227
+ * match the SDK's `postFunction` (PublicKeyId, TransmissionKey, Authorization).
228
+ */
229
+ export function createKeeperFetchTransport(
230
+ options: { fetch?: KeeperFetch; maxResponseBytes?: number; signal?: AbortSignal } = {},
231
+ ): KeeperQueryFunction {
232
+ const fetchImpl: KeeperFetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
233
+ const maxBytes = options.maxResponseBytes ?? KEEPER_MAX_RESPONSE_BYTES;
234
+ return async (url, transmissionKey, payload, _allowUnverifiedCertificate, timeoutMs) => {
235
+ const response = await fetchImpl(url, {
236
+ method: "POST",
237
+ headers: {
238
+ "Content-Type": "application/octet-stream",
239
+ PublicKeyId: String(transmissionKey.publicKeyId),
240
+ TransmissionKey: Buffer.from(transmissionKey.encryptedKey).toString("base64"),
241
+ Authorization: `Signature ${Buffer.from(payload.signature).toString("base64")}`,
242
+ "User-Agent": `Opengeni (KSM JS ${KEEPER_SDK_VERSION})`,
243
+ },
244
+ body: payload.payload as Uint8Array<ArrayBuffer>,
245
+ redirect: "error",
246
+ signal: withDeadline(options.signal, timeoutMs ?? KEEPER_REQUEST_TIMEOUT_MS),
247
+ });
248
+ await refuseRedirect(response);
249
+ const data = await readBounded(response, maxBytes);
250
+ return { statusCode: response.status, headers: Object.fromEntries(response.headers), data };
251
+ };
252
+ }
253
+
254
+ /** Downloads an encrypted file body from a Keeper-issued URL, bounded in size and time. */
255
+ export type KeeperFileFetcher = (
256
+ url: string,
257
+ options: { signal?: AbortSignal; maxBytes: number; timeoutMs: number },
258
+ ) => Promise<Uint8Array>;
259
+
260
+ export function createKeeperFileFetcher(
261
+ fetchImpl: KeeperFetch = (input, init) => globalThis.fetch(input, init),
262
+ ): KeeperFileFetcher {
263
+ return async (url, options) => {
264
+ const parsed = new URL(url);
265
+ if (parsed.protocol !== "https:") {
266
+ throw new VaultProviderError("provider_unavailable", "Keeper returned a non-HTTPS file URL");
267
+ }
268
+ const response = await fetchImpl(parsed, {
269
+ redirect: "error",
270
+ signal: withDeadline(options.signal, options.timeoutMs),
271
+ });
272
+ await refuseRedirect(response);
273
+ if (!response.ok) {
274
+ await response.body?.cancel().catch(() => {});
275
+ throw new VaultProviderError(
276
+ "provider_unavailable",
277
+ `Keeper file storage returned HTTP ${response.status}`,
278
+ );
279
+ }
280
+ return readBounded(response, options.maxBytes);
281
+ };
282
+ }