@opengeni/vaults 3.1.2 → 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/README.md +14 -0
- package/dist/{chunk-7S23LHTK.js → chunk-BD43JBPC.js} +9 -2
- package/dist/chunk-BD43JBPC.js.map +1 -0
- package/dist/{chunk-VKPGG2FW.js → chunk-BEKN77JO.js} +2 -2
- package/dist/index.d.ts +3 -0
- package/dist/index.js +736 -2
- package/dist/index.js.map +1 -1
- package/dist/provider-capabilities.d.ts +4 -0
- package/dist/provider-pages.d.ts +56 -0
- package/dist/provider.d.ts +59 -3
- package/dist/remote-provider.d.ts +117 -0
- package/dist/testing/fake-provider.js +2 -2
- package/dist/testing/index.js +2 -2
- package/package.json +2 -2
- package/src/index.ts +3 -0
- package/src/provider-capabilities.ts +64 -0
- package/src/provider-pages.ts +206 -0
- package/src/provider.ts +84 -2
- package/src/remote-provider.ts +784 -0
- package/dist/chunk-7S23LHTK.js.map +0 -1
- /package/dist/{chunk-VKPGG2FW.js.map → chunk-BEKN77JO.js.map} +0 -0
|
@@ -0,0 +1,784 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic remote vault provider over the signed `opengeni.vault.v1` protocol.
|
|
3
|
+
*
|
|
4
|
+
* The HTTP transport is injected: the API and worker supply their DNS-pinned,
|
|
5
|
+
* HTTPS-only, no-redirect egress. This adapter only signs exact request
|
|
6
|
+
* bytes, enforces the response byte cap again, verifies the response MAC and
|
|
7
|
+
* strictly parses the operation result. It never retries a mutation: any
|
|
8
|
+
* response that cannot be verified after a write may have been applied and is
|
|
9
|
+
* reported as `provider_outcome_unknown`.
|
|
10
|
+
*/
|
|
11
|
+
import {
|
|
12
|
+
VAULT_LIMITS,
|
|
13
|
+
VAULT_PROVIDER_WIRE_LIMITS,
|
|
14
|
+
VAULT_PROVIDER_WIRE_RESPONSE_SIGNATURE_HEADER,
|
|
15
|
+
VAULT_PROVIDER_WIRE_VERSION,
|
|
16
|
+
VaultProviderWireRequest,
|
|
17
|
+
parseTotpInput,
|
|
18
|
+
parseVaultProviderWireResponse,
|
|
19
|
+
vaultProviderWireResponseKey,
|
|
20
|
+
vaultProviderWireUtf8Bytes,
|
|
21
|
+
type SecretMaterial,
|
|
22
|
+
type VaultProviderFreshness,
|
|
23
|
+
type VaultProviderOperations,
|
|
24
|
+
type VaultProviderWireFreshness,
|
|
25
|
+
type VaultProviderWireItemMeta,
|
|
26
|
+
type VaultProviderWireOperation,
|
|
27
|
+
type VaultProviderWireOperationName,
|
|
28
|
+
type VaultProviderWireResult,
|
|
29
|
+
type VaultProviderWireVault,
|
|
30
|
+
type VaultProviderWireWriteField,
|
|
31
|
+
} from "@opengeni/contracts";
|
|
32
|
+
import {
|
|
33
|
+
OPENGENI_SIGNATURE_HEADER,
|
|
34
|
+
signOpenGeniPayload,
|
|
35
|
+
verifyOpenGeniSignature,
|
|
36
|
+
} from "@opengeni/contracts/workspace-integration-wire";
|
|
37
|
+
import {
|
|
38
|
+
VaultProviderError,
|
|
39
|
+
type ProviderCallOperation,
|
|
40
|
+
type ProviderFieldValue,
|
|
41
|
+
type ProviderItemMeta,
|
|
42
|
+
type ProviderPage,
|
|
43
|
+
type ProviderPageRequest,
|
|
44
|
+
type ProviderVault,
|
|
45
|
+
type ProviderWriteItem,
|
|
46
|
+
type VaultProvider,
|
|
47
|
+
type VaultProviderCapabilities,
|
|
48
|
+
type VaultProviderContext,
|
|
49
|
+
type VaultProviderErrorCode,
|
|
50
|
+
} from "./provider";
|
|
51
|
+
import { collectProviderPages } from "./provider-pages";
|
|
52
|
+
|
|
53
|
+
export type RemoteVaultProviderKind = "external" | "bitwarden";
|
|
54
|
+
|
|
55
|
+
/** Case-insensitive header lookup, satisfied by `Headers`. */
|
|
56
|
+
export interface RemoteVaultHeaders {
|
|
57
|
+
get(name: string): string | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface RemoteVaultHttpRequest {
|
|
61
|
+
url: string;
|
|
62
|
+
method: "POST";
|
|
63
|
+
headers: Readonly<Record<string, string>>;
|
|
64
|
+
/** Send exactly these bytes; the signature covers them. */
|
|
65
|
+
body: string;
|
|
66
|
+
/** Hard deadline for connect, send and the complete response body. */
|
|
67
|
+
timeoutMs: number;
|
|
68
|
+
/** Fail instead of reading more response body bytes than this. */
|
|
69
|
+
maxResponseBytes: number;
|
|
70
|
+
signal?: AbortSignal;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface RemoteVaultHttpResponse {
|
|
74
|
+
status: number;
|
|
75
|
+
headers: RemoteVaultHeaders;
|
|
76
|
+
body: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Bounded HTTP egress for remote vault calls. Implementations must refuse
|
|
81
|
+
* redirects, enforce `timeoutMs` and `maxResponseBytes`, and apply the
|
|
82
|
+
* deployment's endpoint policy (HTTPS, DNS pinning, private-address refusal).
|
|
83
|
+
*/
|
|
84
|
+
export interface RemoteVaultTransport {
|
|
85
|
+
post(request: RemoteVaultHttpRequest): Promise<RemoteVaultHttpResponse>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type RemoteVaultTransportFailure =
|
|
89
|
+
| "refused"
|
|
90
|
+
| "network"
|
|
91
|
+
| "timeout"
|
|
92
|
+
| "redirect"
|
|
93
|
+
| "too_large"
|
|
94
|
+
| "aborted";
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Transport failure. Set `requestSent: false` only when the transport knows
|
|
98
|
+
* no request bytes left the process (for example a refused DNS policy);
|
|
99
|
+
* otherwise a failed mutation is reported as an unknown outcome.
|
|
100
|
+
*/
|
|
101
|
+
export class RemoteVaultTransportError extends Error {
|
|
102
|
+
readonly failure: RemoteVaultTransportFailure;
|
|
103
|
+
readonly requestSent: boolean;
|
|
104
|
+
constructor(failure: RemoteVaultTransportFailure, requestSent: boolean) {
|
|
105
|
+
super(`remote vault transport failed: ${failure}`);
|
|
106
|
+
this.name = "RemoteVaultTransportError";
|
|
107
|
+
this.failure = failure;
|
|
108
|
+
this.requestSent = requestSent;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export type RemoteVaultFetch = (
|
|
113
|
+
url: string,
|
|
114
|
+
init: {
|
|
115
|
+
method: "POST";
|
|
116
|
+
headers: Record<string, string>;
|
|
117
|
+
body: string;
|
|
118
|
+
redirect: "manual";
|
|
119
|
+
signal: AbortSignal;
|
|
120
|
+
},
|
|
121
|
+
) => Promise<{
|
|
122
|
+
status: number;
|
|
123
|
+
type?: string;
|
|
124
|
+
headers: RemoteVaultHeaders;
|
|
125
|
+
body: ReadableStream<Uint8Array> | null;
|
|
126
|
+
}>;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Adapts a fetch-compatible function (for example the deployment's pinned
|
|
130
|
+
* fetch) to the bounded transport: manual redirects are refused, the whole
|
|
131
|
+
* exchange shares one deadline and the body is read with a byte cap.
|
|
132
|
+
*/
|
|
133
|
+
export function createFetchRemoteVaultTransport(fetchImpl: RemoteVaultFetch): RemoteVaultTransport {
|
|
134
|
+
return {
|
|
135
|
+
async post(request) {
|
|
136
|
+
const controller = new AbortController();
|
|
137
|
+
let failure: RemoteVaultTransportFailure | null = null;
|
|
138
|
+
const timer = setTimeout(() => {
|
|
139
|
+
failure = "timeout";
|
|
140
|
+
controller.abort();
|
|
141
|
+
}, request.timeoutMs);
|
|
142
|
+
const onAbort = () => {
|
|
143
|
+
failure ??= "aborted";
|
|
144
|
+
controller.abort();
|
|
145
|
+
};
|
|
146
|
+
request.signal?.addEventListener("abort", onAbort, { once: true });
|
|
147
|
+
if (request.signal?.aborted) onAbort();
|
|
148
|
+
try {
|
|
149
|
+
let response: Awaited<ReturnType<RemoteVaultFetch>>;
|
|
150
|
+
try {
|
|
151
|
+
response = await fetchImpl(request.url, {
|
|
152
|
+
method: "POST",
|
|
153
|
+
headers: { ...request.headers },
|
|
154
|
+
body: request.body,
|
|
155
|
+
redirect: "manual",
|
|
156
|
+
signal: controller.signal,
|
|
157
|
+
});
|
|
158
|
+
} catch {
|
|
159
|
+
throw new RemoteVaultTransportError(failure ?? "network", true);
|
|
160
|
+
}
|
|
161
|
+
if (
|
|
162
|
+
response.type === "opaqueredirect" ||
|
|
163
|
+
(response.status >= 300 && response.status < 400)
|
|
164
|
+
) {
|
|
165
|
+
await response.body?.cancel().catch(() => {});
|
|
166
|
+
throw new RemoteVaultTransportError("redirect", true);
|
|
167
|
+
}
|
|
168
|
+
const chunks: Uint8Array[] = [];
|
|
169
|
+
let total = 0;
|
|
170
|
+
const reader = response.body?.getReader();
|
|
171
|
+
if (reader) {
|
|
172
|
+
try {
|
|
173
|
+
for (;;) {
|
|
174
|
+
const { done, value } = await reader.read();
|
|
175
|
+
if (done) break;
|
|
176
|
+
total += value.byteLength;
|
|
177
|
+
if (total > request.maxResponseBytes) {
|
|
178
|
+
await reader.cancel().catch(() => {});
|
|
179
|
+
throw new RemoteVaultTransportError("too_large", true);
|
|
180
|
+
}
|
|
181
|
+
chunks.push(value);
|
|
182
|
+
}
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (error instanceof RemoteVaultTransportError) throw error;
|
|
185
|
+
throw new RemoteVaultTransportError(failure ?? "network", true);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const bytes = new Uint8Array(total);
|
|
189
|
+
let offset = 0;
|
|
190
|
+
for (const chunk of chunks) {
|
|
191
|
+
bytes.set(chunk, offset);
|
|
192
|
+
offset += chunk.byteLength;
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
status: response.status,
|
|
196
|
+
headers: response.headers,
|
|
197
|
+
body: new TextDecoder("utf-8", { fatal: false }).decode(bytes),
|
|
198
|
+
};
|
|
199
|
+
} finally {
|
|
200
|
+
clearTimeout(timer);
|
|
201
|
+
request.signal?.removeEventListener("abort", onAbort);
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export interface RemoteVaultProviderOptions {
|
|
208
|
+
provider: RemoteVaultProviderKind;
|
|
209
|
+
/** Registered endpoint. HTTPS only unless `allowInsecureHttp` (local/test). */
|
|
210
|
+
endpoint: string;
|
|
211
|
+
/** Per-connection secret; signs requests and derives the response key. */
|
|
212
|
+
signingSecret: string;
|
|
213
|
+
transport: RemoteVaultTransport;
|
|
214
|
+
/** The Opengeni connection id; must match `ctx.connectionId` when both exist. */
|
|
215
|
+
connectionId?: string;
|
|
216
|
+
/**
|
|
217
|
+
* Operations verified by the last describe (stored with the connection), so
|
|
218
|
+
* a fresh instance can answer `capabilities()` without a call. Without it,
|
|
219
|
+
* and before `validate`, the provider is read-only.
|
|
220
|
+
*/
|
|
221
|
+
operations?: VaultProviderOperations;
|
|
222
|
+
timeoutMs?: number;
|
|
223
|
+
allowInsecureHttp?: boolean;
|
|
224
|
+
now?: () => number;
|
|
225
|
+
newRequestId?: () => string;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export interface RemoteVaultValidation {
|
|
229
|
+
accountName: string | null;
|
|
230
|
+
accountId: string;
|
|
231
|
+
operations: VaultProviderOperations;
|
|
232
|
+
capabilities: { totp: boolean; files: boolean };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export interface RemoteVaultProvider extends VaultProvider {
|
|
236
|
+
readonly id: RemoteVaultProviderKind;
|
|
237
|
+
validate(ctx: VaultProviderContext): Promise<RemoteVaultValidation>;
|
|
238
|
+
listVaultPage(
|
|
239
|
+
ctx: VaultProviderContext,
|
|
240
|
+
page: ProviderPageRequest,
|
|
241
|
+
): Promise<ProviderPage<ProviderVault>>;
|
|
242
|
+
listItemPage(
|
|
243
|
+
ctx: VaultProviderContext,
|
|
244
|
+
externalVaultId: string,
|
|
245
|
+
page: ProviderPageRequest,
|
|
246
|
+
): Promise<ProviderPage<ProviderItemMeta>>;
|
|
247
|
+
/** Deliberate upstream refresh. Returns the receiver's freshness. */
|
|
248
|
+
refresh(ctx: VaultProviderContext): Promise<VaultProviderFreshness | null>;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Operations a remote connection can never perform through this protocol. */
|
|
252
|
+
export const REMOTE_VAULT_READ_ONLY_OPERATIONS: VaultProviderOperations = Object.freeze({
|
|
253
|
+
createItemTypes: [],
|
|
254
|
+
updateItem: false,
|
|
255
|
+
removeField: false,
|
|
256
|
+
archiveItem: false,
|
|
257
|
+
archiveKind: "none",
|
|
258
|
+
deleteItem: false,
|
|
259
|
+
restoreItem: false,
|
|
260
|
+
readFiles: false,
|
|
261
|
+
writeFiles: false,
|
|
262
|
+
createTotp: false,
|
|
263
|
+
updateTotp: false,
|
|
264
|
+
createVault: false,
|
|
265
|
+
restoreFieldHistory: false,
|
|
266
|
+
}) as VaultProviderOperations;
|
|
267
|
+
|
|
268
|
+
/** Clamp a described operation set to what the v1 wire can express. */
|
|
269
|
+
export function remoteVaultOperations(
|
|
270
|
+
operations: VaultProviderOperations,
|
|
271
|
+
): VaultProviderOperations {
|
|
272
|
+
const createItemTypes = operations.createItemTypes.filter(
|
|
273
|
+
(type) => type !== "passkey" && type !== "file",
|
|
274
|
+
);
|
|
275
|
+
const archiveItem = operations.archiveItem && operations.archiveKind !== "none";
|
|
276
|
+
return {
|
|
277
|
+
createItemTypes,
|
|
278
|
+
updateItem: operations.updateItem,
|
|
279
|
+
removeField: operations.removeField && operations.updateItem,
|
|
280
|
+
archiveItem,
|
|
281
|
+
archiveKind: archiveItem ? operations.archiveKind : "none",
|
|
282
|
+
deleteItem: false,
|
|
283
|
+
restoreItem: false,
|
|
284
|
+
readFiles: operations.readFiles,
|
|
285
|
+
writeFiles: false,
|
|
286
|
+
createTotp: operations.createTotp && createItemTypes.length > 0,
|
|
287
|
+
updateTotp: operations.updateTotp && operations.updateItem,
|
|
288
|
+
createVault: false,
|
|
289
|
+
restoreFieldHistory: false,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const SAFE_MESSAGES: Record<VaultProviderErrorCode, string> = {
|
|
294
|
+
provider_auth_invalid: "The remote vault rejected Opengeni's credentials.",
|
|
295
|
+
provider_auth_expired: "The remote vault needs to be reconnected.",
|
|
296
|
+
provider_rate_limited: "The remote vault is rate limiting requests. Try again later.",
|
|
297
|
+
provider_unavailable: "The remote vault is unavailable.",
|
|
298
|
+
provider_conflict: "The item changed in the remote vault. Reload it and try again.",
|
|
299
|
+
provider_not_found: "The remote vault item or field was not found.",
|
|
300
|
+
provider_permission_denied: "The remote vault does not allow this operation.",
|
|
301
|
+
provider_locked: "The remote vault is locked. Unlock it where it runs, then refresh.",
|
|
302
|
+
provider_inventory_incomplete:
|
|
303
|
+
"The remote vault inventory could not be read completely. The previous inventory was kept.",
|
|
304
|
+
provider_outcome_unknown:
|
|
305
|
+
"The remote vault did not confirm the change. It may or may not have been saved; check the vault before trying again.",
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
function fail(code: VaultProviderErrorCode, retryAfterMs?: number): never {
|
|
309
|
+
throw new VaultProviderError(code, SAFE_MESSAGES[code], retryAfterMs);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
313
|
+
// oxlint-disable-next-line no-control-regex
|
|
314
|
+
const CONTROL = /[\u0000-\u001f\u007f-\u009f]/;
|
|
315
|
+
|
|
316
|
+
function validEndpoint(endpoint: string, allowInsecureHttp: boolean): string {
|
|
317
|
+
let url: URL;
|
|
318
|
+
try {
|
|
319
|
+
url = new URL(endpoint);
|
|
320
|
+
} catch {
|
|
321
|
+
return fail("provider_unavailable");
|
|
322
|
+
}
|
|
323
|
+
const schemeOk = url.protocol === "https:" || (allowInsecureHttp && url.protocol === "http:");
|
|
324
|
+
if (!schemeOk || url.username || url.password || url.hash || endpoint.length > 2_048) {
|
|
325
|
+
fail("provider_unavailable");
|
|
326
|
+
}
|
|
327
|
+
return url.toString();
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function decodeBase64(base64: string): Uint8Array {
|
|
331
|
+
const binary = atob(base64);
|
|
332
|
+
const bytes = new Uint8Array(binary.length);
|
|
333
|
+
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
|
334
|
+
return bytes;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function retryAfterHeader(headers: RemoteVaultHeaders): number | undefined {
|
|
338
|
+
const value = headers.get("retry-after");
|
|
339
|
+
if (!value || !/^\d{1,6}$/.test(value.trim())) return undefined;
|
|
340
|
+
return Math.min(Number(value.trim()) * 1000, 86_400_000);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function toProviderItem(item: VaultProviderWireItemMeta): ProviderItemMeta {
|
|
344
|
+
return {
|
|
345
|
+
externalItemId: item.externalItemId,
|
|
346
|
+
name: item.name,
|
|
347
|
+
type: item.type,
|
|
348
|
+
websites: [...item.websites],
|
|
349
|
+
fields: item.fields.map((field) => ({
|
|
350
|
+
externalFieldId: field.externalFieldId,
|
|
351
|
+
name: field.name,
|
|
352
|
+
kind: field.kind,
|
|
353
|
+
})),
|
|
354
|
+
version: item.version,
|
|
355
|
+
updatedAt: item.updatedAt,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function toProviderVault(vault: VaultProviderWireVault): ProviderVault {
|
|
360
|
+
return {
|
|
361
|
+
externalVaultId: vault.externalVaultId,
|
|
362
|
+
name: vault.name,
|
|
363
|
+
contentVersion: vault.contentVersion,
|
|
364
|
+
itemCount: vault.itemCount,
|
|
365
|
+
writeAccess: vault.writeAccess,
|
|
366
|
+
...(vault.operations ? { operations: remoteVaultOperations(vault.operations) } : {}),
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Create a provider for one registered remote connection. Construction
|
|
372
|
+
* validates the endpoint and secret; no request is sent until a method runs.
|
|
373
|
+
*/
|
|
374
|
+
export function createRemoteVaultProvider(
|
|
375
|
+
options: RemoteVaultProviderOptions,
|
|
376
|
+
): RemoteVaultProvider {
|
|
377
|
+
if (options.provider !== "external" && options.provider !== "bitwarden") {
|
|
378
|
+
fail("provider_unavailable");
|
|
379
|
+
}
|
|
380
|
+
const endpoint = validEndpoint(options.endpoint, options.allowInsecureHttp === true);
|
|
381
|
+
if (
|
|
382
|
+
typeof options.signingSecret !== "string" ||
|
|
383
|
+
options.signingSecret.length < 32 ||
|
|
384
|
+
options.signingSecret.length > 4_096
|
|
385
|
+
) {
|
|
386
|
+
fail("provider_auth_invalid");
|
|
387
|
+
}
|
|
388
|
+
if (options.connectionId !== undefined && !UUID.test(options.connectionId)) {
|
|
389
|
+
fail("provider_unavailable");
|
|
390
|
+
}
|
|
391
|
+
const timeoutMs = Math.min(
|
|
392
|
+
Math.max(Math.trunc(options.timeoutMs ?? VAULT_PROVIDER_WIRE_LIMITS.defaultTimeoutMs), 1_000),
|
|
393
|
+
VAULT_PROVIDER_WIRE_LIMITS.maxTimeoutMs,
|
|
394
|
+
);
|
|
395
|
+
const now = options.now ?? (() => Date.now());
|
|
396
|
+
const newRequestId = options.newRequestId ?? (() => crypto.randomUUID());
|
|
397
|
+
const responseKey = vaultProviderWireResponseKey(options.signingSecret);
|
|
398
|
+
let described: RemoteVaultValidation | null = null;
|
|
399
|
+
|
|
400
|
+
function currentOperations(): VaultProviderOperations {
|
|
401
|
+
if (described) return described.operations;
|
|
402
|
+
if (options.operations) return remoteVaultOperations(options.operations);
|
|
403
|
+
return REMOTE_VAULT_READ_ONLY_OPERATIONS;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function connectionIdFor(ctx: VaultProviderContext): string {
|
|
407
|
+
const fromCtx = ctx.connectionId;
|
|
408
|
+
if (fromCtx && options.connectionId && fromCtx !== options.connectionId) {
|
|
409
|
+
fail("provider_permission_denied");
|
|
410
|
+
}
|
|
411
|
+
const id = fromCtx ?? options.connectionId;
|
|
412
|
+
if (!id || !UUID.test(id)) fail("provider_unavailable");
|
|
413
|
+
return id.toLowerCase();
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function freshnessFrom(wire: VaultProviderWireFreshness | null): VaultProviderFreshness | null {
|
|
417
|
+
if (!wire) return null;
|
|
418
|
+
return {
|
|
419
|
+
state: wire.state,
|
|
420
|
+
lastCheckedAt: new Date(now()).toISOString(),
|
|
421
|
+
sourceSyncedAt: wire.sourceSyncedAt,
|
|
422
|
+
source: wire.source,
|
|
423
|
+
errorCode: wire.errorCode,
|
|
424
|
+
retryAt: wire.retryAt,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async function call<Op extends VaultProviderWireOperationName>(
|
|
429
|
+
ctx: VaultProviderContext,
|
|
430
|
+
budget: ProviderCallOperation,
|
|
431
|
+
operation: Extract<VaultProviderWireOperation, { op: Op }>,
|
|
432
|
+
): Promise<{ result: VaultProviderWireResult<Op>; freshness: VaultProviderFreshness | null }> {
|
|
433
|
+
const mutation = operation.op === "write_item" || operation.op === "archive_item";
|
|
434
|
+
// Never send an unauthorized or unattributed request.
|
|
435
|
+
if (!ctx.authority) fail("provider_permission_denied");
|
|
436
|
+
const connectionId = connectionIdFor(ctx);
|
|
437
|
+
ctx.signal?.throwIfAborted();
|
|
438
|
+
ctx.budget.take(budget);
|
|
439
|
+
const requestId = newRequestId();
|
|
440
|
+
const { accountId, workspaceId, subjectId, connectionWorkspaceId } = ctx.authority;
|
|
441
|
+
const parsed = VaultProviderWireRequest.safeParse({
|
|
442
|
+
version: VAULT_PROVIDER_WIRE_VERSION,
|
|
443
|
+
type: "request",
|
|
444
|
+
requestId,
|
|
445
|
+
sentAt: new Date(now()).toISOString(),
|
|
446
|
+
provider: options.provider,
|
|
447
|
+
connectionId,
|
|
448
|
+
authority: {
|
|
449
|
+
accountId,
|
|
450
|
+
workspaceId,
|
|
451
|
+
subjectId,
|
|
452
|
+
connectionWorkspaceId,
|
|
453
|
+
...(ctx.authority.sessionId ? { sessionId: ctx.authority.sessionId } : {}),
|
|
454
|
+
...(ctx.authority.turnId ? { turnId: ctx.authority.turnId } : {}),
|
|
455
|
+
...(ctx.authority.attemptId ? { attemptId: ctx.authority.attemptId } : {}),
|
|
456
|
+
...(ctx.authority.siteId ? { siteId: ctx.authority.siteId } : {}),
|
|
457
|
+
},
|
|
458
|
+
operation,
|
|
459
|
+
});
|
|
460
|
+
// An unrepresentable request is refused locally; nothing was sent.
|
|
461
|
+
if (!parsed.success) fail("provider_unavailable");
|
|
462
|
+
const body = JSON.stringify(parsed.data);
|
|
463
|
+
if (vaultProviderWireUtf8Bytes(body) > VAULT_PROVIDER_WIRE_LIMITS.maxRequestBytes) {
|
|
464
|
+
fail("provider_unavailable");
|
|
465
|
+
}
|
|
466
|
+
const signature = await signOpenGeniPayload(
|
|
467
|
+
options.signingSecret,
|
|
468
|
+
body,
|
|
469
|
+
Math.floor(now() / 1000),
|
|
470
|
+
);
|
|
471
|
+
const unverified = (code: VaultProviderErrorCode, retryAfterMs?: number): never =>
|
|
472
|
+
mutation ? fail("provider_outcome_unknown") : fail(code, retryAfterMs);
|
|
473
|
+
|
|
474
|
+
let response: RemoteVaultHttpResponse;
|
|
475
|
+
try {
|
|
476
|
+
response = await options.transport.post({
|
|
477
|
+
url: endpoint,
|
|
478
|
+
method: "POST",
|
|
479
|
+
headers: {
|
|
480
|
+
"content-type": "application/json",
|
|
481
|
+
accept: "application/json",
|
|
482
|
+
[OPENGENI_SIGNATURE_HEADER]: signature,
|
|
483
|
+
},
|
|
484
|
+
body,
|
|
485
|
+
timeoutMs,
|
|
486
|
+
maxResponseBytes: VAULT_PROVIDER_WIRE_LIMITS.maxResponseBytes,
|
|
487
|
+
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
488
|
+
});
|
|
489
|
+
} catch (error) {
|
|
490
|
+
if (error instanceof RemoteVaultTransportError && !error.requestSent) {
|
|
491
|
+
fail("provider_unavailable");
|
|
492
|
+
}
|
|
493
|
+
if (mutation) fail("provider_outcome_unknown");
|
|
494
|
+
ctx.signal?.throwIfAborted();
|
|
495
|
+
return fail("provider_unavailable");
|
|
496
|
+
}
|
|
497
|
+
if (
|
|
498
|
+
typeof response?.body !== "string" ||
|
|
499
|
+
vaultProviderWireUtf8Bytes(response.body) > VAULT_PROVIDER_WIRE_LIMITS.maxResponseBytes
|
|
500
|
+
) {
|
|
501
|
+
return unverified("provider_unavailable");
|
|
502
|
+
}
|
|
503
|
+
if (response.status !== 200) {
|
|
504
|
+
// Unsigned status: never trusted as an outcome for a mutation.
|
|
505
|
+
if (response.status === 401 || response.status === 403) {
|
|
506
|
+
return unverified("provider_auth_invalid");
|
|
507
|
+
}
|
|
508
|
+
if (response.status === 429) {
|
|
509
|
+
return unverified("provider_rate_limited", retryAfterHeader(response.headers));
|
|
510
|
+
}
|
|
511
|
+
return unverified("provider_unavailable");
|
|
512
|
+
}
|
|
513
|
+
const verified = await verifyOpenGeniSignature({
|
|
514
|
+
secret: responseKey,
|
|
515
|
+
body: response.body,
|
|
516
|
+
signature: response.headers.get(VAULT_PROVIDER_WIRE_RESPONSE_SIGNATURE_HEADER),
|
|
517
|
+
toleranceSeconds: VAULT_PROVIDER_WIRE_LIMITS.signatureToleranceSeconds,
|
|
518
|
+
nowSeconds: Math.floor(now() / 1000),
|
|
519
|
+
});
|
|
520
|
+
if (!verified) return unverified("provider_unavailable");
|
|
521
|
+
let json: unknown;
|
|
522
|
+
try {
|
|
523
|
+
json = JSON.parse(response.body);
|
|
524
|
+
} catch {
|
|
525
|
+
return unverified("provider_unavailable");
|
|
526
|
+
}
|
|
527
|
+
const envelope = parseVaultProviderWireResponse(json, { requestId, op: operation.op as Op });
|
|
528
|
+
if (!envelope) return unverified("provider_unavailable");
|
|
529
|
+
const freshness = freshnessFrom(envelope.freshness);
|
|
530
|
+
if (!envelope.ok) {
|
|
531
|
+
if (freshness) ctx.onFreshness?.(freshness);
|
|
532
|
+
fail(envelope.error.code, envelope.error.retryAfterMs ?? undefined);
|
|
533
|
+
}
|
|
534
|
+
return { result: envelope.result as VaultProviderWireResult<Op>, freshness };
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function readContext(ctx: VaultProviderContext) {
|
|
538
|
+
const epoch = ctx.cacheEpoch;
|
|
539
|
+
const cacheEpoch =
|
|
540
|
+
typeof epoch === "string" &&
|
|
541
|
+
epoch.length > 0 &&
|
|
542
|
+
epoch.length <= VAULT_PROVIDER_WIRE_LIMITS.cacheEpochMaxChars &&
|
|
543
|
+
!CONTROL.test(epoch)
|
|
544
|
+
? epoch
|
|
545
|
+
: null;
|
|
546
|
+
return { forceRefresh: ctx.forceRefresh === true, cacheEpoch };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function wirePage(page: ProviderPageRequest) {
|
|
550
|
+
const limit = Math.trunc(page.limit);
|
|
551
|
+
if (!Number.isSafeInteger(limit) || limit < 1) fail("provider_inventory_incomplete");
|
|
552
|
+
return {
|
|
553
|
+
limit: Math.min(limit, VAULT_PROVIDER_WIRE_LIMITS.pageSizeMax),
|
|
554
|
+
...(page.cursor !== undefined ? { cursor: page.cursor } : {}),
|
|
555
|
+
...(page.snapshot !== undefined ? { snapshot: page.snapshot } : {}),
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function idempotencyKey(ctx: VaultProviderContext): string {
|
|
560
|
+
const key = ctx.idempotencyKey;
|
|
561
|
+
if (
|
|
562
|
+
typeof key === "string" &&
|
|
563
|
+
key.length > 0 &&
|
|
564
|
+
key.length <= VAULT_PROVIDER_WIRE_LIMITS.idempotencyKeyMaxChars &&
|
|
565
|
+
!CONTROL.test(key)
|
|
566
|
+
) {
|
|
567
|
+
return key;
|
|
568
|
+
}
|
|
569
|
+
return crypto.randomUUID();
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const provider: RemoteVaultProvider = {
|
|
573
|
+
id: options.provider,
|
|
574
|
+
|
|
575
|
+
capabilities(): VaultProviderCapabilities {
|
|
576
|
+
const operations = currentOperations();
|
|
577
|
+
return {
|
|
578
|
+
write:
|
|
579
|
+
operations.createItemTypes.length > 0 || operations.updateItem || operations.archiveItem,
|
|
580
|
+
createVault: false,
|
|
581
|
+
totp: described?.capabilities.totp ?? (operations.createTotp || operations.updateTotp),
|
|
582
|
+
files: operations.readFiles,
|
|
583
|
+
groupGrants: false,
|
|
584
|
+
passkeys: false,
|
|
585
|
+
operations,
|
|
586
|
+
};
|
|
587
|
+
},
|
|
588
|
+
|
|
589
|
+
async validate(ctx) {
|
|
590
|
+
const { result, freshness } = await call(ctx, "admin", { op: "describe" });
|
|
591
|
+
if (freshness) ctx.onFreshness?.(freshness);
|
|
592
|
+
const validation: RemoteVaultValidation = {
|
|
593
|
+
accountId: result.accountId,
|
|
594
|
+
accountName: result.accountName,
|
|
595
|
+
operations: remoteVaultOperations(result.operations),
|
|
596
|
+
capabilities: { ...result.capabilities },
|
|
597
|
+
};
|
|
598
|
+
validation.operations.readFiles &&= validation.capabilities.files;
|
|
599
|
+
if (!validation.capabilities.totp) {
|
|
600
|
+
validation.operations.createTotp = false;
|
|
601
|
+
validation.operations.updateTotp = false;
|
|
602
|
+
}
|
|
603
|
+
described = validation;
|
|
604
|
+
return {
|
|
605
|
+
...validation,
|
|
606
|
+
operations: { ...validation.operations },
|
|
607
|
+
capabilities: { ...validation.capabilities },
|
|
608
|
+
};
|
|
609
|
+
},
|
|
610
|
+
|
|
611
|
+
async listVaultPage(ctx, page) {
|
|
612
|
+
const request = wirePage(page);
|
|
613
|
+
const { result, freshness } = await call(ctx, "list", {
|
|
614
|
+
op: "list_vaults",
|
|
615
|
+
page: request,
|
|
616
|
+
...readContext(ctx),
|
|
617
|
+
});
|
|
618
|
+
if (result.items.length > request.limit) fail("provider_inventory_incomplete");
|
|
619
|
+
return {
|
|
620
|
+
items: result.items.map(toProviderVault),
|
|
621
|
+
nextCursor: result.nextCursor,
|
|
622
|
+
snapshot: result.snapshot,
|
|
623
|
+
...(freshness ? { freshness } : {}),
|
|
624
|
+
};
|
|
625
|
+
},
|
|
626
|
+
|
|
627
|
+
async listItemPage(ctx, externalVaultId, page) {
|
|
628
|
+
const request = wirePage(page);
|
|
629
|
+
const { result, freshness } = await call(ctx, "list", {
|
|
630
|
+
op: "list_items",
|
|
631
|
+
externalVaultId,
|
|
632
|
+
page: request,
|
|
633
|
+
...readContext(ctx),
|
|
634
|
+
});
|
|
635
|
+
if (result.items.length > request.limit) fail("provider_inventory_incomplete");
|
|
636
|
+
return {
|
|
637
|
+
items: result.items.map(toProviderItem),
|
|
638
|
+
nextCursor: result.nextCursor,
|
|
639
|
+
snapshot: result.snapshot,
|
|
640
|
+
...(freshness ? { freshness } : {}),
|
|
641
|
+
};
|
|
642
|
+
},
|
|
643
|
+
|
|
644
|
+
// Compatibility: a complete bounded traversal or a refusal, never a prefix.
|
|
645
|
+
async listVaults(ctx) {
|
|
646
|
+
return collectProviderPages({
|
|
647
|
+
page: async (page) => {
|
|
648
|
+
const result = await provider.listVaultPage(ctx, page);
|
|
649
|
+
if (result.freshness) ctx.onFreshness?.(result.freshness);
|
|
650
|
+
return result;
|
|
651
|
+
},
|
|
652
|
+
identity: (vault) => vault.externalVaultId,
|
|
653
|
+
maxItems: VAULT_LIMITS.vaultsPerOwnerScope,
|
|
654
|
+
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
655
|
+
});
|
|
656
|
+
},
|
|
657
|
+
|
|
658
|
+
async listItems(ctx, externalVaultId) {
|
|
659
|
+
return collectProviderPages({
|
|
660
|
+
page: async (page) => {
|
|
661
|
+
const result = await provider.listItemPage(ctx, externalVaultId, page);
|
|
662
|
+
if (result.freshness) ctx.onFreshness?.(result.freshness);
|
|
663
|
+
return result;
|
|
664
|
+
},
|
|
665
|
+
identity: (item) => item.externalItemId,
|
|
666
|
+
maxItems: VAULT_LIMITS.itemsPerVault,
|
|
667
|
+
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
668
|
+
});
|
|
669
|
+
},
|
|
670
|
+
|
|
671
|
+
async readFields(ctx, externalVaultId, externalItemId, externalFieldIds) {
|
|
672
|
+
if (externalFieldIds.length === 0) return [];
|
|
673
|
+
if (
|
|
674
|
+
externalFieldIds.length > VAULT_PROVIDER_WIRE_LIMITS.readFieldsMax ||
|
|
675
|
+
new Set(externalFieldIds).size !== externalFieldIds.length
|
|
676
|
+
) {
|
|
677
|
+
fail("provider_not_found");
|
|
678
|
+
}
|
|
679
|
+
const { result, freshness } = await call(ctx, "read", {
|
|
680
|
+
op: "read_fields",
|
|
681
|
+
externalVaultId,
|
|
682
|
+
externalItemId,
|
|
683
|
+
externalFieldIds: [...externalFieldIds],
|
|
684
|
+
...readContext(ctx),
|
|
685
|
+
});
|
|
686
|
+
if (freshness) ctx.onFreshness?.(freshness);
|
|
687
|
+
// Exactly the requested ids, each once: never accept unrequested values.
|
|
688
|
+
const byId = new Map(result.fields.map((field) => [field.externalFieldId, field]));
|
|
689
|
+
if (
|
|
690
|
+
result.fields.length !== externalFieldIds.length ||
|
|
691
|
+
externalFieldIds.some((id) => !byId.has(id))
|
|
692
|
+
) {
|
|
693
|
+
fail("provider_unavailable");
|
|
694
|
+
}
|
|
695
|
+
const values: ProviderFieldValue[] = [];
|
|
696
|
+
for (const externalFieldId of externalFieldIds) {
|
|
697
|
+
const wire = byId.get(externalFieldId)!.value;
|
|
698
|
+
let material: SecretMaterial;
|
|
699
|
+
if (wire.type === "text") {
|
|
700
|
+
material = { type: "text", value: wire.value };
|
|
701
|
+
} else if (wire.type === "totp") {
|
|
702
|
+
try {
|
|
703
|
+
material = { type: "totp", params: parseTotpInput(wire.value) };
|
|
704
|
+
} catch {
|
|
705
|
+
return fail("provider_unavailable");
|
|
706
|
+
}
|
|
707
|
+
} else {
|
|
708
|
+
let bytes: Uint8Array;
|
|
709
|
+
try {
|
|
710
|
+
bytes = decodeBase64(wire.base64);
|
|
711
|
+
} catch {
|
|
712
|
+
return fail("provider_unavailable");
|
|
713
|
+
}
|
|
714
|
+
if (bytes.byteLength > VAULT_LIMITS.fileValueMaxBytes) fail("provider_unavailable");
|
|
715
|
+
material = { type: "bytes", value: bytes };
|
|
716
|
+
}
|
|
717
|
+
values.push({ externalFieldId, material });
|
|
718
|
+
}
|
|
719
|
+
return values;
|
|
720
|
+
},
|
|
721
|
+
|
|
722
|
+
async writeItem(ctx, externalVaultId, item: ProviderWriteItem) {
|
|
723
|
+
const operations = currentOperations();
|
|
724
|
+
const update = item.externalItemId !== undefined;
|
|
725
|
+
const type = item.type;
|
|
726
|
+
if (type === "passkey") return fail("provider_permission_denied");
|
|
727
|
+
if (update ? !operations.updateItem : !operations.createItemTypes.includes(item.type)) {
|
|
728
|
+
fail("provider_permission_denied");
|
|
729
|
+
}
|
|
730
|
+
if (
|
|
731
|
+
item.fields.some((field) => field.kind === "file" && field.value !== undefined) ||
|
|
732
|
+
(update && !operations.removeField && item.fields.some((field) => field.remove)) ||
|
|
733
|
+
(item.fields.some((field) => field.kind === "totp" && field.value !== undefined) &&
|
|
734
|
+
!(update ? operations.updateTotp : operations.createTotp))
|
|
735
|
+
) {
|
|
736
|
+
fail("provider_permission_denied");
|
|
737
|
+
}
|
|
738
|
+
const fields: VaultProviderWireWriteField[] = item.fields.map((field) => ({
|
|
739
|
+
...(field.externalFieldId !== undefined ? { externalFieldId: field.externalFieldId } : {}),
|
|
740
|
+
name: field.name,
|
|
741
|
+
kind: field.kind,
|
|
742
|
+
...(field.value !== undefined ? { value: field.value } : {}),
|
|
743
|
+
...(field.remove ? { remove: true as const } : {}),
|
|
744
|
+
}));
|
|
745
|
+
const { result, freshness } = await call(ctx, "write", {
|
|
746
|
+
op: "write_item",
|
|
747
|
+
externalVaultId,
|
|
748
|
+
item: {
|
|
749
|
+
...(update ? { externalItemId: item.externalItemId } : {}),
|
|
750
|
+
...(item.expectedVersion !== undefined ? { expectedVersion: item.expectedVersion } : {}),
|
|
751
|
+
name: item.name,
|
|
752
|
+
type,
|
|
753
|
+
websites: [...item.websites],
|
|
754
|
+
fields,
|
|
755
|
+
},
|
|
756
|
+
idempotencyKey: idempotencyKey(ctx),
|
|
757
|
+
});
|
|
758
|
+
if (freshness) ctx.onFreshness?.(freshness);
|
|
759
|
+
// A confirmed write that names another item cannot be trusted as this write.
|
|
760
|
+
if (update && result.item.externalItemId !== item.externalItemId) {
|
|
761
|
+
fail("provider_outcome_unknown");
|
|
762
|
+
}
|
|
763
|
+
return toProviderItem(result.item);
|
|
764
|
+
},
|
|
765
|
+
|
|
766
|
+
async archiveItem(ctx, externalVaultId, externalItemId) {
|
|
767
|
+
if (!currentOperations().archiveItem) fail("provider_permission_denied");
|
|
768
|
+
const { freshness } = await call(ctx, "write", {
|
|
769
|
+
op: "archive_item",
|
|
770
|
+
externalVaultId,
|
|
771
|
+
externalItemId,
|
|
772
|
+
idempotencyKey: idempotencyKey(ctx),
|
|
773
|
+
});
|
|
774
|
+
if (freshness) ctx.onFreshness?.(freshness);
|
|
775
|
+
},
|
|
776
|
+
|
|
777
|
+
async refresh(ctx) {
|
|
778
|
+
const { freshness } = await call(ctx, "admin", { op: "refresh" });
|
|
779
|
+
if (freshness) ctx.onFreshness?.(freshness);
|
|
780
|
+
return freshness;
|
|
781
|
+
},
|
|
782
|
+
};
|
|
783
|
+
return provider;
|
|
784
|
+
}
|