@opengeni/vaults-keeper 0.0.0-stage → 3.2.2
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/LICENSE +190 -0
- package/README.md +147 -2
- package/dist/bootstrap.d.ts +40 -0
- package/dist/config.d.ts +41 -0
- package/dist/connection.d.ts +37 -0
- package/dist/contract.d.ts +6 -0
- package/dist/errors.d.ts +36 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +1450 -0
- package/dist/index.js.map +1 -0
- package/dist/mapping.d.ts +59 -0
- package/dist/naming.d.ts +8 -0
- package/dist/operations.d.ts +7 -0
- package/dist/provider.d.ts +81 -0
- package/dist/sdk.d.ts +81 -0
- package/package.json +42 -4
- package/src/bootstrap.ts +182 -0
- package/src/config.ts +161 -0
- package/src/connection.ts +75 -0
- package/src/contract.ts +29 -0
- package/src/errors.ts +196 -0
- package/src/index.ts +45 -0
- package/src/mapping.ts +373 -0
- package/src/naming.ts +44 -0
- package/src/operations.ts +65 -0
- package/src/provider.ts +681 -0
- package/src/sdk.ts +282 -0
package/src/bootstrap.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { VaultProviderError } from "./contract";
|
|
2
|
+
import {
|
|
3
|
+
KEEPER_DEFAULT_ALLOWED_HOSTS,
|
|
4
|
+
KEEPER_REGION_HOSTS,
|
|
5
|
+
assertAllowedHost,
|
|
6
|
+
memoryKeeperStorage,
|
|
7
|
+
parseKeeperConfiguration,
|
|
8
|
+
serializeKeeperConfiguration,
|
|
9
|
+
} from "./config";
|
|
10
|
+
import { mapKeeperMutationError } from "./errors";
|
|
11
|
+
import {
|
|
12
|
+
type KeeperRuntimeOptions,
|
|
13
|
+
type KeeperVaultProvider,
|
|
14
|
+
type PersistKeeperConfiguration,
|
|
15
|
+
createKeeperVaultProvider,
|
|
16
|
+
runKeeperOperation,
|
|
17
|
+
} from "./provider";
|
|
18
|
+
import { createKeeperSdkLoader } from "./sdk";
|
|
19
|
+
|
|
20
|
+
export interface KeeperBootstrapOptions extends KeeperRuntimeOptions {
|
|
21
|
+
/** One-time access token, `REGION:key` (for example `US:…`), or a bare key with `hostname`. */
|
|
22
|
+
oneTimeToken: string;
|
|
23
|
+
/** Region host for a token without a region prefix. */
|
|
24
|
+
hostname?: string;
|
|
25
|
+
signal?: AbortSignal;
|
|
26
|
+
/**
|
|
27
|
+
* Awaited with the bound configuration (`previous: null`, `reason: "bind"`)
|
|
28
|
+
* as soon as Keeper has accepted the token, before bootstrap returns or
|
|
29
|
+
* reports a later failure. The token is spent at that point, so this is the
|
|
30
|
+
* only copy of the credential.
|
|
31
|
+
*/
|
|
32
|
+
persistConfiguration?: PersistKeeperConfiguration;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface KeeperBootstrapResult {
|
|
36
|
+
/** Bound KSM configuration (JSON). Store it encrypted; it is the connection's credential. */
|
|
37
|
+
configuration: string;
|
|
38
|
+
/** The Secrets Manager application's title. */
|
|
39
|
+
accountName: string | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const TOKEN_PART = /^[A-Za-z0-9_-]{16,128}$/;
|
|
43
|
+
|
|
44
|
+
function invalidToken(): never {
|
|
45
|
+
throw new VaultProviderError(
|
|
46
|
+
"provider_auth_invalid",
|
|
47
|
+
"That is not a Keeper Secrets Manager one-time access token",
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The region host a token names, checked against the allowlist before any network call. */
|
|
52
|
+
export function keeperTokenHost(oneTimeToken: string, hostname?: string): string {
|
|
53
|
+
const parts = oneTimeToken.trim().split(":");
|
|
54
|
+
if (parts.length === 1) {
|
|
55
|
+
if (!TOKEN_PART.test(parts[0]!)) invalidToken();
|
|
56
|
+
if (!hostname) {
|
|
57
|
+
throw new VaultProviderError(
|
|
58
|
+
"provider_auth_invalid",
|
|
59
|
+
"This one-time token has no region prefix; choose the Keeper region",
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return hostname;
|
|
63
|
+
}
|
|
64
|
+
if (parts.length !== 2 && !(parts.length === 4 && parts[0]!.toUpperCase() === "IL5"))
|
|
65
|
+
invalidToken();
|
|
66
|
+
if (!TOKEN_PART.test(parts[1]!)) invalidToken();
|
|
67
|
+
const region = parts[0]!.toUpperCase() as keyof typeof KEEPER_REGION_HOSTS;
|
|
68
|
+
return KEEPER_REGION_HOSTS[region] ?? parts[0]!;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Redeems a one-time access token once: generates the client key pair in
|
|
73
|
+
* memory, binds it with the first `get_secret` call, and returns the bound
|
|
74
|
+
* configuration. The token is never retried; an ambiguous failure means the
|
|
75
|
+
* customer must generate a new token.
|
|
76
|
+
*/
|
|
77
|
+
export async function bootstrapKeeperConfiguration(
|
|
78
|
+
options: KeeperBootstrapOptions,
|
|
79
|
+
): Promise<KeeperBootstrapResult> {
|
|
80
|
+
const token = options.oneTimeToken.trim();
|
|
81
|
+
const allowed = (options.allowedHosts ?? KEEPER_DEFAULT_ALLOWED_HOSTS).map((host) =>
|
|
82
|
+
host.toLowerCase(),
|
|
83
|
+
);
|
|
84
|
+
assertAllowedHost(keeperTokenHost(token, options.hostname), allowed);
|
|
85
|
+
const loader = options.sdk ?? createKeeperSdkLoader();
|
|
86
|
+
let tracker: { sent: number; lastStatus: number | null; mutating: boolean } | null = null;
|
|
87
|
+
let bindStorage: ReturnType<typeof memoryKeeperStorage> | null = null;
|
|
88
|
+
let accountName: string | null = null;
|
|
89
|
+
let failure: unknown = null;
|
|
90
|
+
try {
|
|
91
|
+
await runKeeperOperation({
|
|
92
|
+
runtime: options,
|
|
93
|
+
loader,
|
|
94
|
+
config: new Map(),
|
|
95
|
+
hostInConfig: false,
|
|
96
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
97
|
+
work: async (keeper, storage) => {
|
|
98
|
+
tracker = keeper.tracker;
|
|
99
|
+
bindStorage = storage;
|
|
100
|
+
await keeper.sdk.initializeStorage(storage, token, options.hostname);
|
|
101
|
+
assertAllowedHost(storage.values.get("hostname"), allowed);
|
|
102
|
+
keeper.tracker.mutating = true;
|
|
103
|
+
const secrets = await keeper.sdk.getSecrets2(keeper.options);
|
|
104
|
+
keeper.tracker.mutating = false;
|
|
105
|
+
const title = secrets.appData?.title;
|
|
106
|
+
accountName = typeof title === "string" && title.trim() ? title.trim().slice(0, 200) : null;
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
} catch (error) {
|
|
110
|
+
failure = error;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Keeper binds the key pair when it answers the first request. Once the
|
|
114
|
+
// SDK has unwrapped the app key the token is spent, so the configuration is
|
|
115
|
+
// kept (and persisted) even if something after the bind failed.
|
|
116
|
+
const bound = bindStorage as ReturnType<typeof memoryKeeperStorage> | null;
|
|
117
|
+
if (bound?.values.get("appKey")) {
|
|
118
|
+
bound.values.delete("clientKey");
|
|
119
|
+
const configuration = serializeKeeperConfiguration(bound.values);
|
|
120
|
+
parseKeeperConfiguration(configuration);
|
|
121
|
+
if (options.persistConfiguration) {
|
|
122
|
+
try {
|
|
123
|
+
await options.persistConfiguration(configuration, { previous: null, reason: "bind" });
|
|
124
|
+
} catch {
|
|
125
|
+
throw new VaultProviderError(
|
|
126
|
+
"provider_unavailable",
|
|
127
|
+
"Keeper accepted the one-time token but Opengeni could not save the connection; generate a new token and connect again",
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return { configuration, accountName };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
if (failure === null) {
|
|
136
|
+
throw new VaultProviderError(
|
|
137
|
+
"provider_outcome_unknown",
|
|
138
|
+
"Keeper did not complete binding; generate a new one-time token",
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
throw failure;
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if (error instanceof VaultProviderError && error.code === "provider_outcome_unknown")
|
|
144
|
+
throw error;
|
|
145
|
+
const used = tracker as { sent: number; lastStatus: number | null; mutating: boolean } | null;
|
|
146
|
+
const mapped = used
|
|
147
|
+
? mapKeeperMutationError(error, "redeem the one-time token", used)
|
|
148
|
+
: error instanceof VaultProviderError
|
|
149
|
+
? error
|
|
150
|
+
: new VaultProviderError(
|
|
151
|
+
"provider_auth_invalid",
|
|
152
|
+
"Keeper could not use that one-time token",
|
|
153
|
+
);
|
|
154
|
+
if (mapped.code === "provider_outcome_unknown") {
|
|
155
|
+
throw new VaultProviderError(
|
|
156
|
+
"provider_outcome_unknown",
|
|
157
|
+
"Keeper may have redeemed the one-time token; generate a new one and connect again",
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
if (mapped.code === "provider_auth_invalid" || mapped.code === "provider_permission_denied") {
|
|
161
|
+
throw new VaultProviderError(
|
|
162
|
+
"provider_auth_invalid",
|
|
163
|
+
"Keeper rejected the one-time token (expired, already used, or IP-locked); generate a new one",
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
throw mapped;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Bootstrap, then a provider over the bound configuration. The configuration
|
|
172
|
+
* is persisted through `persistConfiguration` (reason `"bind"`) before this
|
|
173
|
+
* returns and before the provider makes any request.
|
|
174
|
+
*/
|
|
175
|
+
export async function bootstrapKeeperVaultProvider(
|
|
176
|
+
options: KeeperBootstrapOptions & { persistConfiguration: PersistKeeperConfiguration },
|
|
177
|
+
): Promise<KeeperBootstrapResult & { provider: KeeperVaultProvider }> {
|
|
178
|
+
const { oneTimeToken: _token, hostname: _hostname, signal: _signal, ...runtime } = options;
|
|
179
|
+
const result = await bootstrapKeeperConfiguration(options);
|
|
180
|
+
const provider = createKeeperVaultProvider({ ...runtime, configuration: result.configuration });
|
|
181
|
+
return { ...result, provider };
|
|
182
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { VaultProviderError } from "./contract";
|
|
2
|
+
import type { KeyValueStorage } from "./sdk";
|
|
3
|
+
|
|
4
|
+
/** Region prefixes of a one-time token (`US:…`), as mapped by SDK 17.6.0 `initializeStorage`. */
|
|
5
|
+
export const KEEPER_REGION_HOSTS = {
|
|
6
|
+
US: "keepersecurity.com",
|
|
7
|
+
EU: "keepersecurity.eu",
|
|
8
|
+
AU: "keepersecurity.com.au",
|
|
9
|
+
GOV: "govcloud.keepersecurity.us",
|
|
10
|
+
JP: "keepersecurity.jp",
|
|
11
|
+
CA: "keepersecurity.ca",
|
|
12
|
+
IL5: "il5.keepersecurity.us",
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Hosts a configuration may talk to. The SDK treats an unknown token prefix
|
|
17
|
+
* as a host name, so without this list a crafted token or configuration could
|
|
18
|
+
* point Opengeni's server at an arbitrary host.
|
|
19
|
+
*/
|
|
20
|
+
export const KEEPER_DEFAULT_ALLOWED_HOSTS: readonly string[] = Object.values(KEEPER_REGION_HOSTS);
|
|
21
|
+
|
|
22
|
+
/** Keys of the standard KSM JSON configuration. Bytes are standard base64. */
|
|
23
|
+
export const KEEPER_CONFIG_KEYS = [
|
|
24
|
+
"hostname",
|
|
25
|
+
"clientId",
|
|
26
|
+
"clientKey",
|
|
27
|
+
"privateKey",
|
|
28
|
+
"appKey",
|
|
29
|
+
"appOwnerPublicKey",
|
|
30
|
+
"serverPublicKeyId",
|
|
31
|
+
"serverPublicKey",
|
|
32
|
+
] as const;
|
|
33
|
+
export type KeeperConfigKey = (typeof KEEPER_CONFIG_KEYS)[number];
|
|
34
|
+
const KNOWN = new Set<string>(KEEPER_CONFIG_KEYS);
|
|
35
|
+
|
|
36
|
+
export type KeeperConfigValues = Map<KeeperConfigKey, string>;
|
|
37
|
+
|
|
38
|
+
function invalid(message: string): never {
|
|
39
|
+
throw new VaultProviderError("provider_auth_invalid", message);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Parses a bound KSM configuration (JSON, or base64 of JSON as Keeper tools
|
|
44
|
+
* export it, or the already parsed string record). Unknown keys are dropped;
|
|
45
|
+
* the client must already be bound (appKey present).
|
|
46
|
+
*/
|
|
47
|
+
export function parseKeeperConfiguration(
|
|
48
|
+
input: string | Readonly<Record<string, string>>,
|
|
49
|
+
): KeeperConfigValues {
|
|
50
|
+
if (typeof input !== "string") {
|
|
51
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
52
|
+
return invalid("The Keeper configuration is not KSM JSON");
|
|
53
|
+
}
|
|
54
|
+
return valuesOf(input);
|
|
55
|
+
}
|
|
56
|
+
let text = input.trim();
|
|
57
|
+
if (!text.startsWith("{")) {
|
|
58
|
+
try {
|
|
59
|
+
text = Buffer.from(text, "base64").toString("utf8").trim();
|
|
60
|
+
} catch {
|
|
61
|
+
invalid("The Keeper configuration is not KSM JSON");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
let parsed: unknown;
|
|
65
|
+
try {
|
|
66
|
+
parsed = JSON.parse(text);
|
|
67
|
+
} catch {
|
|
68
|
+
return invalid("The Keeper configuration is not KSM JSON");
|
|
69
|
+
}
|
|
70
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
71
|
+
return invalid("The Keeper configuration is not KSM JSON");
|
|
72
|
+
}
|
|
73
|
+
return valuesOf(parsed as Record<string, unknown>);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function valuesOf(parsed: Readonly<Record<string, unknown>>): KeeperConfigValues {
|
|
77
|
+
const values: KeeperConfigValues = new Map();
|
|
78
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
79
|
+
if (!KNOWN.has(key)) continue;
|
|
80
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 4096) {
|
|
81
|
+
invalid(`The Keeper configuration has an invalid ${key}`);
|
|
82
|
+
}
|
|
83
|
+
values.set(key as KeeperConfigKey, value);
|
|
84
|
+
}
|
|
85
|
+
for (const key of ["hostname", "clientId", "privateKey", "appKey"] as const) {
|
|
86
|
+
if (!values.get(key)) {
|
|
87
|
+
invalid(
|
|
88
|
+
key === "appKey"
|
|
89
|
+
? "The Keeper configuration is not bound yet; connect with a one-time token"
|
|
90
|
+
: `The Keeper configuration has no ${key}`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return values;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The canonical JSON form (sorted known keys) handed to the persistence callback. */
|
|
98
|
+
export function serializeKeeperConfiguration(values: KeeperConfigValues): string {
|
|
99
|
+
const out: Record<string, string> = {};
|
|
100
|
+
for (const key of KEEPER_CONFIG_KEYS) {
|
|
101
|
+
const value = values.get(key);
|
|
102
|
+
if (value !== undefined) out[key] = value;
|
|
103
|
+
}
|
|
104
|
+
return JSON.stringify(out);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The canonical string record of a bound configuration, for structured storage. */
|
|
108
|
+
export function keeperConfigurationRecord(
|
|
109
|
+
configuration: string | Readonly<Record<string, string>>,
|
|
110
|
+
): Record<string, string> {
|
|
111
|
+
return JSON.parse(
|
|
112
|
+
serializeKeeperConfiguration(parseKeeperConfiguration(configuration)),
|
|
113
|
+
) as Record<string, string>;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function assertAllowedHost(
|
|
117
|
+
hostname: string | undefined,
|
|
118
|
+
allowed: readonly string[],
|
|
119
|
+
): string {
|
|
120
|
+
const host = (hostname ?? "").trim().toLowerCase();
|
|
121
|
+
if (!host || !allowed.includes(host)) {
|
|
122
|
+
throw new VaultProviderError(
|
|
123
|
+
"provider_auth_invalid",
|
|
124
|
+
"The Keeper region host is not one Opengeni connects to",
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
return host;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* In-memory KSM storage over a copy of the configuration. Nothing reaches
|
|
132
|
+
* disk; `changed` reports whether the SDK rewrote any persisted key (binding,
|
|
133
|
+
* server key rotation) so the caller can persist the new configuration.
|
|
134
|
+
*/
|
|
135
|
+
export function memoryKeeperStorage(initial: KeeperConfigValues = new Map()): KeyValueStorage & {
|
|
136
|
+
readonly values: KeeperConfigValues;
|
|
137
|
+
changed(): boolean;
|
|
138
|
+
} {
|
|
139
|
+
const values: KeeperConfigValues = new Map(initial);
|
|
140
|
+
const before = serializeKeeperConfiguration(initial);
|
|
141
|
+
const set = (key: string, value: string) => {
|
|
142
|
+
// Session keys (folder, record and file keys) are never persisted by the
|
|
143
|
+
// SDK; only configuration keys arrive here.
|
|
144
|
+
if (!KNOWN.has(key)) throw new Error(`unexpected Keeper storage key ${key}`);
|
|
145
|
+
values.set(key as KeeperConfigKey, value);
|
|
146
|
+
};
|
|
147
|
+
return {
|
|
148
|
+
values,
|
|
149
|
+
changed: () => serializeKeeperConfiguration(values) !== before,
|
|
150
|
+
getString: async (key) => values.get(key as KeeperConfigKey),
|
|
151
|
+
saveString: async (key, value) => set(key, value),
|
|
152
|
+
getBytes: async (key) => {
|
|
153
|
+
const value = values.get(key as KeeperConfigKey);
|
|
154
|
+
return value === undefined ? undefined : new Uint8Array(Buffer.from(value, "base64"));
|
|
155
|
+
},
|
|
156
|
+
saveBytes: async (key, value) => set(key, Buffer.from(value).toString("base64")),
|
|
157
|
+
delete: async (key) => {
|
|
158
|
+
values.delete(key as KeeperConfigKey);
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { VaultProviderError, type VaultProviderOperations } from "./contract";
|
|
2
|
+
import { bootstrapKeeperConfiguration } from "./bootstrap";
|
|
3
|
+
import { keeperConfigurationRecord } from "./config";
|
|
4
|
+
import {
|
|
5
|
+
type KeeperRuntimeOptions,
|
|
6
|
+
type KeeperVaultProvider,
|
|
7
|
+
createKeeperVaultProvider,
|
|
8
|
+
} from "./provider";
|
|
9
|
+
|
|
10
|
+
/** What a server stores for a Keeper connection: setup input, or the bound configuration. */
|
|
11
|
+
export type KeeperConnectionCredentials =
|
|
12
|
+
| { oneTimeToken: string; hostname?: string }
|
|
13
|
+
| { configuration: string | Readonly<Record<string, string>> };
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Durable, encrypted storage for the bound configuration. Called with
|
|
17
|
+
* `reason: "bind"` when a one-time token is redeemed (before anything else
|
|
18
|
+
* runs) and `reason: "rotation"` when Keeper rotates its key.
|
|
19
|
+
*/
|
|
20
|
+
export type PersistKeeperConfigurationRecord = (
|
|
21
|
+
configuration: Record<string, string>,
|
|
22
|
+
context: { reason: "bind" | "rotation" },
|
|
23
|
+
) => Promise<void>;
|
|
24
|
+
|
|
25
|
+
export interface OpenKeeperConnectionOptions extends KeeperRuntimeOptions {
|
|
26
|
+
credentials: KeeperConnectionCredentials;
|
|
27
|
+
/** Required: without durable storage a redeemed token or a rotated key would be lost. */
|
|
28
|
+
persistConfiguration?: PersistKeeperConfigurationRecord;
|
|
29
|
+
/** Operations verified at setup; the provider narrows to them. */
|
|
30
|
+
operations?: VaultProviderOperations;
|
|
31
|
+
valueCacheMs?: number;
|
|
32
|
+
signal?: AbortSignal;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* One Keeper connection for a server: redeems a one-time token or opens a
|
|
37
|
+
* stored configuration, and returns the provider with the bound configuration
|
|
38
|
+
* as a string record. The one-time token is never part of the result. Each
|
|
39
|
+
* call has its own provider; SDK state is per operation, so connections never
|
|
40
|
+
* share keys.
|
|
41
|
+
*/
|
|
42
|
+
export async function openKeeperConnection(
|
|
43
|
+
options: OpenKeeperConnectionOptions,
|
|
44
|
+
): Promise<{ provider: KeeperVaultProvider; configuration: Record<string, string> }> {
|
|
45
|
+
const persist = options.persistConfiguration;
|
|
46
|
+
if (!persist) {
|
|
47
|
+
throw new VaultProviderError(
|
|
48
|
+
"provider_unavailable",
|
|
49
|
+
"Keeper connections need durable configuration storage on this server",
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
const { credentials, persistConfiguration: _persist, operations, signal, ...runtime } = options;
|
|
53
|
+
let configuration: Record<string, string>;
|
|
54
|
+
if ("oneTimeToken" in credentials) {
|
|
55
|
+
const bound = await bootstrapKeeperConfiguration({
|
|
56
|
+
...runtime,
|
|
57
|
+
oneTimeToken: credentials.oneTimeToken,
|
|
58
|
+
...(credentials.hostname ? { hostname: credentials.hostname } : {}),
|
|
59
|
+
...(signal ? { signal } : {}),
|
|
60
|
+
persistConfiguration: (next, context) =>
|
|
61
|
+
persist(keeperConfigurationRecord(next), { reason: context.reason }),
|
|
62
|
+
});
|
|
63
|
+
configuration = keeperConfigurationRecord(bound.configuration);
|
|
64
|
+
} else {
|
|
65
|
+
configuration = keeperConfigurationRecord(credentials.configuration);
|
|
66
|
+
}
|
|
67
|
+
const provider = createKeeperVaultProvider({
|
|
68
|
+
...runtime,
|
|
69
|
+
configuration,
|
|
70
|
+
...(operations ? { operations } : {}),
|
|
71
|
+
persistConfiguration: (next, context) =>
|
|
72
|
+
persist(keeperConfigurationRecord(next), { reason: context.reason }),
|
|
73
|
+
});
|
|
74
|
+
return { provider, configuration };
|
|
75
|
+
}
|
package/src/contract.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared vault contracts this adapter implements, re-exported for the
|
|
3
|
+
* package's modules so every file names one source.
|
|
4
|
+
*/
|
|
5
|
+
export {
|
|
6
|
+
VaultProviderError,
|
|
7
|
+
type ProviderCallBudget,
|
|
8
|
+
type ProviderCallOperation,
|
|
9
|
+
type ProviderFieldMeta,
|
|
10
|
+
type ProviderFieldValue,
|
|
11
|
+
type ProviderItemMeta,
|
|
12
|
+
type ProviderVault,
|
|
13
|
+
type ProviderWriteField,
|
|
14
|
+
type ProviderWriteItem,
|
|
15
|
+
type VaultProvider,
|
|
16
|
+
type VaultProviderAuthority,
|
|
17
|
+
type VaultProviderCapabilities,
|
|
18
|
+
type VaultProviderContext,
|
|
19
|
+
type VaultProviderErrorCode,
|
|
20
|
+
} from "@opengeni/vaults";
|
|
21
|
+
export {
|
|
22
|
+
VAULT_LIMITS,
|
|
23
|
+
parseTotpInput,
|
|
24
|
+
type SecretMaterial,
|
|
25
|
+
type TotpParams,
|
|
26
|
+
type VaultFieldKind,
|
|
27
|
+
type VaultItemType,
|
|
28
|
+
type VaultProviderOperations,
|
|
29
|
+
} from "@opengeni/contracts";
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { VaultProviderError } from "./contract";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Keeper Secrets Manager errors as SDK 17.6.0 raises them: a non-200 response
|
|
5
|
+
* becomes `new Error(<first 1000 bytes of the body>)`, where the body is JSON
|
|
6
|
+
* such as `{"path":"…","result_code":"access_denied","message":"…"}` (older
|
|
7
|
+
* servers use `error` for the code); an empty error body becomes
|
|
8
|
+
* `unknown ksm error, code <status>`; exhausted throttle retries raise
|
|
9
|
+
* `KeeperThrottleError`; request deadlines raise `KeeperError("Request to …
|
|
10
|
+
* timed out after …ms")`. These shapes come from the SDK source and Keeper's
|
|
11
|
+
* documentation; Keeper does not publish a complete result-code table, so the
|
|
12
|
+
* message patterns below are deliberately conservative.
|
|
13
|
+
*/
|
|
14
|
+
const AUTH_HINT =
|
|
15
|
+
/signature|client ?id|clientid|one[- ]?time|token|expired|ip address|ip lock|locked out|not bound|unbound|device|revoked/i;
|
|
16
|
+
const NOT_FOUND_HINT = /not found|does not exist|no such|unknown (?:record|folder)/i;
|
|
17
|
+
const CONFLICT_HINT = /revision|out of (?:date|sync)|conflict|modified/i;
|
|
18
|
+
|
|
19
|
+
export interface KeeperErrorBody {
|
|
20
|
+
code: string | null;
|
|
21
|
+
message: string;
|
|
22
|
+
retryAfterSeconds: number | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Parses the JSON error body the SDK embeds in its Error message. */
|
|
26
|
+
export function keeperErrorBody(error: unknown): KeeperErrorBody | null {
|
|
27
|
+
if (!(error instanceof Error)) return null;
|
|
28
|
+
const text = error.message.trim();
|
|
29
|
+
if (!text.startsWith("{")) return null;
|
|
30
|
+
try {
|
|
31
|
+
const body = JSON.parse(text) as Record<string, unknown>;
|
|
32
|
+
const code = body.result_code ?? body.error;
|
|
33
|
+
const retry = body.retry_after;
|
|
34
|
+
return {
|
|
35
|
+
code: typeof code === "string" ? code.slice(0, 64) : null,
|
|
36
|
+
message: typeof body.message === "string" ? body.message : "",
|
|
37
|
+
retryAfterSeconds: typeof retry === "number" && retry >= 0 ? retry : null,
|
|
38
|
+
};
|
|
39
|
+
} catch {
|
|
40
|
+
// A body cut at 1000 bytes may not parse; fall back to the code alone.
|
|
41
|
+
const code = /"(?:result_code|error)"\s*:\s*"([a-z_]{1,64})"/.exec(text)?.[1] ?? null;
|
|
42
|
+
return { code, message: "", retryAfterSeconds: null };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function safeCode(code: string | null): string {
|
|
47
|
+
return code && /^[a-z0-9_]{1,64}$/i.test(code) ? code : "error";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function isAbortLike(error: unknown): boolean {
|
|
51
|
+
if (!(error instanceof Error)) return false;
|
|
52
|
+
return (
|
|
53
|
+
error.name === "AbortError" ||
|
|
54
|
+
error.name === "TimeoutError" ||
|
|
55
|
+
/timed out after \d+ms/.test(error.message)
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Maps any failure of a read-side operation. stderr-like SDK text never
|
|
61
|
+
* leaves this function; only a closed result code may appear in messages.
|
|
62
|
+
*/
|
|
63
|
+
export function mapKeeperError(error: unknown, subject: string): VaultProviderError {
|
|
64
|
+
if (error instanceof VaultProviderError) return error;
|
|
65
|
+
if (error instanceof Error && error.name === "KeeperThrottleError") {
|
|
66
|
+
return new VaultProviderError(
|
|
67
|
+
"provider_rate_limited",
|
|
68
|
+
"Keeper is rate limiting this application",
|
|
69
|
+
60_000,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
if (isAbortLike(error)) {
|
|
73
|
+
return new VaultProviderError(
|
|
74
|
+
"provider_unavailable",
|
|
75
|
+
`Keeper did not answer in time (${subject})`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
const body = keeperErrorBody(error);
|
|
79
|
+
if (body) {
|
|
80
|
+
const { code, message } = body;
|
|
81
|
+
if (code === "throttled") {
|
|
82
|
+
return new VaultProviderError(
|
|
83
|
+
"provider_rate_limited",
|
|
84
|
+
"Keeper is rate limiting this application",
|
|
85
|
+
(body.retryAfterSeconds ?? 60) * 1000,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
if (
|
|
89
|
+
code === "access_denied" ||
|
|
90
|
+
code === "invalid_client" ||
|
|
91
|
+
code === "invalid_client_version"
|
|
92
|
+
) {
|
|
93
|
+
if (code !== "access_denied" || AUTH_HINT.test(message)) {
|
|
94
|
+
return new VaultProviderError(
|
|
95
|
+
"provider_auth_invalid",
|
|
96
|
+
"Keeper rejected this application's credentials; reconnect with a new one-time token",
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
if (NOT_FOUND_HINT.test(message)) {
|
|
100
|
+
return new VaultProviderError(
|
|
101
|
+
"provider_not_found",
|
|
102
|
+
"No such Keeper shared folder or record",
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
return new VaultProviderError(
|
|
106
|
+
"provider_permission_denied",
|
|
107
|
+
`The Keeper application may not ${subject}`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
if (
|
|
111
|
+
NOT_FOUND_HINT.test(message) ||
|
|
112
|
+
(code && /not_found|invalid_record|record_uid/.test(code))
|
|
113
|
+
) {
|
|
114
|
+
return new VaultProviderError("provider_not_found", "No such Keeper shared folder or record");
|
|
115
|
+
}
|
|
116
|
+
if (CONFLICT_HINT.test(message) || (code && /revision|conflict/.test(code))) {
|
|
117
|
+
return new VaultProviderError(
|
|
118
|
+
"provider_conflict",
|
|
119
|
+
"The Keeper record changed since it was read; read it again before writing",
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
return new VaultProviderError(
|
|
123
|
+
"provider_unavailable",
|
|
124
|
+
`Keeper rejected the request (${safeCode(code)})`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
const status =
|
|
128
|
+
error instanceof Error ? /unknown ksm error, code (\d{3})/.exec(error.message)?.[1] : undefined;
|
|
129
|
+
if (status) {
|
|
130
|
+
const code = Number(status);
|
|
131
|
+
if (code === 401) {
|
|
132
|
+
return new VaultProviderError(
|
|
133
|
+
"provider_auth_invalid",
|
|
134
|
+
"Keeper rejected this application's credentials",
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
if (code === 403) {
|
|
138
|
+
return new VaultProviderError(
|
|
139
|
+
"provider_permission_denied",
|
|
140
|
+
`The Keeper application may not ${subject}`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
if (code === 404)
|
|
144
|
+
return new VaultProviderError("provider_not_found", "No such Keeper shared folder or record");
|
|
145
|
+
if (code === 429)
|
|
146
|
+
return new VaultProviderError(
|
|
147
|
+
"provider_rate_limited",
|
|
148
|
+
"Keeper is rate limiting this application",
|
|
149
|
+
60_000,
|
|
150
|
+
);
|
|
151
|
+
return new VaultProviderError("provider_unavailable", `Keeper returned HTTP ${code}`);
|
|
152
|
+
}
|
|
153
|
+
return new VaultProviderError("provider_unavailable", `Keeper could not ${subject}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Tracks one mutation's requests so a failure can be classified as definite or unknown. */
|
|
157
|
+
export interface MutationTracker {
|
|
158
|
+
/** Requests started for the mutation itself. */
|
|
159
|
+
sent: number;
|
|
160
|
+
/** Status of the last mutation response, if one arrived. */
|
|
161
|
+
lastStatus: number | null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Statuses with which Keeper refuses a request before applying it: a bad or
|
|
166
|
+
* unauthorised request (400, 401, 403), a missing target (404), a revision
|
|
167
|
+
* conflict (409) or throttling (429). Any other status after the request was
|
|
168
|
+
* sent (5xx from Keeper or a proxy, an unexpected 2xx or 3xx) may follow an
|
|
169
|
+
* applied change.
|
|
170
|
+
*/
|
|
171
|
+
export const KEEPER_DEFINITE_REJECTION_STATUSES: ReadonlySet<number> = new Set([
|
|
172
|
+
400, 401, 403, 404, 409, 429,
|
|
173
|
+
]);
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* A mutation failure is definite only when nothing was sent, or Keeper
|
|
177
|
+
* answered the last request with a known rejection status. A transport
|
|
178
|
+
* failure, deadline or abort after sending, any other status (502 included),
|
|
179
|
+
* or any failure after a 200, leaves the outcome unknown.
|
|
180
|
+
*/
|
|
181
|
+
export function mapKeeperMutationError(
|
|
182
|
+
error: unknown,
|
|
183
|
+
subject: string,
|
|
184
|
+
tracker: MutationTracker,
|
|
185
|
+
): VaultProviderError {
|
|
186
|
+
const definite =
|
|
187
|
+
tracker.sent === 0 ||
|
|
188
|
+
(tracker.lastStatus !== null && KEEPER_DEFINITE_REJECTION_STATUSES.has(tracker.lastStatus));
|
|
189
|
+
if (!definite) {
|
|
190
|
+
return new VaultProviderError(
|
|
191
|
+
"provider_outcome_unknown",
|
|
192
|
+
`Keeper may or may not have completed "${subject}"; check the record before retrying`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
return mapKeeperError(error, subject);
|
|
196
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export {
|
|
2
|
+
KEEPER_OPERATIONS,
|
|
3
|
+
KEEPER_VALUE_CACHE_MS,
|
|
4
|
+
createKeeperVaultProvider,
|
|
5
|
+
type KeeperRuntimeOptions,
|
|
6
|
+
type KeeperVaultProvider,
|
|
7
|
+
type KeeperVaultProviderOptions,
|
|
8
|
+
type PersistKeeperConfiguration,
|
|
9
|
+
} from "./provider";
|
|
10
|
+
export {
|
|
11
|
+
bootstrapKeeperConfiguration,
|
|
12
|
+
bootstrapKeeperVaultProvider,
|
|
13
|
+
keeperTokenHost,
|
|
14
|
+
type KeeperBootstrapOptions,
|
|
15
|
+
type KeeperBootstrapResult,
|
|
16
|
+
} from "./bootstrap";
|
|
17
|
+
export {
|
|
18
|
+
KEEPER_CONFIG_KEYS,
|
|
19
|
+
KEEPER_DEFAULT_ALLOWED_HOSTS,
|
|
20
|
+
KEEPER_REGION_HOSTS,
|
|
21
|
+
keeperConfigurationRecord,
|
|
22
|
+
parseKeeperConfiguration,
|
|
23
|
+
serializeKeeperConfiguration,
|
|
24
|
+
} from "./config";
|
|
25
|
+
export {
|
|
26
|
+
KEEPER_MAX_RESPONSE_BYTES,
|
|
27
|
+
KEEPER_REQUEST_TIMEOUT_MS,
|
|
28
|
+
KEEPER_SDK_PACKAGE,
|
|
29
|
+
KEEPER_SDK_SHA256,
|
|
30
|
+
KEEPER_SDK_VERSION,
|
|
31
|
+
createKeeperSdkLoader,
|
|
32
|
+
type KeeperFetch,
|
|
33
|
+
type KeeperFileFetcher,
|
|
34
|
+
type KeeperQueryFunction,
|
|
35
|
+
type KeeperSdkLoader,
|
|
36
|
+
} from "./sdk";
|
|
37
|
+
export { FILE_FIELD_PREFIX, NOTES_FIELD_ID } from "./mapping";
|
|
38
|
+
export {
|
|
39
|
+
openKeeperConnection,
|
|
40
|
+
type KeeperConnectionCredentials,
|
|
41
|
+
type OpenKeeperConnectionOptions,
|
|
42
|
+
type PersistKeeperConfigurationRecord,
|
|
43
|
+
} from "./connection";
|
|
44
|
+
export { restrictToOperations } from "./operations";
|
|
45
|
+
export { KEEPER_DEFINITE_REJECTION_STATUSES } from "./errors";
|