@opengeni/vaults-protonpass 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/LICENSE +190 -0
- package/README.md +136 -2
- package/dist/contract.d.ts +6 -0
- package/dist/errors.d.ts +57 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +1836 -0
- package/dist/index.js.map +1 -0
- package/dist/mapping.d.ts +41 -0
- package/dist/naming.d.ts +8 -0
- package/dist/operations.d.ts +7 -0
- package/dist/pool.d.ts +49 -0
- package/dist/provider.d.ts +38 -0
- package/dist/runner.d.ts +31 -0
- package/dist/schema.d.ts +545 -0
- package/dist/session.d.ts +113 -0
- package/package.json +42 -4
- package/src/contract.ts +29 -0
- package/src/errors.ts +287 -0
- package/src/index.ts +29 -0
- package/src/mapping.ts +247 -0
- package/src/naming.ts +44 -0
- package/src/operations.ts +65 -0
- package/src/pool.ts +199 -0
- package/src/provider.ts +690 -0
- package/src/runner.ts +144 -0
- package/src/schema.ts +164 -0
- package/src/session.ts +578 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1836 @@
|
|
|
1
|
+
// src/provider.ts
|
|
2
|
+
import { lstatSync as lstatSync2, readFileSync, rmSync as rmSync2 } from "fs";
|
|
3
|
+
import { join as join2 } from "path";
|
|
4
|
+
|
|
5
|
+
// src/contract.ts
|
|
6
|
+
import {
|
|
7
|
+
VaultProviderError
|
|
8
|
+
} from "@opengeni/vaults";
|
|
9
|
+
import {
|
|
10
|
+
VAULT_LIMITS,
|
|
11
|
+
parseTotpInput
|
|
12
|
+
} from "@opengeni/contracts";
|
|
13
|
+
|
|
14
|
+
// src/naming.ts
|
|
15
|
+
var FORBIDDEN_NAME_CHARS = /[/{}#\\\u0000-\u001f\u007f-\u009f]/g;
|
|
16
|
+
function nameKey(name) {
|
|
17
|
+
return name.normalize("NFC").trim().toLowerCase();
|
|
18
|
+
}
|
|
19
|
+
function safeSecretName(raw, fallback) {
|
|
20
|
+
const max = VAULT_LIMITS.nameMaxChars;
|
|
21
|
+
let name = raw.replace(FORBIDDEN_NAME_CHARS, "-").trim().slice(0, max).trim();
|
|
22
|
+
if (!name) name = fallback.replace(FORBIDDEN_NAME_CHARS, "-").trim().slice(0, max) || "item";
|
|
23
|
+
if (/^id:/i.test(name)) name = `id-${name.slice(3)}`;
|
|
24
|
+
return name;
|
|
25
|
+
}
|
|
26
|
+
function safeFieldName(raw, fallback) {
|
|
27
|
+
const name = safeSecretName(raw, fallback);
|
|
28
|
+
return nameKey(name) === "otp" ? "otp-code" : name;
|
|
29
|
+
}
|
|
30
|
+
function uniqueName(name, taken) {
|
|
31
|
+
let candidate = name;
|
|
32
|
+
for (let n = 2; taken.has(nameKey(candidate)); n += 1) {
|
|
33
|
+
const suffix = ` (${n})`;
|
|
34
|
+
candidate = `${name.slice(0, VAULT_LIMITS.nameMaxChars - suffix.length)}${suffix}`;
|
|
35
|
+
}
|
|
36
|
+
taken.add(nameKey(candidate));
|
|
37
|
+
return candidate;
|
|
38
|
+
}
|
|
39
|
+
function totpUri(params, label) {
|
|
40
|
+
const query = new URLSearchParams({
|
|
41
|
+
secret: params.secretBase32,
|
|
42
|
+
algorithm: params.algorithm,
|
|
43
|
+
digits: String(params.digits),
|
|
44
|
+
period: String(params.periodSeconds)
|
|
45
|
+
});
|
|
46
|
+
return `otpauth://totp/${encodeURIComponent(label || "Opengeni")}?${query.toString()}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// src/mapping.ts
|
|
50
|
+
var NOTE_FIELD_ID = "@note";
|
|
51
|
+
var FILE_FIELD_PREFIX = "@file:";
|
|
52
|
+
var LOGIN_FIELD_IDS = {
|
|
53
|
+
username: "login.username",
|
|
54
|
+
email: "login.email",
|
|
55
|
+
password: "login.password",
|
|
56
|
+
totp: "login.totp"
|
|
57
|
+
};
|
|
58
|
+
var TOTP_FIELD_NAME = "one-time password";
|
|
59
|
+
var IDENTITY_CONCEALED = /* @__PURE__ */ new Set(["social_security_number", "passport_number", "license_number"]);
|
|
60
|
+
var FieldCollector = class {
|
|
61
|
+
fields = [];
|
|
62
|
+
names = /* @__PURE__ */ new Set();
|
|
63
|
+
ids = /* @__PURE__ */ new Map();
|
|
64
|
+
add(baseId, rawName, kind, value) {
|
|
65
|
+
const seen = (this.ids.get(baseId) ?? 0) + 1;
|
|
66
|
+
this.ids.set(baseId, seen);
|
|
67
|
+
const externalFieldId = seen === 1 ? baseId : `${baseId}#${seen}`;
|
|
68
|
+
const name = uniqueName(safeFieldName(rawName, "field"), this.names);
|
|
69
|
+
this.fields.push({ externalFieldId, name, kind, value });
|
|
70
|
+
}
|
|
71
|
+
extra(prefix, field) {
|
|
72
|
+
const content = field.content;
|
|
73
|
+
const id = `${prefix}${field.name}`;
|
|
74
|
+
if ("Text" in content) this.add(id, field.name, "text", { type: "text", value: content.Text });
|
|
75
|
+
else if ("Hidden" in content) {
|
|
76
|
+
this.add(id, field.name, "concealed", { type: "text", value: content.Hidden });
|
|
77
|
+
} else if ("Totp" in content) {
|
|
78
|
+
if (content.Totp) this.add(id, field.name, "totp", { type: "totp", uri: content.Totp });
|
|
79
|
+
else this.add(id, field.name, "text", { type: "text", value: "" });
|
|
80
|
+
} else {
|
|
81
|
+
this.add(id, field.name, "text", { type: "text", value: timestampText(content.Timestamp) });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
sections(sections) {
|
|
85
|
+
for (const section of sections) {
|
|
86
|
+
for (const field of section.section_fields) this.extra(`s:${section.section_name}/`, field);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
function timestampText(seconds) {
|
|
91
|
+
const date = new Date(seconds * 1e3);
|
|
92
|
+
return Number.isNaN(date.getTime()) ? String(seconds) : date.toISOString();
|
|
93
|
+
}
|
|
94
|
+
function variantName(content) {
|
|
95
|
+
if (typeof content === "string") return content;
|
|
96
|
+
return Object.keys(content)[0] ?? "Note";
|
|
97
|
+
}
|
|
98
|
+
function itemTypeForVariant(variant) {
|
|
99
|
+
switch (variant) {
|
|
100
|
+
case "Login":
|
|
101
|
+
return "login";
|
|
102
|
+
case "Note":
|
|
103
|
+
return "note";
|
|
104
|
+
case "Custom":
|
|
105
|
+
case "SshKey":
|
|
106
|
+
return "api_key";
|
|
107
|
+
default:
|
|
108
|
+
return "note";
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function civilToIso(civil) {
|
|
112
|
+
const date = /* @__PURE__ */ new Date(`${civil}Z`);
|
|
113
|
+
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
114
|
+
}
|
|
115
|
+
function concealedText(collector, id, name, value) {
|
|
116
|
+
collector.add(id, name, "concealed", { type: "text", value });
|
|
117
|
+
}
|
|
118
|
+
function plainText(collector, id, name, value) {
|
|
119
|
+
collector.add(id, name, "text", { type: "text", value });
|
|
120
|
+
}
|
|
121
|
+
function identityFields(collector, identity) {
|
|
122
|
+
for (const [key, value] of Object.entries(identity)) {
|
|
123
|
+
if (typeof value === "string") {
|
|
124
|
+
if (!value) continue;
|
|
125
|
+
if (IDENTITY_CONCEALED.has(key)) concealedText(collector, `identity.${key}`, key, value);
|
|
126
|
+
else plainText(collector, `identity.${key}`, key, value);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (!Array.isArray(value)) continue;
|
|
130
|
+
for (const entry of value) {
|
|
131
|
+
if (!entry || typeof entry !== "object") continue;
|
|
132
|
+
const record = entry;
|
|
133
|
+
if (typeof record.section_name === "string" && Array.isArray(record.section_fields)) {
|
|
134
|
+
collector.sections([
|
|
135
|
+
{ section_name: record.section_name, section_fields: extraFields(record.section_fields) }
|
|
136
|
+
]);
|
|
137
|
+
} else {
|
|
138
|
+
for (const field of extraFields([record])) collector.extra(`s:${key}/`, field);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function extraFields(values) {
|
|
144
|
+
const out = [];
|
|
145
|
+
for (const value of values) {
|
|
146
|
+
if (!value || typeof value !== "object") continue;
|
|
147
|
+
const record = value;
|
|
148
|
+
if (typeof record.name !== "string" || !record.content || typeof record.content !== "object") {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const content = record.content;
|
|
152
|
+
if (typeof content.Text === "string")
|
|
153
|
+
out.push({ name: record.name, content: { Text: content.Text } });
|
|
154
|
+
else if (typeof content.Hidden === "string") {
|
|
155
|
+
out.push({ name: record.name, content: { Hidden: content.Hidden } });
|
|
156
|
+
} else if (typeof content.Totp === "string") {
|
|
157
|
+
out.push({ name: record.name, content: { Totp: content.Totp } });
|
|
158
|
+
} else if (typeof content.Timestamp === "number") {
|
|
159
|
+
out.push({ name: record.name, content: { Timestamp: content.Timestamp } });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
function mapItem(item, attachments = []) {
|
|
165
|
+
const collector = new FieldCollector();
|
|
166
|
+
const content = item.content.content;
|
|
167
|
+
const variant = variantName(content);
|
|
168
|
+
const websites = [];
|
|
169
|
+
if (typeof content === "object") {
|
|
170
|
+
if ("Login" in content) {
|
|
171
|
+
const login = content.Login;
|
|
172
|
+
plainText(collector, LOGIN_FIELD_IDS.username, "username", login.username);
|
|
173
|
+
if (login.email) plainText(collector, LOGIN_FIELD_IDS.email, "email", login.email);
|
|
174
|
+
concealedText(collector, LOGIN_FIELD_IDS.password, "password", login.password);
|
|
175
|
+
if (login.totp_uri) {
|
|
176
|
+
collector.add(LOGIN_FIELD_IDS.totp, TOTP_FIELD_NAME, "totp", {
|
|
177
|
+
type: "totp",
|
|
178
|
+
uri: login.totp_uri
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
websites.push(...login.urls.filter((url) => url.trim().length > 0));
|
|
182
|
+
} else if ("SshKey" in content) {
|
|
183
|
+
concealedText(collector, "ssh.private_key", "private key", content.SshKey.private_key);
|
|
184
|
+
plainText(collector, "ssh.public_key", "public key", content.SshKey.public_key);
|
|
185
|
+
collector.sections(content.SshKey.sections);
|
|
186
|
+
} else if ("Wifi" in content) {
|
|
187
|
+
plainText(collector, "wifi.ssid", "ssid", content.Wifi.ssid);
|
|
188
|
+
concealedText(collector, "wifi.password", "password", content.Wifi.password);
|
|
189
|
+
collector.sections(content.Wifi.sections);
|
|
190
|
+
} else if ("CreditCard" in content) {
|
|
191
|
+
const card = content.CreditCard;
|
|
192
|
+
plainText(collector, "card.cardholder_name", "cardholder name", card.cardholder_name);
|
|
193
|
+
concealedText(collector, "card.number", "number", card.number);
|
|
194
|
+
concealedText(
|
|
195
|
+
collector,
|
|
196
|
+
"card.verification_number",
|
|
197
|
+
"verification number",
|
|
198
|
+
card.verification_number
|
|
199
|
+
);
|
|
200
|
+
plainText(collector, "card.expiration_date", "expiration date", card.expiration_date);
|
|
201
|
+
if (card.pin) concealedText(collector, "card.pin", "PIN", card.pin);
|
|
202
|
+
} else if ("Identity" in content) {
|
|
203
|
+
identityFields(collector, content.Identity);
|
|
204
|
+
} else if ("Custom" in content) {
|
|
205
|
+
collector.sections(content.Custom.sections);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const note = item.content.note;
|
|
209
|
+
if (variant === "Note" || note) {
|
|
210
|
+
collector.add(NOTE_FIELD_ID, "note", "concealed", { type: "text", value: note });
|
|
211
|
+
}
|
|
212
|
+
for (const field of item.content.extra_fields) collector.extra("x:", field);
|
|
213
|
+
for (const attachment of attachments) {
|
|
214
|
+
collector.add(`${FILE_FIELD_PREFIX}${attachment.id}`, attachment.content.name, "file", {
|
|
215
|
+
type: "file",
|
|
216
|
+
attachment
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
const meta = {
|
|
220
|
+
externalItemId: item.id,
|
|
221
|
+
name: safeSecretName(item.content.title, item.id),
|
|
222
|
+
type: itemTypeForVariant(variant),
|
|
223
|
+
websites,
|
|
224
|
+
fields: collector.fields.map(({ externalFieldId, name, kind }) => ({
|
|
225
|
+
externalFieldId,
|
|
226
|
+
name,
|
|
227
|
+
kind
|
|
228
|
+
})),
|
|
229
|
+
version: item.modify_time,
|
|
230
|
+
updatedAt: civilToIso(item.modify_time)
|
|
231
|
+
};
|
|
232
|
+
return { meta, fields: collector.fields };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// src/operations.ts
|
|
236
|
+
import { providerOperations } from "@opengeni/vaults";
|
|
237
|
+
function refused(what) {
|
|
238
|
+
throw new VaultProviderError(
|
|
239
|
+
"provider_permission_denied",
|
|
240
|
+
`${what} is not enabled for this vault connection`
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
function checkWrite(operations, item) {
|
|
244
|
+
const totp = item.fields.some((field) => field.kind === "totp" && !field.remove);
|
|
245
|
+
if (item.externalItemId !== void 0) {
|
|
246
|
+
if (!operations.updateItem) refused("Updating items");
|
|
247
|
+
if (totp && !operations.updateTotp) refused("Changing one-time password seeds");
|
|
248
|
+
if (item.fields.some((field) => field.remove) && !operations.removeField)
|
|
249
|
+
refused("Removing fields");
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (!operations.createItemTypes.includes(item.type)) refused(`Creating ${item.type} items`);
|
|
253
|
+
if (totp && !operations.createTotp) refused("Adding one-time password seeds");
|
|
254
|
+
}
|
|
255
|
+
function restrictToOperations(provider, verified, fileFieldPrefix) {
|
|
256
|
+
if (!verified) return provider;
|
|
257
|
+
const effective = () => providerOperations(provider.capabilities(), { operations: verified, writeAccess: "unknown" });
|
|
258
|
+
return {
|
|
259
|
+
...provider,
|
|
260
|
+
capabilities() {
|
|
261
|
+
const base = provider.capabilities();
|
|
262
|
+
const operations = effective();
|
|
263
|
+
const writes = operations.createItemTypes.length > 0 || operations.updateItem || operations.archiveItem;
|
|
264
|
+
return { ...base, write: base.write && writes, operations };
|
|
265
|
+
},
|
|
266
|
+
async readFields(ctx, vaultId, itemId, fieldIds) {
|
|
267
|
+
if (!effective().readFiles && fieldIds.some((id) => id.startsWith(fileFieldPrefix)))
|
|
268
|
+
refused("Reading files");
|
|
269
|
+
return provider.readFields(ctx, vaultId, itemId, fieldIds);
|
|
270
|
+
},
|
|
271
|
+
async writeItem(ctx, vaultId, item) {
|
|
272
|
+
checkWrite(effective(), item);
|
|
273
|
+
return provider.writeItem(ctx, vaultId, item);
|
|
274
|
+
},
|
|
275
|
+
async archiveItem(ctx, vaultId, itemId) {
|
|
276
|
+
if (!effective().archiveItem) refused("Archiving items");
|
|
277
|
+
return provider.archiveItem(ctx, vaultId, itemId);
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// src/schema.ts
|
|
283
|
+
import { z } from "zod";
|
|
284
|
+
var Id = z.string().min(1).max(512);
|
|
285
|
+
var CivilDateTime = z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,9})?)?$/);
|
|
286
|
+
var ShareRole = z.union([
|
|
287
|
+
z.enum(["Owner", "Manager", "Editor", "Viewer"]),
|
|
288
|
+
z.object({
|
|
289
|
+
Custom: z.object({ name: z.string().optional(), permission: z.number().int().min(0) })
|
|
290
|
+
})
|
|
291
|
+
]);
|
|
292
|
+
var ShareListOutput = z.object({
|
|
293
|
+
shares: z.array(
|
|
294
|
+
z.object({
|
|
295
|
+
id: Id,
|
|
296
|
+
name: z.string(),
|
|
297
|
+
share_type: z.string(),
|
|
298
|
+
share_role: ShareRole
|
|
299
|
+
})
|
|
300
|
+
)
|
|
301
|
+
});
|
|
302
|
+
var VaultListOutput = z.object({
|
|
303
|
+
vaults: z.array(z.object({ name: z.string(), vault_id: Id, share_id: Id }))
|
|
304
|
+
});
|
|
305
|
+
var ExtraFieldContent = z.union([
|
|
306
|
+
z.object({ Text: z.string() }),
|
|
307
|
+
z.object({ Hidden: z.string() }),
|
|
308
|
+
z.object({ Totp: z.string() }),
|
|
309
|
+
z.object({ Timestamp: z.number().int() })
|
|
310
|
+
]);
|
|
311
|
+
var ExtraField = z.object({ name: z.string(), content: ExtraFieldContent });
|
|
312
|
+
var Section = z.object({ section_name: z.string(), section_fields: z.array(ExtraField) });
|
|
313
|
+
var OptionalString = z.string().nullish().transform((value) => value ?? "");
|
|
314
|
+
var LoginContent = z.object({
|
|
315
|
+
email: OptionalString,
|
|
316
|
+
username: OptionalString,
|
|
317
|
+
password: OptionalString,
|
|
318
|
+
urls: z.array(z.string()).default([]),
|
|
319
|
+
totp_uri: OptionalString
|
|
320
|
+
});
|
|
321
|
+
var SshKeyContent = z.object({
|
|
322
|
+
private_key: OptionalString,
|
|
323
|
+
public_key: OptionalString,
|
|
324
|
+
sections: z.array(Section).default([])
|
|
325
|
+
});
|
|
326
|
+
var WifiContent = z.object({
|
|
327
|
+
ssid: OptionalString,
|
|
328
|
+
password: OptionalString,
|
|
329
|
+
sections: z.array(Section).default([])
|
|
330
|
+
});
|
|
331
|
+
var CreditCardContent = z.object({
|
|
332
|
+
cardholder_name: OptionalString,
|
|
333
|
+
number: OptionalString,
|
|
334
|
+
verification_number: OptionalString,
|
|
335
|
+
expiration_date: OptionalString,
|
|
336
|
+
pin: OptionalString
|
|
337
|
+
});
|
|
338
|
+
var IdentityContent = z.record(z.string(), z.unknown());
|
|
339
|
+
var CustomContent = z.object({ sections: z.array(Section).default([]) });
|
|
340
|
+
var ItemContentVariant = z.union([
|
|
341
|
+
z.object({ Note: z.null() }).strict(),
|
|
342
|
+
z.object({ Alias: z.null() }).strict(),
|
|
343
|
+
z.object({ Login: LoginContent }),
|
|
344
|
+
z.object({ SshKey: SshKeyContent }),
|
|
345
|
+
z.object({ Wifi: WifiContent }),
|
|
346
|
+
z.object({ CreditCard: CreditCardContent }),
|
|
347
|
+
z.object({ Identity: IdentityContent }),
|
|
348
|
+
z.object({ Custom: CustomContent }),
|
|
349
|
+
z.literal("Note"),
|
|
350
|
+
z.literal("Alias")
|
|
351
|
+
]);
|
|
352
|
+
var Item = z.object({
|
|
353
|
+
id: Id,
|
|
354
|
+
share_id: Id,
|
|
355
|
+
vault_id: z.string().optional(),
|
|
356
|
+
content: z.object({
|
|
357
|
+
title: z.string(),
|
|
358
|
+
note: OptionalString,
|
|
359
|
+
content: ItemContentVariant,
|
|
360
|
+
extra_fields: z.array(ExtraField).default([])
|
|
361
|
+
}),
|
|
362
|
+
state: z.string(),
|
|
363
|
+
flags: z.array(z.string()).default([]),
|
|
364
|
+
create_time: CivilDateTime,
|
|
365
|
+
modify_time: CivilDateTime
|
|
366
|
+
});
|
|
367
|
+
var Attachment = z.object({
|
|
368
|
+
id: Id,
|
|
369
|
+
size: z.number().int().min(0),
|
|
370
|
+
content: z.object({ name: z.string(), mime_type: z.string().optional() })
|
|
371
|
+
});
|
|
372
|
+
var ItemDetails = z.object({
|
|
373
|
+
item: Item,
|
|
374
|
+
attachments: z.array(Attachment).default([])
|
|
375
|
+
});
|
|
376
|
+
var FullItemList = z.object({ items: z.array(Item) });
|
|
377
|
+
var ItemSummary = z.object({
|
|
378
|
+
id: Id,
|
|
379
|
+
share_id: Id,
|
|
380
|
+
state: z.string(),
|
|
381
|
+
flags: z.array(z.string()).default([]),
|
|
382
|
+
create_time: CivilDateTime,
|
|
383
|
+
modify_time: CivilDateTime,
|
|
384
|
+
title: z.string(),
|
|
385
|
+
item_type: z.string()
|
|
386
|
+
});
|
|
387
|
+
var ItemSummaryList = z.object({ items: z.array(ItemSummary) });
|
|
388
|
+
var InfoOutput = z.object({
|
|
389
|
+
personal_access_token_name: z.string().optional(),
|
|
390
|
+
username: z.string().optional(),
|
|
391
|
+
session_has_lock: z.boolean().optional()
|
|
392
|
+
});
|
|
393
|
+
var ITEM_HAS_FILES = "ItemHasFiles";
|
|
394
|
+
var SHARE_PERMISSION = {
|
|
395
|
+
admin: 1,
|
|
396
|
+
read: 2,
|
|
397
|
+
create: 4,
|
|
398
|
+
update: 8,
|
|
399
|
+
trash: 16,
|
|
400
|
+
delete: 32
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
// src/session.ts
|
|
404
|
+
import { createHash, randomBytes } from "crypto";
|
|
405
|
+
import {
|
|
406
|
+
accessSync,
|
|
407
|
+
chmodSync,
|
|
408
|
+
constants as fsConstants,
|
|
409
|
+
createReadStream,
|
|
410
|
+
lstatSync,
|
|
411
|
+
mkdirSync,
|
|
412
|
+
mkdtempSync,
|
|
413
|
+
realpathSync,
|
|
414
|
+
rmSync,
|
|
415
|
+
statSync
|
|
416
|
+
} from "fs";
|
|
417
|
+
import { tmpdir } from "os";
|
|
418
|
+
import { delimiter, isAbsolute, join, resolve } from "path";
|
|
419
|
+
|
|
420
|
+
// src/errors.ts
|
|
421
|
+
var REASON = /Could not perform operation\. Reason: ([A-Za-z]+)/;
|
|
422
|
+
var STATUS = /Invalid status code(?: for download chunk)?: (\d{3})/;
|
|
423
|
+
var SESSION_INVALIDATED = /Your session has been invalidated/;
|
|
424
|
+
var NOT_AUTHENTICATED = /This operation requires an authenticated client/;
|
|
425
|
+
var NOT_FOUND = /Item not found|Share with id \S+ not found|Could not find vault|Could not find item|Attachment not found/;
|
|
426
|
+
var NETWORK = /error sending request|dns error|failed to lookup address|Connection refused|connection (?:reset|closed)|operation timed out|tcp connect error|certificate/i;
|
|
427
|
+
var PERMISSION_REASONS = /* @__PURE__ */ new Set([
|
|
428
|
+
"PermissionDenied",
|
|
429
|
+
"NotAllowed",
|
|
430
|
+
"InsufficientScope",
|
|
431
|
+
"AdminPermissionMissing",
|
|
432
|
+
"Banned",
|
|
433
|
+
"FeatureDisabled"
|
|
434
|
+
]);
|
|
435
|
+
var NOT_FOUND_REASONS = /* @__PURE__ */ new Set(["NotExists", "DeletedShare"]);
|
|
436
|
+
var CONFLICT_REASONS = /* @__PURE__ */ new Set(["NotLatestRevision", "NotLatestKey", "AlreadyExists"]);
|
|
437
|
+
var QUOTA_REASONS = /* @__PURE__ */ new Set(["NoQuotaLeft", "ResourceLimitExceeded", "PayloadTooLarge"]);
|
|
438
|
+
function protonReason(stderr) {
|
|
439
|
+
return REASON.exec(stderr)?.[1] ?? null;
|
|
440
|
+
}
|
|
441
|
+
function protonStatus(stderr) {
|
|
442
|
+
const match = STATUS.exec(stderr);
|
|
443
|
+
return match ? Number(match[1]) : null;
|
|
444
|
+
}
|
|
445
|
+
var PROTON_DEFINITE_REJECTION_STATUSES = /* @__PURE__ */ new Set([
|
|
446
|
+
400,
|
|
447
|
+
401,
|
|
448
|
+
403,
|
|
449
|
+
404,
|
|
450
|
+
409,
|
|
451
|
+
413,
|
|
452
|
+
422,
|
|
453
|
+
429
|
|
454
|
+
]);
|
|
455
|
+
var PROTON_DEFINITE_REJECTION_REASONS = /* @__PURE__ */ new Set([
|
|
456
|
+
...PERMISSION_REASONS,
|
|
457
|
+
...NOT_FOUND_REASONS,
|
|
458
|
+
...CONFLICT_REASONS,
|
|
459
|
+
...QUOTA_REASONS,
|
|
460
|
+
"SessionLocked",
|
|
461
|
+
"IsLocked",
|
|
462
|
+
"InvalidSignature",
|
|
463
|
+
"MissingKeys",
|
|
464
|
+
"RotationPayloadIncomplete",
|
|
465
|
+
"IncompatibleState",
|
|
466
|
+
"BadLockId",
|
|
467
|
+
"InvalidRequirements",
|
|
468
|
+
"InvalidValue",
|
|
469
|
+
"InvalidType",
|
|
470
|
+
"ValueOutOfBounds",
|
|
471
|
+
"NotNull",
|
|
472
|
+
"NotEmpty",
|
|
473
|
+
"NotTrue",
|
|
474
|
+
"NotFalse",
|
|
475
|
+
"NotEqual",
|
|
476
|
+
"Equal",
|
|
477
|
+
"NotSameAsField",
|
|
478
|
+
"RegexError",
|
|
479
|
+
"InvalidNumber",
|
|
480
|
+
"InvalidLength",
|
|
481
|
+
"TooShort",
|
|
482
|
+
"TooLong",
|
|
483
|
+
"HashNotEqual",
|
|
484
|
+
"EmailFormat",
|
|
485
|
+
"IpFormat",
|
|
486
|
+
"UrlFormat",
|
|
487
|
+
"CurrencyFormat",
|
|
488
|
+
"LocaleFormat",
|
|
489
|
+
"DateFormat",
|
|
490
|
+
"JsonFormat",
|
|
491
|
+
"MimeFormat",
|
|
492
|
+
"PhoneFormat",
|
|
493
|
+
"DomainFormat",
|
|
494
|
+
"PgpFormat",
|
|
495
|
+
"IdFormat",
|
|
496
|
+
"HexFormat",
|
|
497
|
+
"Base64Format",
|
|
498
|
+
"VersionFormat",
|
|
499
|
+
"ImageFormat"
|
|
500
|
+
]);
|
|
501
|
+
function isDefiniteRejection(stderr) {
|
|
502
|
+
const reason = protonReason(stderr);
|
|
503
|
+
if (reason !== null) return PROTON_DEFINITE_REJECTION_REASONS.has(reason);
|
|
504
|
+
const status = protonStatus(stderr);
|
|
505
|
+
return status !== null && PROTON_DEFINITE_REJECTION_STATUSES.has(status);
|
|
506
|
+
}
|
|
507
|
+
function mapProtonFailure(result, subject) {
|
|
508
|
+
if (result.spawnError) {
|
|
509
|
+
return new VaultProviderError("provider_unavailable", "pass-cli could not be started");
|
|
510
|
+
}
|
|
511
|
+
if (result.timedOut) {
|
|
512
|
+
return new VaultProviderError("provider_unavailable", `Proton Pass timed out (${subject})`);
|
|
513
|
+
}
|
|
514
|
+
if (result.aborted) {
|
|
515
|
+
return new VaultProviderError(
|
|
516
|
+
"provider_unavailable",
|
|
517
|
+
`Proton Pass call cancelled (${subject})`
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
const stderr = result.stderr;
|
|
521
|
+
if (SESSION_INVALIDATED.test(stderr) || NOT_AUTHENTICATED.test(stderr)) {
|
|
522
|
+
return new VaultProviderError(
|
|
523
|
+
"provider_auth_expired",
|
|
524
|
+
"The Proton Pass session ended; sign in again with the personal access token"
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
const reason = protonReason(stderr);
|
|
528
|
+
if (reason) {
|
|
529
|
+
if (reason === "SessionLocked" || reason === "IsLocked") {
|
|
530
|
+
return new VaultProviderError("provider_locked", "The Proton Pass session is locked");
|
|
531
|
+
}
|
|
532
|
+
if (PERMISSION_REASONS.has(reason)) {
|
|
533
|
+
return new VaultProviderError(
|
|
534
|
+
"provider_permission_denied",
|
|
535
|
+
`The Proton Pass token may not ${subject} (${reason})`
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
if (NOT_FOUND_REASONS.has(reason)) {
|
|
539
|
+
return new VaultProviderError("provider_not_found", "No such Proton Pass vault or item");
|
|
540
|
+
}
|
|
541
|
+
if (CONFLICT_REASONS.has(reason)) {
|
|
542
|
+
return new VaultProviderError(
|
|
543
|
+
"provider_conflict",
|
|
544
|
+
`Proton Pass rejected the change because the item changed (${reason})`
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
if (QUOTA_REASONS.has(reason)) {
|
|
548
|
+
return new VaultProviderError(
|
|
549
|
+
"provider_unavailable",
|
|
550
|
+
`Proton Pass refused the request: account limit reached (${reason})`
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
return new VaultProviderError(
|
|
554
|
+
"provider_unavailable",
|
|
555
|
+
`Proton Pass rejected the request (${reason})`
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
const status = protonStatus(stderr);
|
|
559
|
+
if (status !== null) {
|
|
560
|
+
if (status === 429) {
|
|
561
|
+
return new VaultProviderError(
|
|
562
|
+
"provider_rate_limited",
|
|
563
|
+
"Proton Pass is rate limiting",
|
|
564
|
+
6e4
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
if (status === 401) {
|
|
568
|
+
return new VaultProviderError("provider_auth_expired", "The Proton Pass session expired");
|
|
569
|
+
}
|
|
570
|
+
if (status === 403) {
|
|
571
|
+
return new VaultProviderError(
|
|
572
|
+
"provider_permission_denied",
|
|
573
|
+
`The Proton Pass token may not ${subject}`
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
if (status === 404) {
|
|
577
|
+
return new VaultProviderError("provider_not_found", "No such Proton Pass vault or item");
|
|
578
|
+
}
|
|
579
|
+
if (status === 409) {
|
|
580
|
+
return new VaultProviderError("provider_conflict", "Proton Pass reported a conflict");
|
|
581
|
+
}
|
|
582
|
+
return new VaultProviderError("provider_unavailable", `Proton Pass returned HTTP ${status}`);
|
|
583
|
+
}
|
|
584
|
+
if (NOT_FOUND.test(stderr)) {
|
|
585
|
+
return new VaultProviderError("provider_not_found", "No such Proton Pass vault or item");
|
|
586
|
+
}
|
|
587
|
+
if (NETWORK.test(stderr)) {
|
|
588
|
+
return new VaultProviderError("provider_unavailable", "Proton Pass could not be reached");
|
|
589
|
+
}
|
|
590
|
+
return new VaultProviderError("provider_unavailable", `Proton Pass could not ${subject}`);
|
|
591
|
+
}
|
|
592
|
+
var PRE_FLIGHT = /Error parsing JSON|Error parsing template JSON|Error reading (?:from stdin|template)|Error converting template|requires an authenticated client|Agent sessions must set the PROTON_PASS_AGENT_REASON|PROTON_PASS_AGENT_REASON is (?:set but empty|too long)|Invalid field type|Invalid timestamp value|Please provide either|Error creating trash item request|Could not find vault|Share with id \S+ not found/;
|
|
593
|
+
function isDefiniteMutationFailure(result, mutationContext) {
|
|
594
|
+
if (result.spawnError) return true;
|
|
595
|
+
if (result.timedOut || result.aborted || result.signal !== null || result.stdoutOverflow) {
|
|
596
|
+
return false;
|
|
597
|
+
}
|
|
598
|
+
if (result.exitCode === null) return false;
|
|
599
|
+
const stderr = result.stderr;
|
|
600
|
+
if (SESSION_INVALIDATED.test(stderr)) return false;
|
|
601
|
+
if (PRE_FLIGHT.test(stderr)) return true;
|
|
602
|
+
return mutationContext.test(stderr) && isDefiniteRejection(stderr);
|
|
603
|
+
}
|
|
604
|
+
function outcomeUnknown(subject) {
|
|
605
|
+
return new VaultProviderError(
|
|
606
|
+
"provider_outcome_unknown",
|
|
607
|
+
`Proton Pass may or may not have completed "${subject}"; check the vault before retrying`
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
var LOGIN_CONTEXT = /Error logging in with personal access token/;
|
|
611
|
+
var LOGIN_REJECTED_STATUSES = /* @__PURE__ */ new Set([400, 401, 403, 422]);
|
|
612
|
+
function loginFailure(result) {
|
|
613
|
+
const stderr = result.stderr;
|
|
614
|
+
const answered = !result.spawnError && !result.timedOut && !result.aborted && LOGIN_CONTEXT.test(stderr);
|
|
615
|
+
if (answered) {
|
|
616
|
+
const status = protonStatus(stderr);
|
|
617
|
+
const reason = protonReason(stderr);
|
|
618
|
+
if (status !== null && LOGIN_REJECTED_STATUSES.has(status) || reason !== null && (PERMISSION_REASONS.has(reason) || NOT_FOUND_REASONS.has(reason))) {
|
|
619
|
+
return new VaultProviderError(
|
|
620
|
+
"provider_auth_invalid",
|
|
621
|
+
"Proton Pass rejected the personal access token (revoked, expired or mistyped)"
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
const mapped = mapProtonFailure(result, "sign in");
|
|
626
|
+
if (mapped.code === "provider_rate_limited") return mapped;
|
|
627
|
+
if (mapped.code === "provider_unavailable") return mapped;
|
|
628
|
+
return new VaultProviderError("provider_unavailable", "Proton Pass could not sign in");
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// src/runner.ts
|
|
632
|
+
import { spawn } from "child_process";
|
|
633
|
+
var KILL_GRACE_MS = 1e3;
|
|
634
|
+
var runProcess = (request) => new Promise((resolve2) => {
|
|
635
|
+
const stdout = [];
|
|
636
|
+
const stderr = [];
|
|
637
|
+
let stdoutBytes = 0;
|
|
638
|
+
let stderrBytes = 0;
|
|
639
|
+
let stdoutOverflow = false;
|
|
640
|
+
let timedOut = false;
|
|
641
|
+
let aborted = false;
|
|
642
|
+
let settled = false;
|
|
643
|
+
let killTimer;
|
|
644
|
+
if (request.signal?.aborted) {
|
|
645
|
+
resolve2({
|
|
646
|
+
exitCode: null,
|
|
647
|
+
signal: null,
|
|
648
|
+
stdout: "",
|
|
649
|
+
stderr: "",
|
|
650
|
+
timedOut: false,
|
|
651
|
+
aborted: true,
|
|
652
|
+
stdoutOverflow: false,
|
|
653
|
+
spawnError: null
|
|
654
|
+
});
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
const child = spawn(request.file, [...request.args], {
|
|
658
|
+
cwd: request.cwd,
|
|
659
|
+
env: { ...request.env },
|
|
660
|
+
shell: false,
|
|
661
|
+
detached: process.platform !== "win32",
|
|
662
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
663
|
+
windowsHide: true
|
|
664
|
+
});
|
|
665
|
+
const terminate = () => {
|
|
666
|
+
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
667
|
+
const kill = (sig) => {
|
|
668
|
+
try {
|
|
669
|
+
if (child.pid !== void 0 && process.platform !== "win32")
|
|
670
|
+
process.kill(-child.pid, sig);
|
|
671
|
+
else child.kill(sig);
|
|
672
|
+
} catch {
|
|
673
|
+
}
|
|
674
|
+
};
|
|
675
|
+
kill("SIGTERM");
|
|
676
|
+
killTimer ??= setTimeout(() => kill("SIGKILL"), KILL_GRACE_MS);
|
|
677
|
+
killTimer.unref?.();
|
|
678
|
+
};
|
|
679
|
+
const timer = setTimeout(() => {
|
|
680
|
+
timedOut = true;
|
|
681
|
+
terminate();
|
|
682
|
+
}, request.timeoutMs);
|
|
683
|
+
timer.unref?.();
|
|
684
|
+
const onAbort = () => {
|
|
685
|
+
aborted = true;
|
|
686
|
+
terminate();
|
|
687
|
+
};
|
|
688
|
+
request.signal?.addEventListener("abort", onAbort, { once: true });
|
|
689
|
+
const finish = (result) => {
|
|
690
|
+
if (settled) return;
|
|
691
|
+
settled = true;
|
|
692
|
+
clearTimeout(timer);
|
|
693
|
+
if (killTimer) clearTimeout(killTimer);
|
|
694
|
+
request.signal?.removeEventListener("abort", onAbort);
|
|
695
|
+
resolve2({
|
|
696
|
+
...result,
|
|
697
|
+
stdout: Buffer.concat(stdout).toString("utf8"),
|
|
698
|
+
stderr: Buffer.concat(stderr).toString("utf8"),
|
|
699
|
+
timedOut,
|
|
700
|
+
aborted,
|
|
701
|
+
stdoutOverflow
|
|
702
|
+
});
|
|
703
|
+
};
|
|
704
|
+
child.stdout.on("data", (chunk) => {
|
|
705
|
+
if (stdoutOverflow) return;
|
|
706
|
+
stdoutBytes += chunk.length;
|
|
707
|
+
if (stdoutBytes > request.maxStdoutBytes) {
|
|
708
|
+
stdoutOverflow = true;
|
|
709
|
+
terminate();
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
stdout.push(chunk);
|
|
713
|
+
});
|
|
714
|
+
child.stderr.on("data", (chunk) => {
|
|
715
|
+
if (stderrBytes >= request.maxStderrBytes) return;
|
|
716
|
+
const room = request.maxStderrBytes - stderrBytes;
|
|
717
|
+
const kept = chunk.length > room ? chunk.subarray(0, room) : chunk;
|
|
718
|
+
stderrBytes += kept.length;
|
|
719
|
+
stderr.push(kept);
|
|
720
|
+
});
|
|
721
|
+
child.on("error", (error) => {
|
|
722
|
+
finish({ exitCode: null, signal: null, spawnError: error.code ?? "spawn_failed" });
|
|
723
|
+
});
|
|
724
|
+
child.on("close", (exitCode, signal) => {
|
|
725
|
+
finish({ exitCode, signal, spawnError: null });
|
|
726
|
+
});
|
|
727
|
+
child.stdin.on("error", () => {
|
|
728
|
+
});
|
|
729
|
+
child.stdin.end(request.stdin ?? "");
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
// src/session.ts
|
|
733
|
+
var PROTON_PASS_CLI_VERSION = "2.4.2";
|
|
734
|
+
var PROTON_PASS_SESSION_MAX_AGE_MS = 105 * 6e4;
|
|
735
|
+
var PROTON_PASS_IDLE_CLOSE_MS = 15 * 6e4;
|
|
736
|
+
var PROTON_PASS_COMMAND_TIMEOUT_MS = 6e4;
|
|
737
|
+
var PROTON_PASS_MAX_STDOUT_BYTES = 32 * 1024 * 1024;
|
|
738
|
+
var PROTON_PASS_MAX_REASON_CHARS = 300;
|
|
739
|
+
var PROTON_PASS_MAX_PROCESSES = 4;
|
|
740
|
+
var MAX_STDERR_BYTES = 16 * 1024;
|
|
741
|
+
var SMALL_STDOUT_BYTES = 256 * 1024;
|
|
742
|
+
var PASSTHROUGH_ENV = [
|
|
743
|
+
"HTTPS_PROXY",
|
|
744
|
+
"HTTP_PROXY",
|
|
745
|
+
"NO_PROXY",
|
|
746
|
+
"https_proxy",
|
|
747
|
+
"http_proxy",
|
|
748
|
+
"no_proxy",
|
|
749
|
+
"SSL_CERT_FILE",
|
|
750
|
+
"SSL_CERT_DIR"
|
|
751
|
+
];
|
|
752
|
+
function createSlotSemaphore(limit) {
|
|
753
|
+
let active = 0;
|
|
754
|
+
const waiters = [];
|
|
755
|
+
function releaser() {
|
|
756
|
+
let released = false;
|
|
757
|
+
return () => {
|
|
758
|
+
if (released) return;
|
|
759
|
+
released = true;
|
|
760
|
+
const next = waiters.shift();
|
|
761
|
+
if (next) next();
|
|
762
|
+
else active -= 1;
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
return {
|
|
766
|
+
async acquire(signal) {
|
|
767
|
+
if (active < limit) {
|
|
768
|
+
active += 1;
|
|
769
|
+
return releaser();
|
|
770
|
+
}
|
|
771
|
+
await new Promise((resolveSlot, reject) => {
|
|
772
|
+
if (signal?.aborted) {
|
|
773
|
+
reject(signal.reason ?? new Error("aborted"));
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
const ready = () => {
|
|
777
|
+
signal?.removeEventListener("abort", onAbort);
|
|
778
|
+
resolveSlot();
|
|
779
|
+
};
|
|
780
|
+
const onAbort = () => {
|
|
781
|
+
const index = waiters.indexOf(ready);
|
|
782
|
+
if (index >= 0) waiters.splice(index, 1);
|
|
783
|
+
reject(signal?.reason ?? new Error("aborted"));
|
|
784
|
+
};
|
|
785
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
786
|
+
waiters.push(ready);
|
|
787
|
+
});
|
|
788
|
+
return releaser();
|
|
789
|
+
},
|
|
790
|
+
active: () => active,
|
|
791
|
+
waiting: () => waiters.length
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
var processSlots = createSlotSemaphore(PROTON_PASS_MAX_PROCESSES);
|
|
795
|
+
function acquireProcessSlot(signal) {
|
|
796
|
+
return processSlots.acquire(signal);
|
|
797
|
+
}
|
|
798
|
+
var liveStateDirs = /* @__PURE__ */ new Set();
|
|
799
|
+
var exitHookInstalled = false;
|
|
800
|
+
function trackStateDir(dir) {
|
|
801
|
+
liveStateDirs.add(dir);
|
|
802
|
+
if (exitHookInstalled) return;
|
|
803
|
+
exitHookInstalled = true;
|
|
804
|
+
process.once("exit", () => {
|
|
805
|
+
for (const path of liveStateDirs) {
|
|
806
|
+
try {
|
|
807
|
+
rmSync(path, { recursive: true, force: true });
|
|
808
|
+
} catch {
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
function unavailable(message) {
|
|
814
|
+
return new VaultProviderError("provider_unavailable", message);
|
|
815
|
+
}
|
|
816
|
+
function findOnPath(command, pathValue) {
|
|
817
|
+
for (const dir of (pathValue ?? "").split(delimiter)) {
|
|
818
|
+
if (!dir || !isAbsolute(dir)) continue;
|
|
819
|
+
const candidate = join(dir, command);
|
|
820
|
+
try {
|
|
821
|
+
accessSync(candidate, fsConstants.X_OK);
|
|
822
|
+
if (statSync(candidate).isFile()) return candidate;
|
|
823
|
+
} catch {
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
return null;
|
|
827
|
+
}
|
|
828
|
+
async function sha256File(path) {
|
|
829
|
+
const hash = createHash("sha256");
|
|
830
|
+
for await (const chunk of createReadStream(path)) hash.update(chunk);
|
|
831
|
+
return hash.digest("hex");
|
|
832
|
+
}
|
|
833
|
+
function ensurePrivateDirectory(path) {
|
|
834
|
+
mkdirSync(path, { recursive: true, mode: 448 });
|
|
835
|
+
const stat = lstatSync(path);
|
|
836
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
837
|
+
throw unavailable("The Proton Pass state directory is not a private directory");
|
|
838
|
+
}
|
|
839
|
+
const uid = process.getuid?.();
|
|
840
|
+
if (uid !== void 0 && stat.uid !== uid) {
|
|
841
|
+
throw unavailable("The Proton Pass state directory is owned by another user");
|
|
842
|
+
}
|
|
843
|
+
if ((stat.mode & 63) !== 0) chmodSync(path, 448);
|
|
844
|
+
}
|
|
845
|
+
function agentReason(subject, authority) {
|
|
846
|
+
const clean = (value) => value.replace(/[^A-Za-z0-9:._-]/g, "").slice(0, 64);
|
|
847
|
+
const parts = [`Opengeni ${subject.replace(/[^A-Za-z0-9 ._-]/g, "")}`];
|
|
848
|
+
if (authority) {
|
|
849
|
+
parts.push(`workspace ${clean(authority.workspaceId)}`);
|
|
850
|
+
if (authority.sessionId) parts.push(`session ${clean(authority.sessionId)}`);
|
|
851
|
+
if (authority.turnId) parts.push(`turn ${clean(authority.turnId)}`);
|
|
852
|
+
if (authority.subjectId) parts.push(`subject ${clean(authority.subjectId)}`);
|
|
853
|
+
}
|
|
854
|
+
return parts.join("; ").slice(0, PROTON_PASS_MAX_REASON_CHARS);
|
|
855
|
+
}
|
|
856
|
+
function succeeded(result) {
|
|
857
|
+
return result.exitCode === 0 && result.signal === null && !result.timedOut && !result.aborted && !result.stdoutOverflow && result.spawnError === null;
|
|
858
|
+
}
|
|
859
|
+
var ProtonCliSession = class {
|
|
860
|
+
constructor(token, options = {}) {
|
|
861
|
+
this.token = token;
|
|
862
|
+
this.options = options;
|
|
863
|
+
this.runner = options.runner ?? runProcess;
|
|
864
|
+
this.now = options.now ?? Date.now;
|
|
865
|
+
this.hostEnv = options.hostEnv ?? process.env;
|
|
866
|
+
}
|
|
867
|
+
runner;
|
|
868
|
+
now;
|
|
869
|
+
hostEnv;
|
|
870
|
+
encryptionKey = randomBytes(32).toString("hex");
|
|
871
|
+
binary = null;
|
|
872
|
+
dir = null;
|
|
873
|
+
loggedInAt = null;
|
|
874
|
+
info = null;
|
|
875
|
+
queue = Promise.resolve();
|
|
876
|
+
pending = 0;
|
|
877
|
+
idleTimer = null;
|
|
878
|
+
closed = false;
|
|
879
|
+
/** The state directory while a session is open (tests and diagnostics). */
|
|
880
|
+
get stateDirectory() {
|
|
881
|
+
return this.dir;
|
|
882
|
+
}
|
|
883
|
+
get sessionInfo() {
|
|
884
|
+
return this.info;
|
|
885
|
+
}
|
|
886
|
+
/** Runs one pass-cli command after ensuring a fresh login; returns stdout. */
|
|
887
|
+
run(command) {
|
|
888
|
+
return this.serialize(async () => (await this.execute(command)).stdout);
|
|
889
|
+
}
|
|
890
|
+
/** Login state for `validate`: logs in when needed and returns the token identity. */
|
|
891
|
+
ensureSession(signal) {
|
|
892
|
+
return this.serialize(async () => {
|
|
893
|
+
await this.ensureLogin(signal);
|
|
894
|
+
return this.info;
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
/** Allocates a private directory inside the session state for one download. */
|
|
898
|
+
privateScratchDir() {
|
|
899
|
+
const dir = this.requireDir();
|
|
900
|
+
return mkdtempSync(join(dir, "dl-"));
|
|
901
|
+
}
|
|
902
|
+
/** Logs out (best effort) and wipes the state directory. */
|
|
903
|
+
async close() {
|
|
904
|
+
this.closed = true;
|
|
905
|
+
if (this.idleTimer) clearTimeout(this.idleTimer);
|
|
906
|
+
this.idleTimer = null;
|
|
907
|
+
await this.queue.catch(() => {
|
|
908
|
+
});
|
|
909
|
+
await this.teardown();
|
|
910
|
+
}
|
|
911
|
+
serialize(work) {
|
|
912
|
+
if (this.closed) return Promise.reject(unavailable("The Proton Pass connection is closed"));
|
|
913
|
+
this.pending += 1;
|
|
914
|
+
if (this.idleTimer) clearTimeout(this.idleTimer);
|
|
915
|
+
this.idleTimer = null;
|
|
916
|
+
const run = this.queue.then(work, work);
|
|
917
|
+
this.queue = run.then(
|
|
918
|
+
() => void 0,
|
|
919
|
+
() => void 0
|
|
920
|
+
);
|
|
921
|
+
return run.finally(() => {
|
|
922
|
+
this.pending -= 1;
|
|
923
|
+
if (this.pending === 0 && !this.closed) this.armIdleClose();
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
armIdleClose() {
|
|
927
|
+
if (this.dir === null) return;
|
|
928
|
+
const idleMs = this.options.idleCloseMs ?? PROTON_PASS_IDLE_CLOSE_MS;
|
|
929
|
+
this.idleTimer = setTimeout(() => {
|
|
930
|
+
this.idleTimer = null;
|
|
931
|
+
if (this.pending > 0 || this.closed) return;
|
|
932
|
+
void this.serialize(() => this.teardown()).catch(() => {
|
|
933
|
+
});
|
|
934
|
+
}, idleMs);
|
|
935
|
+
this.idleTimer.unref?.();
|
|
936
|
+
}
|
|
937
|
+
async teardown() {
|
|
938
|
+
const dir = this.dir;
|
|
939
|
+
if (!dir) return;
|
|
940
|
+
if (this.loggedInAt !== null) {
|
|
941
|
+
try {
|
|
942
|
+
await this.spawn({ args: ["logout"], subject: "sign out" }, false, 1e4);
|
|
943
|
+
} catch {
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
this.loggedInAt = null;
|
|
947
|
+
this.info = null;
|
|
948
|
+
this.dir = null;
|
|
949
|
+
liveStateDirs.delete(dir);
|
|
950
|
+
rmSync(dir, { recursive: true, force: true });
|
|
951
|
+
}
|
|
952
|
+
requireDir() {
|
|
953
|
+
if (this.dir) return this.dir;
|
|
954
|
+
const root = resolve(this.options.stateRoot ?? join(tmpdir(), "opengeni-protonpass"));
|
|
955
|
+
ensurePrivateDirectory(root);
|
|
956
|
+
const dir = mkdtempSync(join(root, "pp-"));
|
|
957
|
+
ensurePrivateDirectory(dir);
|
|
958
|
+
trackStateDir(dir);
|
|
959
|
+
this.dir = dir;
|
|
960
|
+
return dir;
|
|
961
|
+
}
|
|
962
|
+
sessionDir() {
|
|
963
|
+
return join(this.requireDir(), "session");
|
|
964
|
+
}
|
|
965
|
+
baseEnv() {
|
|
966
|
+
const dir = this.requireDir();
|
|
967
|
+
const env = {
|
|
968
|
+
HOME: dir,
|
|
969
|
+
TZ: "UTC",
|
|
970
|
+
LANG: "C.UTF-8",
|
|
971
|
+
LC_ALL: "C.UTF-8",
|
|
972
|
+
PATH: "/usr/bin:/bin",
|
|
973
|
+
PROTON_PASS_SESSION_DIR: this.sessionDir(),
|
|
974
|
+
PROTON_PASS_KEY_PROVIDER: "env",
|
|
975
|
+
PROTON_PASS_ENCRYPTION_KEY: this.encryptionKey,
|
|
976
|
+
PROTON_PASS_DISABLE_TELEMETRY: "1",
|
|
977
|
+
PROTON_PASS_NO_UPDATE_CHECK: "1",
|
|
978
|
+
PASS_LOG_LEVEL: "off"
|
|
979
|
+
};
|
|
980
|
+
for (const name of PASSTHROUGH_ENV) {
|
|
981
|
+
const value = this.hostEnv[name];
|
|
982
|
+
if (value) env[name] = value;
|
|
983
|
+
}
|
|
984
|
+
return env;
|
|
985
|
+
}
|
|
986
|
+
async resolveBinary() {
|
|
987
|
+
this.binary ??= (async () => {
|
|
988
|
+
const configured = this.options.binaryPath;
|
|
989
|
+
if (configured !== void 0 && !isAbsolute(configured)) {
|
|
990
|
+
throw unavailable("The pass-cli path must be absolute");
|
|
991
|
+
}
|
|
992
|
+
const found = configured ?? findOnPath("pass-cli", this.hostEnv.PATH);
|
|
993
|
+
if (!found) throw unavailable("pass-cli is not installed on this Opengeni server");
|
|
994
|
+
let path;
|
|
995
|
+
try {
|
|
996
|
+
path = realpathSync(found);
|
|
997
|
+
const stat = statSync(path);
|
|
998
|
+
if (!stat.isFile()) throw new Error("not a file");
|
|
999
|
+
if ((stat.mode & 2) !== 0) {
|
|
1000
|
+
throw unavailable("pass-cli is world-writable; refusing to run it");
|
|
1001
|
+
}
|
|
1002
|
+
accessSync(path, fsConstants.X_OK);
|
|
1003
|
+
} catch (error) {
|
|
1004
|
+
if (error instanceof VaultProviderError) throw error;
|
|
1005
|
+
throw unavailable("pass-cli is not an executable file");
|
|
1006
|
+
}
|
|
1007
|
+
const expectedSha = this.options.expectedSha256?.toLowerCase();
|
|
1008
|
+
if (expectedSha && await sha256File(path) !== expectedSha) {
|
|
1009
|
+
throw unavailable("pass-cli does not match the configured SHA-256");
|
|
1010
|
+
}
|
|
1011
|
+
const expected = this.options.expectedVersion ?? PROTON_PASS_CLI_VERSION;
|
|
1012
|
+
const result = await this.runner({
|
|
1013
|
+
file: path,
|
|
1014
|
+
args: ["--version"],
|
|
1015
|
+
env: { HOME: this.requireDir(), PATH: "/usr/bin:/bin", LANG: "C.UTF-8" },
|
|
1016
|
+
cwd: this.requireDir(),
|
|
1017
|
+
timeoutMs: 1e4,
|
|
1018
|
+
maxStdoutBytes: 4096,
|
|
1019
|
+
maxStderrBytes: 4096
|
|
1020
|
+
});
|
|
1021
|
+
const version = /\b(\d+\.\d+\.\d+)\b/.exec(result.stdout)?.[1];
|
|
1022
|
+
if (!succeeded(result) || !version) {
|
|
1023
|
+
throw unavailable("pass-cli --version failed");
|
|
1024
|
+
}
|
|
1025
|
+
if (version !== expected) {
|
|
1026
|
+
throw unavailable(
|
|
1027
|
+
`pass-cli ${version} is not supported; install pass-cli ${expected} for this Opengeni release`
|
|
1028
|
+
);
|
|
1029
|
+
}
|
|
1030
|
+
return path;
|
|
1031
|
+
})();
|
|
1032
|
+
this.binary.catch(() => {
|
|
1033
|
+
this.binary = null;
|
|
1034
|
+
});
|
|
1035
|
+
return this.binary;
|
|
1036
|
+
}
|
|
1037
|
+
async spawn(command, withToken, timeoutMs) {
|
|
1038
|
+
const file = await this.resolveBinary();
|
|
1039
|
+
const env = this.baseEnv();
|
|
1040
|
+
if (withToken) env.PROTON_PASS_PERSONAL_ACCESS_TOKEN = this.token;
|
|
1041
|
+
else env.PROTON_PASS_AGENT_REASON = agentReason(command.subject, command.authority);
|
|
1042
|
+
const release = await acquireProcessSlot(command.signal);
|
|
1043
|
+
try {
|
|
1044
|
+
return await this.runner({
|
|
1045
|
+
file,
|
|
1046
|
+
args: command.args,
|
|
1047
|
+
env,
|
|
1048
|
+
cwd: this.requireDir(),
|
|
1049
|
+
...command.stdin !== void 0 ? { stdin: command.stdin } : {},
|
|
1050
|
+
timeoutMs: timeoutMs ?? this.options.commandTimeoutMs ?? PROTON_PASS_COMMAND_TIMEOUT_MS,
|
|
1051
|
+
maxStdoutBytes: command.maxStdoutBytes ?? this.options.maxStdoutBytes ?? PROTON_PASS_MAX_STDOUT_BYTES,
|
|
1052
|
+
maxStderrBytes: MAX_STDERR_BYTES,
|
|
1053
|
+
...command.signal ? { signal: command.signal } : {}
|
|
1054
|
+
});
|
|
1055
|
+
} finally {
|
|
1056
|
+
release();
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
sessionFresh() {
|
|
1060
|
+
if (this.loggedInAt === null || this.info === null) return false;
|
|
1061
|
+
const maxAge = this.options.sessionMaxAgeMs ?? PROTON_PASS_SESSION_MAX_AGE_MS;
|
|
1062
|
+
return this.now() - this.loggedInAt < maxAge;
|
|
1063
|
+
}
|
|
1064
|
+
async ensureLogin(signal) {
|
|
1065
|
+
if (this.sessionFresh()) return;
|
|
1066
|
+
await this.login(signal);
|
|
1067
|
+
}
|
|
1068
|
+
async login(signal) {
|
|
1069
|
+
this.loggedInAt = null;
|
|
1070
|
+
this.info = null;
|
|
1071
|
+
const sessionDir = this.sessionDir();
|
|
1072
|
+
rmSync(sessionDir, { recursive: true, force: true });
|
|
1073
|
+
mkdirSync(sessionDir, { mode: 448 });
|
|
1074
|
+
const result = await this.spawn(
|
|
1075
|
+
{
|
|
1076
|
+
args: ["login"],
|
|
1077
|
+
subject: "sign in",
|
|
1078
|
+
maxStdoutBytes: SMALL_STDOUT_BYTES,
|
|
1079
|
+
...signal ? { signal } : {}
|
|
1080
|
+
},
|
|
1081
|
+
true
|
|
1082
|
+
);
|
|
1083
|
+
if (!succeeded(result)) {
|
|
1084
|
+
signal?.throwIfAborted();
|
|
1085
|
+
throw loginFailure(result);
|
|
1086
|
+
}
|
|
1087
|
+
this.loggedInAt = this.now();
|
|
1088
|
+
const infoResult = await this.spawn(
|
|
1089
|
+
{
|
|
1090
|
+
args: ["info", "--output", "json"],
|
|
1091
|
+
subject: "read session info",
|
|
1092
|
+
maxStdoutBytes: SMALL_STDOUT_BYTES,
|
|
1093
|
+
...signal ? { signal } : {}
|
|
1094
|
+
},
|
|
1095
|
+
false
|
|
1096
|
+
);
|
|
1097
|
+
if (!succeeded(infoResult)) {
|
|
1098
|
+
this.loggedInAt = null;
|
|
1099
|
+
signal?.throwIfAborted();
|
|
1100
|
+
throw mapProtonFailure(infoResult, "read session info");
|
|
1101
|
+
}
|
|
1102
|
+
const parsed = InfoOutput.safeParse(parseJson(infoResult.stdout));
|
|
1103
|
+
if (!parsed.success) {
|
|
1104
|
+
this.loggedInAt = null;
|
|
1105
|
+
throw unavailable("pass-cli returned unexpected session info");
|
|
1106
|
+
}
|
|
1107
|
+
if (parsed.data.username !== void 0) {
|
|
1108
|
+
this.loggedInAt = null;
|
|
1109
|
+
throw new VaultProviderError(
|
|
1110
|
+
"provider_auth_invalid",
|
|
1111
|
+
"The Proton Pass session is not a personal access token session"
|
|
1112
|
+
);
|
|
1113
|
+
}
|
|
1114
|
+
const tokenName = parsed.data.personal_access_token_name ?? null;
|
|
1115
|
+
this.info = {
|
|
1116
|
+
tokenName: tokenName?.replace(/^\[Agent\] /, "") ?? null,
|
|
1117
|
+
agent: tokenName?.startsWith("[Agent] ") ?? false
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
async execute(command) {
|
|
1121
|
+
command.signal?.throwIfAborted();
|
|
1122
|
+
await this.ensureLogin(command.signal);
|
|
1123
|
+
let result = await this.spawn(command, false);
|
|
1124
|
+
if (!succeeded(result) && !result.timedOut && !result.aborted) {
|
|
1125
|
+
if (NOT_AUTHENTICATED.test(result.stderr)) {
|
|
1126
|
+
await this.login(command.signal);
|
|
1127
|
+
result = await this.spawn(command, false);
|
|
1128
|
+
} else if (SESSION_INVALIDATED.test(result.stderr)) {
|
|
1129
|
+
this.loggedInAt = null;
|
|
1130
|
+
this.info = null;
|
|
1131
|
+
if (command.mutationContext) throw outcomeUnknown(command.subject);
|
|
1132
|
+
await this.login(command.signal);
|
|
1133
|
+
result = await this.spawn(command, false);
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
if (succeeded(result)) return result;
|
|
1137
|
+
if (SESSION_INVALIDATED.test(result.stderr) || NOT_AUTHENTICATED.test(result.stderr)) {
|
|
1138
|
+
this.loggedInAt = null;
|
|
1139
|
+
this.info = null;
|
|
1140
|
+
}
|
|
1141
|
+
if (command.mutationContext) {
|
|
1142
|
+
if (!isDefiniteMutationFailure(result, command.mutationContext)) {
|
|
1143
|
+
throw outcomeUnknown(command.subject);
|
|
1144
|
+
}
|
|
1145
|
+
throw mapProtonFailure(result, command.subject);
|
|
1146
|
+
}
|
|
1147
|
+
if (result.aborted) command.signal?.throwIfAborted();
|
|
1148
|
+
if (result.stdoutOverflow) {
|
|
1149
|
+
throw new VaultProviderError(
|
|
1150
|
+
command.overflowCode ?? "provider_unavailable",
|
|
1151
|
+
`Proton Pass returned more data than Opengeni reads at once (${command.subject})`
|
|
1152
|
+
);
|
|
1153
|
+
}
|
|
1154
|
+
throw mapProtonFailure(result, command.subject);
|
|
1155
|
+
}
|
|
1156
|
+
};
|
|
1157
|
+
function parseJson(text) {
|
|
1158
|
+
try {
|
|
1159
|
+
return JSON.parse(text);
|
|
1160
|
+
} catch {
|
|
1161
|
+
return void 0;
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
// src/provider.ts
|
|
1166
|
+
var PROTON_PASS_VALUE_CACHE_MS = VAULT_LIMITS.providerValueCacheMs;
|
|
1167
|
+
var PROTON_PASS_MAX_ITEM_VIEWS_PER_LIST = 500;
|
|
1168
|
+
var PROTON_PASS_OPERATIONS = {
|
|
1169
|
+
createItemTypes: ["login", "note", "api_key"],
|
|
1170
|
+
updateItem: false,
|
|
1171
|
+
removeField: false,
|
|
1172
|
+
archiveItem: true,
|
|
1173
|
+
archiveKind: "trash",
|
|
1174
|
+
deleteItem: false,
|
|
1175
|
+
restoreItem: false,
|
|
1176
|
+
readFiles: true,
|
|
1177
|
+
writeFiles: false,
|
|
1178
|
+
createTotp: true,
|
|
1179
|
+
updateTotp: false,
|
|
1180
|
+
createVault: false,
|
|
1181
|
+
restoreFieldHistory: false
|
|
1182
|
+
};
|
|
1183
|
+
function protonPassCapabilities() {
|
|
1184
|
+
return {
|
|
1185
|
+
write: true,
|
|
1186
|
+
createVault: false,
|
|
1187
|
+
totp: true,
|
|
1188
|
+
// Legacy flag means file writes; reads are declared in operations.readFiles.
|
|
1189
|
+
files: false,
|
|
1190
|
+
groupGrants: false,
|
|
1191
|
+
passkeys: false,
|
|
1192
|
+
operations: {
|
|
1193
|
+
...PROTON_PASS_OPERATIONS,
|
|
1194
|
+
createItemTypes: [...PROTON_PASS_OPERATIONS.createItemTypes]
|
|
1195
|
+
}
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
var PROTON_ID = /^[A-Za-z0-9_=+/][A-Za-z0-9_=+/-]{0,255}$/;
|
|
1199
|
+
function assertIds(...ids) {
|
|
1200
|
+
for (const id of ids) {
|
|
1201
|
+
if (!PROTON_ID.test(id)) {
|
|
1202
|
+
throw new VaultProviderError("provider_not_found", "No such Proton Pass vault or item");
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
var TOKEN_SHAPE = /^[\x21-\x7e]{8,4096}$/;
|
|
1207
|
+
function writeAccessFor(role) {
|
|
1208
|
+
if (typeof role === "string") return role === "Viewer" ? "read_only" : "read_write";
|
|
1209
|
+
return (role.Custom.permission & SHARE_PERMISSION.create) !== 0 ? "read_write" : "read_only";
|
|
1210
|
+
}
|
|
1211
|
+
function operationsFor(role) {
|
|
1212
|
+
if (typeof role === "string") return void 0;
|
|
1213
|
+
const permission = role.Custom.permission;
|
|
1214
|
+
const canCreate = (permission & SHARE_PERMISSION.create) !== 0;
|
|
1215
|
+
const canTrash = (permission & SHARE_PERMISSION.trash) !== 0;
|
|
1216
|
+
return {
|
|
1217
|
+
...PROTON_PASS_OPERATIONS,
|
|
1218
|
+
createItemTypes: canCreate ? [...PROTON_PASS_OPERATIONS.createItemTypes] : [],
|
|
1219
|
+
createTotp: canCreate,
|
|
1220
|
+
archiveItem: canTrash,
|
|
1221
|
+
archiveKind: canTrash ? "trash" : "none"
|
|
1222
|
+
};
|
|
1223
|
+
}
|
|
1224
|
+
function utf8Bytes(value) {
|
|
1225
|
+
return Buffer.byteLength(value, "utf8");
|
|
1226
|
+
}
|
|
1227
|
+
function refuse(message) {
|
|
1228
|
+
throw new VaultProviderError("provider_unavailable", message);
|
|
1229
|
+
}
|
|
1230
|
+
function fieldValue(field) {
|
|
1231
|
+
if (field.remove) refuse("Proton Pass items cannot have fields removed from Opengeni");
|
|
1232
|
+
const value = field.value ?? "";
|
|
1233
|
+
if (utf8Bytes(value) > VAULT_LIMITS.textValueMaxBytes) {
|
|
1234
|
+
refuse(`The value of "${field.name}" is larger than Opengeni stores`);
|
|
1235
|
+
}
|
|
1236
|
+
return value;
|
|
1237
|
+
}
|
|
1238
|
+
function totpValue(field, label) {
|
|
1239
|
+
try {
|
|
1240
|
+
return totpUri(parseTotpInput(fieldValue(field)), label);
|
|
1241
|
+
} catch (error) {
|
|
1242
|
+
if (error instanceof VaultProviderError) throw error;
|
|
1243
|
+
throw new VaultProviderError(
|
|
1244
|
+
"provider_unavailable",
|
|
1245
|
+
`"${field.name}" is not a TOTP seed or otpauth:// URI`
|
|
1246
|
+
);
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
function planCreate(item) {
|
|
1250
|
+
const title = item.name;
|
|
1251
|
+
if (item.type === "login") {
|
|
1252
|
+
let username = null;
|
|
1253
|
+
let email = null;
|
|
1254
|
+
let password = null;
|
|
1255
|
+
let totp = null;
|
|
1256
|
+
for (const field of item.fields) {
|
|
1257
|
+
const key = nameKey(field.name);
|
|
1258
|
+
if (field.kind === "totp") {
|
|
1259
|
+
if (totp !== null) refuse("Proton Pass logins hold one one-time password");
|
|
1260
|
+
totp = totpValue(field, title);
|
|
1261
|
+
} else if (field.kind === "text" && key === "username" && username === null) {
|
|
1262
|
+
username = fieldValue(field);
|
|
1263
|
+
} else if (field.kind === "text" && key === "email" && email === null) {
|
|
1264
|
+
email = fieldValue(field);
|
|
1265
|
+
} else if (field.kind === "concealed" && key === "password" && password === null) {
|
|
1266
|
+
password = fieldValue(field);
|
|
1267
|
+
} else {
|
|
1268
|
+
refuse(
|
|
1269
|
+
`Proton Pass logins created from Opengeni hold a username, email, password and one one-time password; "${field.name}" needs an API key (custom) item`
|
|
1270
|
+
);
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
const urls = item.websites.filter((url) => url.trim().length > 0);
|
|
1274
|
+
return {
|
|
1275
|
+
kind: "login",
|
|
1276
|
+
template: { title, username, email, password, totp_uri: totp, urls },
|
|
1277
|
+
synthetic: {
|
|
1278
|
+
Login: {
|
|
1279
|
+
username: username ?? "",
|
|
1280
|
+
email: email ?? "",
|
|
1281
|
+
password: password ?? "",
|
|
1282
|
+
totp_uri: totp ?? "",
|
|
1283
|
+
urls
|
|
1284
|
+
}
|
|
1285
|
+
},
|
|
1286
|
+
note: "",
|
|
1287
|
+
extraSections: []
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
if (item.type === "note") {
|
|
1291
|
+
if (item.fields.length > 1) refuse("A Proton Pass note holds one note field");
|
|
1292
|
+
const field = item.fields[0];
|
|
1293
|
+
if (field && field.kind !== "text" && field.kind !== "concealed") {
|
|
1294
|
+
refuse("A Proton Pass note holds text only");
|
|
1295
|
+
}
|
|
1296
|
+
const note = field ? fieldValue(field) : "";
|
|
1297
|
+
return {
|
|
1298
|
+
kind: "note",
|
|
1299
|
+
template: { title, note },
|
|
1300
|
+
synthetic: { Note: null },
|
|
1301
|
+
note,
|
|
1302
|
+
extraSections: []
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
if (item.type === "api_key") {
|
|
1306
|
+
const fields = item.fields.map((field) => {
|
|
1307
|
+
if (field.kind === "file") refuse("Proton Pass files cannot be written from Opengeni");
|
|
1308
|
+
const fieldType = field.kind === "totp" ? "totp" : field.kind === "concealed" ? "hidden" : "text";
|
|
1309
|
+
const value = field.kind === "totp" ? totpValue(field, title) : fieldValue(field);
|
|
1310
|
+
return { field_name: field.name, field_type: fieldType, value };
|
|
1311
|
+
});
|
|
1312
|
+
const sections = fields.length ? [{ section_name: "Fields", fields }] : [];
|
|
1313
|
+
return {
|
|
1314
|
+
kind: "custom",
|
|
1315
|
+
template: { title, note: null, sections },
|
|
1316
|
+
synthetic: {
|
|
1317
|
+
Custom: {
|
|
1318
|
+
sections: sections.map((section) => ({
|
|
1319
|
+
section_name: section.section_name,
|
|
1320
|
+
section_fields: section.fields.map((field) => ({
|
|
1321
|
+
name: field.field_name,
|
|
1322
|
+
content: field.field_type === "totp" ? { Totp: field.value } : field.field_type === "hidden" ? { Hidden: field.value } : { Text: field.value }
|
|
1323
|
+
}))
|
|
1324
|
+
}))
|
|
1325
|
+
}
|
|
1326
|
+
},
|
|
1327
|
+
note: "",
|
|
1328
|
+
extraSections: []
|
|
1329
|
+
};
|
|
1330
|
+
}
|
|
1331
|
+
return refuse(
|
|
1332
|
+
item.type === "passkey" ? "Passkeys live only in Opengeni vaults" : "Proton Pass file items cannot be created from Opengeni"
|
|
1333
|
+
);
|
|
1334
|
+
}
|
|
1335
|
+
var CREATE_CONTEXT = {
|
|
1336
|
+
login: /Error creating login item/,
|
|
1337
|
+
note: /Error creating note item/,
|
|
1338
|
+
custom: /Error creating custom item/
|
|
1339
|
+
};
|
|
1340
|
+
function createProtonPassVaultProvider(options) {
|
|
1341
|
+
const token = options.token.trim();
|
|
1342
|
+
if (!TOKEN_SHAPE.test(token)) {
|
|
1343
|
+
throw new VaultProviderError(
|
|
1344
|
+
"provider_auth_invalid",
|
|
1345
|
+
"A Proton Pass personal access token looks like pst_\u2026::\u2026"
|
|
1346
|
+
);
|
|
1347
|
+
}
|
|
1348
|
+
const session = new ProtonCliSession(token, options);
|
|
1349
|
+
const now = options.now ?? Date.now;
|
|
1350
|
+
const valueCacheMs = options.valueCacheMs ?? PROTON_PASS_VALUE_CACHE_MS;
|
|
1351
|
+
const attemptCaches = /* @__PURE__ */ new WeakMap();
|
|
1352
|
+
const maxViews = options.maxItemViewsPerList ?? PROTON_PASS_MAX_ITEM_VIEWS_PER_LIST;
|
|
1353
|
+
const shareAccess = /* @__PURE__ */ new Map();
|
|
1354
|
+
function cli(ctx, op, command) {
|
|
1355
|
+
ctx.signal?.throwIfAborted();
|
|
1356
|
+
ctx.budget.take(op);
|
|
1357
|
+
return session.run({
|
|
1358
|
+
...command,
|
|
1359
|
+
...ctx.authority ? { authority: ctx.authority } : {},
|
|
1360
|
+
...ctx.signal ? { signal: ctx.signal } : {}
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1363
|
+
function parseOutput(stdout, schema, code) {
|
|
1364
|
+
const parsed = schema.safeParse(parseJson(stdout));
|
|
1365
|
+
if (!parsed.success) {
|
|
1366
|
+
throw new VaultProviderError(
|
|
1367
|
+
code,
|
|
1368
|
+
"pass-cli returned output this Opengeni release does not understand"
|
|
1369
|
+
);
|
|
1370
|
+
}
|
|
1371
|
+
return parsed.data;
|
|
1372
|
+
}
|
|
1373
|
+
function viewItem(ctx, shareId, itemId, subject) {
|
|
1374
|
+
return cli(ctx, "read", {
|
|
1375
|
+
args: ["item", "view", `--share-id=${shareId}`, `--item-id=${itemId}`, "--output", "json"],
|
|
1376
|
+
subject
|
|
1377
|
+
}).then((stdout) => parseOutput(stdout, ItemDetails, "provider_unavailable"));
|
|
1378
|
+
}
|
|
1379
|
+
function cachedItem(ctx, shareId, itemId) {
|
|
1380
|
+
let cache = attemptCaches.get(ctx.budget);
|
|
1381
|
+
if (!cache) {
|
|
1382
|
+
cache = /* @__PURE__ */ new Map();
|
|
1383
|
+
attemptCaches.set(ctx.budget, cache);
|
|
1384
|
+
}
|
|
1385
|
+
const key = `${ctx.cacheEpoch ?? ""}|${shareId}/${itemId}`;
|
|
1386
|
+
const cached = cache.get(key);
|
|
1387
|
+
if (cached && !ctx.forceRefresh && now() - cached.at < valueCacheMs) return cached.details;
|
|
1388
|
+
const details = viewItem(ctx, shareId, itemId, "read item");
|
|
1389
|
+
const entry = { at: now(), details };
|
|
1390
|
+
cache.set(key, entry);
|
|
1391
|
+
details.catch(() => {
|
|
1392
|
+
if (cache.get(key) === entry) cache.delete(key);
|
|
1393
|
+
});
|
|
1394
|
+
return details;
|
|
1395
|
+
}
|
|
1396
|
+
function forgetItem(ctx, shareId, itemId) {
|
|
1397
|
+
const cache = attemptCaches.get(ctx.budget);
|
|
1398
|
+
if (!cache) return;
|
|
1399
|
+
for (const key of cache.keys()) if (key.endsWith(`|${shareId}/${itemId}`)) cache.delete(key);
|
|
1400
|
+
}
|
|
1401
|
+
function reportFresh(ctx) {
|
|
1402
|
+
const at = new Date(now()).toISOString();
|
|
1403
|
+
ctx.onFreshness?.({
|
|
1404
|
+
state: "current",
|
|
1405
|
+
lastCheckedAt: at,
|
|
1406
|
+
sourceSyncedAt: at,
|
|
1407
|
+
source: "live",
|
|
1408
|
+
errorCode: null,
|
|
1409
|
+
retryAt: null
|
|
1410
|
+
});
|
|
1411
|
+
}
|
|
1412
|
+
async function downloadFile(ctx, shareId, itemId, field) {
|
|
1413
|
+
const attachment = field.value.attachment;
|
|
1414
|
+
assertIds(attachment.id);
|
|
1415
|
+
if (attachment.size > VAULT_LIMITS.fileValueMaxBytes) {
|
|
1416
|
+
refuse(`"${field.name}" is larger than Opengeni reads from Proton Pass`);
|
|
1417
|
+
}
|
|
1418
|
+
const dir = session.privateScratchDir();
|
|
1419
|
+
const target = join2(dir, "attachment");
|
|
1420
|
+
try {
|
|
1421
|
+
await cli(ctx, "read", {
|
|
1422
|
+
args: [
|
|
1423
|
+
"item",
|
|
1424
|
+
"attachment",
|
|
1425
|
+
"download",
|
|
1426
|
+
`--share-id=${shareId}`,
|
|
1427
|
+
`--item-id=${itemId}`,
|
|
1428
|
+
`--attachment-id=${attachment.id}`,
|
|
1429
|
+
`--output=${target}`
|
|
1430
|
+
],
|
|
1431
|
+
subject: "download attachment",
|
|
1432
|
+
maxStdoutBytes: 64 * 1024
|
|
1433
|
+
});
|
|
1434
|
+
let size;
|
|
1435
|
+
try {
|
|
1436
|
+
const stat = lstatSync2(target);
|
|
1437
|
+
if (!stat.isFile()) throw new Error("not a file");
|
|
1438
|
+
size = stat.size;
|
|
1439
|
+
} catch {
|
|
1440
|
+
return refuse("pass-cli did not produce the attachment");
|
|
1441
|
+
}
|
|
1442
|
+
if (size > VAULT_LIMITS.fileValueMaxBytes) {
|
|
1443
|
+
refuse(`"${field.name}" is larger than Opengeni reads from Proton Pass`);
|
|
1444
|
+
}
|
|
1445
|
+
return new Uint8Array(readFileSync(target));
|
|
1446
|
+
} finally {
|
|
1447
|
+
rmSync2(dir, { recursive: true, force: true });
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
async function material(ctx, shareId, itemId, mapped, externalFieldId) {
|
|
1451
|
+
const field = mapped.fields.find((candidate) => candidate.externalFieldId === externalFieldId);
|
|
1452
|
+
if (!field) {
|
|
1453
|
+
throw new VaultProviderError(
|
|
1454
|
+
"provider_not_found",
|
|
1455
|
+
`Proton Pass item "${mapped.meta.name}" has no field ${externalFieldId}`
|
|
1456
|
+
);
|
|
1457
|
+
}
|
|
1458
|
+
const value = field.value;
|
|
1459
|
+
if (value.type === "text") return { type: "text", value: value.value };
|
|
1460
|
+
if (value.type === "totp") {
|
|
1461
|
+
try {
|
|
1462
|
+
return { type: "totp", params: parseTotpInput(value.uri) };
|
|
1463
|
+
} catch {
|
|
1464
|
+
return refuse(`The one-time password in "${mapped.meta.name}" is not a valid TOTP seed`);
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
const bytes = await downloadFile(ctx, shareId, itemId, { ...field, value });
|
|
1468
|
+
return { type: "bytes", value: bytes };
|
|
1469
|
+
}
|
|
1470
|
+
const provider = {
|
|
1471
|
+
id: "protonpass",
|
|
1472
|
+
capabilities: protonPassCapabilities,
|
|
1473
|
+
async validate(ctx) {
|
|
1474
|
+
ctx.signal?.throwIfAborted();
|
|
1475
|
+
const info = await session.ensureSession(ctx.signal);
|
|
1476
|
+
await provider.listVaults(ctx);
|
|
1477
|
+
return { accountName: info.tokenName };
|
|
1478
|
+
},
|
|
1479
|
+
async listVaults(ctx) {
|
|
1480
|
+
let shares;
|
|
1481
|
+
try {
|
|
1482
|
+
shares = parseOutput(
|
|
1483
|
+
await cli(ctx, "list", {
|
|
1484
|
+
args: ["share", "list", "--only-vaults=true", "--output", "json"],
|
|
1485
|
+
subject: "list vaults",
|
|
1486
|
+
overflowCode: "provider_inventory_incomplete"
|
|
1487
|
+
}),
|
|
1488
|
+
ShareListOutput,
|
|
1489
|
+
"provider_inventory_incomplete"
|
|
1490
|
+
);
|
|
1491
|
+
} catch (error) {
|
|
1492
|
+
if (!(error instanceof VaultProviderError) || ![
|
|
1493
|
+
"provider_permission_denied",
|
|
1494
|
+
"provider_not_found",
|
|
1495
|
+
"provider_inventory_incomplete"
|
|
1496
|
+
].includes(error.code)) {
|
|
1497
|
+
throw error;
|
|
1498
|
+
}
|
|
1499
|
+
const vaults = parseOutput(
|
|
1500
|
+
await cli(ctx, "list", {
|
|
1501
|
+
args: ["vault", "list", "--output", "json"],
|
|
1502
|
+
subject: "list vaults",
|
|
1503
|
+
overflowCode: "provider_inventory_incomplete"
|
|
1504
|
+
}),
|
|
1505
|
+
VaultListOutput,
|
|
1506
|
+
"provider_inventory_incomplete"
|
|
1507
|
+
);
|
|
1508
|
+
reportFresh(ctx);
|
|
1509
|
+
return vaults.vaults.map((vault) => {
|
|
1510
|
+
shareAccess.set(vault.share_id, "unknown");
|
|
1511
|
+
return {
|
|
1512
|
+
externalVaultId: vault.share_id,
|
|
1513
|
+
name: safeSecretName(vault.name, vault.share_id),
|
|
1514
|
+
contentVersion: null,
|
|
1515
|
+
itemCount: null,
|
|
1516
|
+
writeAccess: "unknown"
|
|
1517
|
+
};
|
|
1518
|
+
});
|
|
1519
|
+
}
|
|
1520
|
+
reportFresh(ctx);
|
|
1521
|
+
return shares.shares.filter((share) => share.share_type === "Vault").map((share) => {
|
|
1522
|
+
const writeAccess = writeAccessFor(share.share_role);
|
|
1523
|
+
shareAccess.set(share.id, writeAccess);
|
|
1524
|
+
const operations = operationsFor(share.share_role);
|
|
1525
|
+
return {
|
|
1526
|
+
externalVaultId: share.id,
|
|
1527
|
+
name: safeSecretName(share.name, share.id),
|
|
1528
|
+
contentVersion: null,
|
|
1529
|
+
itemCount: null,
|
|
1530
|
+
writeAccess,
|
|
1531
|
+
...operations ? { operations } : {}
|
|
1532
|
+
};
|
|
1533
|
+
});
|
|
1534
|
+
},
|
|
1535
|
+
async listItems(ctx, externalVaultId) {
|
|
1536
|
+
assertIds(externalVaultId);
|
|
1537
|
+
await session.ensureSession(ctx.signal);
|
|
1538
|
+
const agent = session.sessionInfo?.agent ?? false;
|
|
1539
|
+
const base = ["item", "list", `--share-id=${externalVaultId}`, "--filter-state", "active"];
|
|
1540
|
+
const metas = [];
|
|
1541
|
+
if (!agent) {
|
|
1542
|
+
const list = parseOutput(
|
|
1543
|
+
await cli(ctx, "list", {
|
|
1544
|
+
args: [...base, "--show-secrets", "--output", "json"],
|
|
1545
|
+
subject: "list items",
|
|
1546
|
+
overflowCode: "provider_inventory_incomplete"
|
|
1547
|
+
}),
|
|
1548
|
+
FullItemList,
|
|
1549
|
+
"provider_inventory_incomplete"
|
|
1550
|
+
);
|
|
1551
|
+
const withFiles = list.items.filter(
|
|
1552
|
+
(item) => item.share_id === externalVaultId && item.flags.includes(ITEM_HAS_FILES)
|
|
1553
|
+
);
|
|
1554
|
+
if (withFiles.length > maxViews) incomplete();
|
|
1555
|
+
for (const item of list.items) {
|
|
1556
|
+
if (item.share_id !== externalVaultId) continue;
|
|
1557
|
+
if (item.flags.includes(ITEM_HAS_FILES)) {
|
|
1558
|
+
const details = await viewItem(ctx, externalVaultId, item.id, "list items");
|
|
1559
|
+
metas.push(mapItem(details.item, details.attachments).meta);
|
|
1560
|
+
} else {
|
|
1561
|
+
metas.push(mapItem(item).meta);
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
} else {
|
|
1565
|
+
const list = parseOutput(
|
|
1566
|
+
await cli(ctx, "list", {
|
|
1567
|
+
args: [...base, "--output", "json"],
|
|
1568
|
+
subject: "list items",
|
|
1569
|
+
overflowCode: "provider_inventory_incomplete"
|
|
1570
|
+
}),
|
|
1571
|
+
ItemSummaryList,
|
|
1572
|
+
"provider_inventory_incomplete"
|
|
1573
|
+
);
|
|
1574
|
+
const summaries = list.items.filter((item) => item.share_id === externalVaultId);
|
|
1575
|
+
if (summaries.length > maxViews) incomplete();
|
|
1576
|
+
for (const summary of summaries) {
|
|
1577
|
+
const details = await viewItem(ctx, externalVaultId, summary.id, "list items");
|
|
1578
|
+
metas.push(mapItem(details.item, details.attachments).meta);
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
if (metas.length > VAULT_LIMITS.itemsPerVault) incomplete();
|
|
1582
|
+
for (const meta of metas) assertMirrorable(meta);
|
|
1583
|
+
reportFresh(ctx);
|
|
1584
|
+
return metas;
|
|
1585
|
+
},
|
|
1586
|
+
async readFields(ctx, externalVaultId, externalItemId, externalFieldIds) {
|
|
1587
|
+
assertIds(externalVaultId, externalItemId);
|
|
1588
|
+
const details = await cachedItem(ctx, externalVaultId, externalItemId);
|
|
1589
|
+
if (details.item.share_id !== externalVaultId || details.item.state !== "Active") {
|
|
1590
|
+
throw new VaultProviderError("provider_not_found", "No such Proton Pass vault or item");
|
|
1591
|
+
}
|
|
1592
|
+
const mapped = mapItem(details.item, details.attachments);
|
|
1593
|
+
const values = [];
|
|
1594
|
+
for (const externalFieldId of externalFieldIds) {
|
|
1595
|
+
values.push({
|
|
1596
|
+
externalFieldId,
|
|
1597
|
+
material: await material(ctx, externalVaultId, externalItemId, mapped, externalFieldId)
|
|
1598
|
+
});
|
|
1599
|
+
}
|
|
1600
|
+
return values;
|
|
1601
|
+
},
|
|
1602
|
+
async writeItem(ctx, externalVaultId, item) {
|
|
1603
|
+
assertIds(externalVaultId);
|
|
1604
|
+
if (item.externalItemId !== void 0) {
|
|
1605
|
+
throw new VaultProviderError(
|
|
1606
|
+
"provider_unavailable",
|
|
1607
|
+
"Proton Pass items cannot be updated from Opengeni: pass-cli 2.4.2 takes new values only as command-line arguments and cannot edit one-time passwords"
|
|
1608
|
+
);
|
|
1609
|
+
}
|
|
1610
|
+
if (shareAccess.get(externalVaultId) === "read_only") {
|
|
1611
|
+
throw new VaultProviderError(
|
|
1612
|
+
"provider_permission_denied",
|
|
1613
|
+
"The Proton Pass token can only read this vault"
|
|
1614
|
+
);
|
|
1615
|
+
}
|
|
1616
|
+
const plan = planCreate(item);
|
|
1617
|
+
const stdout = await cli(ctx, "write", {
|
|
1618
|
+
args: [
|
|
1619
|
+
"item",
|
|
1620
|
+
"create",
|
|
1621
|
+
plan.kind,
|
|
1622
|
+
`--share-id=${externalVaultId}`,
|
|
1623
|
+
"--from-template",
|
|
1624
|
+
"-"
|
|
1625
|
+
],
|
|
1626
|
+
stdin: JSON.stringify(plan.template),
|
|
1627
|
+
subject: `create ${plan.kind} item`,
|
|
1628
|
+
mutationContext: CREATE_CONTEXT[plan.kind],
|
|
1629
|
+
maxStdoutBytes: 64 * 1024
|
|
1630
|
+
});
|
|
1631
|
+
const lines = stdout.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
1632
|
+
const itemId = lines.at(-1);
|
|
1633
|
+
if (!itemId || !PROTON_ID.test(itemId)) {
|
|
1634
|
+
throw new VaultProviderError(
|
|
1635
|
+
"provider_outcome_unknown",
|
|
1636
|
+
"Proton Pass did not confirm the new item; check the vault before retrying"
|
|
1637
|
+
);
|
|
1638
|
+
}
|
|
1639
|
+
try {
|
|
1640
|
+
const details = await viewItem(ctx, externalVaultId, itemId, "read created item");
|
|
1641
|
+
return mapItem(details.item, details.attachments).meta;
|
|
1642
|
+
} catch {
|
|
1643
|
+
const synthetic = mapItem({
|
|
1644
|
+
id: itemId,
|
|
1645
|
+
share_id: externalVaultId,
|
|
1646
|
+
content: {
|
|
1647
|
+
title: item.name,
|
|
1648
|
+
note: plan.note,
|
|
1649
|
+
content: plan.synthetic,
|
|
1650
|
+
extra_fields: []
|
|
1651
|
+
},
|
|
1652
|
+
state: "Active",
|
|
1653
|
+
flags: [],
|
|
1654
|
+
create_time: "1970-01-01T00:00:00",
|
|
1655
|
+
modify_time: "1970-01-01T00:00:00"
|
|
1656
|
+
}).meta;
|
|
1657
|
+
return { ...synthetic, version: null, updatedAt: null };
|
|
1658
|
+
}
|
|
1659
|
+
},
|
|
1660
|
+
async archiveItem(ctx, externalVaultId, externalItemId) {
|
|
1661
|
+
assertIds(externalVaultId, externalItemId);
|
|
1662
|
+
if (shareAccess.get(externalVaultId) === "read_only") {
|
|
1663
|
+
throw new VaultProviderError(
|
|
1664
|
+
"provider_permission_denied",
|
|
1665
|
+
"The Proton Pass token can only read this vault"
|
|
1666
|
+
);
|
|
1667
|
+
}
|
|
1668
|
+
await cli(ctx, "write", {
|
|
1669
|
+
args: ["item", "trash", `--share-id=${externalVaultId}`, `--item-id=${externalItemId}`],
|
|
1670
|
+
subject: "trash item",
|
|
1671
|
+
mutationContext: /Error trashing item/,
|
|
1672
|
+
maxStdoutBytes: 64 * 1024
|
|
1673
|
+
});
|
|
1674
|
+
forgetItem(ctx, externalVaultId, externalItemId);
|
|
1675
|
+
},
|
|
1676
|
+
close: () => session.close()
|
|
1677
|
+
};
|
|
1678
|
+
return restrictToOperations(provider, options.operations, FILE_FIELD_PREFIX);
|
|
1679
|
+
}
|
|
1680
|
+
function incomplete() {
|
|
1681
|
+
throw new VaultProviderError(
|
|
1682
|
+
"provider_inventory_incomplete",
|
|
1683
|
+
"The Proton Pass vault has more items than Opengeni mirrors at once"
|
|
1684
|
+
);
|
|
1685
|
+
}
|
|
1686
|
+
function assertMirrorable(meta) {
|
|
1687
|
+
if (meta.fields.length > VAULT_LIMITS.fieldsPerItem || meta.websites.length > VAULT_LIMITS.websitesPerItem || meta.websites.some((website) => website.length > VAULT_LIMITS.websiteMaxChars)) {
|
|
1688
|
+
throw new VaultProviderError(
|
|
1689
|
+
"provider_inventory_incomplete",
|
|
1690
|
+
`A Proton Pass item has more than ${VAULT_LIMITS.fieldsPerItem} fields or ${VAULT_LIMITS.websitesPerItem} websites (or a website over ${VAULT_LIMITS.websiteMaxChars} characters), more than Opengeni mirrors`
|
|
1691
|
+
);
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
// src/pool.ts
|
|
1696
|
+
import { createHmac, randomBytes as randomBytes2, randomUUID } from "crypto";
|
|
1697
|
+
var PROTON_PASS_POOL_MAX_SESSIONS = 32;
|
|
1698
|
+
var PROTON_PASS_POOL_IDLE_MS = 15 * 6e4;
|
|
1699
|
+
var PROTON_PASS_POOL_BUSY_RETRY_MS = 5e3;
|
|
1700
|
+
function createProtonPassProviderPool(options) {
|
|
1701
|
+
const {
|
|
1702
|
+
maxSessions = PROTON_PASS_POOL_MAX_SESSIONS,
|
|
1703
|
+
poolIdleMs = PROTON_PASS_POOL_IDLE_MS,
|
|
1704
|
+
create = createProtonPassVaultProvider,
|
|
1705
|
+
...providerOptions
|
|
1706
|
+
} = options;
|
|
1707
|
+
if (!Number.isInteger(maxSessions) || maxSessions < 1) {
|
|
1708
|
+
throw new RangeError("maxSessions must be a positive integer");
|
|
1709
|
+
}
|
|
1710
|
+
const now = providerOptions.now ?? Date.now;
|
|
1711
|
+
const key = randomBytes2(32);
|
|
1712
|
+
const entries = /* @__PURE__ */ new Map();
|
|
1713
|
+
let sweeper = null;
|
|
1714
|
+
let closed = false;
|
|
1715
|
+
function retire(id, entry) {
|
|
1716
|
+
entries.delete(id);
|
|
1717
|
+
void entry.provider.close().catch(() => {
|
|
1718
|
+
});
|
|
1719
|
+
if (entries.size === 0 && sweeper) {
|
|
1720
|
+
clearInterval(sweeper);
|
|
1721
|
+
sweeper = null;
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
function sweep() {
|
|
1725
|
+
const at = now();
|
|
1726
|
+
for (const [id, entry] of entries) {
|
|
1727
|
+
if (entry.active === 0 && at - entry.lastUsed >= poolIdleMs) retire(id, entry);
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
function evictForRoom() {
|
|
1731
|
+
while (entries.size >= maxSessions) {
|
|
1732
|
+
let oldest = null;
|
|
1733
|
+
for (const pair of entries) {
|
|
1734
|
+
if (pair[1].active > 0) continue;
|
|
1735
|
+
if (!oldest || pair[1].lastUsed < oldest[1].lastUsed) oldest = pair;
|
|
1736
|
+
}
|
|
1737
|
+
if (!oldest) return;
|
|
1738
|
+
retire(oldest[0], oldest[1]);
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
function entryFor(scope, token) {
|
|
1742
|
+
if (closed) {
|
|
1743
|
+
throw new VaultProviderError(
|
|
1744
|
+
"provider_unavailable",
|
|
1745
|
+
"The Proton Pass session pool is closed"
|
|
1746
|
+
);
|
|
1747
|
+
}
|
|
1748
|
+
const id = createHmac("sha256", key).update(JSON.stringify([scope, token])).digest("hex");
|
|
1749
|
+
sweep();
|
|
1750
|
+
const existing = entries.get(id);
|
|
1751
|
+
if (existing) return existing;
|
|
1752
|
+
for (const [otherId, other] of entries) {
|
|
1753
|
+
if (other.scope === scope && other.active === 0) retire(otherId, other);
|
|
1754
|
+
}
|
|
1755
|
+
evictForRoom();
|
|
1756
|
+
if (entries.size >= maxSessions) {
|
|
1757
|
+
throw new VaultProviderError(
|
|
1758
|
+
"provider_rate_limited",
|
|
1759
|
+
"Every Proton Pass CLI session on this server is busy; try again shortly",
|
|
1760
|
+
PROTON_PASS_POOL_BUSY_RETRY_MS
|
|
1761
|
+
);
|
|
1762
|
+
}
|
|
1763
|
+
const entry = {
|
|
1764
|
+
provider: create({ ...providerOptions, token }),
|
|
1765
|
+
scope,
|
|
1766
|
+
lastUsed: now(),
|
|
1767
|
+
active: 0
|
|
1768
|
+
};
|
|
1769
|
+
entries.set(id, entry);
|
|
1770
|
+
if (!sweeper) {
|
|
1771
|
+
sweeper = setInterval(sweep, Math.max(1e3, Math.min(poolIdleMs, 6e4)));
|
|
1772
|
+
sweeper.unref?.();
|
|
1773
|
+
}
|
|
1774
|
+
return entry;
|
|
1775
|
+
}
|
|
1776
|
+
function tracked(scope, token) {
|
|
1777
|
+
const run = async (work) => {
|
|
1778
|
+
const entry = entryFor(scope, token);
|
|
1779
|
+
entry.active += 1;
|
|
1780
|
+
entry.lastUsed = now();
|
|
1781
|
+
try {
|
|
1782
|
+
return await work(entry.provider);
|
|
1783
|
+
} finally {
|
|
1784
|
+
entry.active -= 1;
|
|
1785
|
+
entry.lastUsed = now();
|
|
1786
|
+
}
|
|
1787
|
+
};
|
|
1788
|
+
return {
|
|
1789
|
+
id: "protonpass",
|
|
1790
|
+
capabilities: protonPassCapabilities,
|
|
1791
|
+
validate: (ctx) => run((provider) => provider.validate(ctx)),
|
|
1792
|
+
listVaults: (ctx) => run((provider) => provider.listVaults(ctx)),
|
|
1793
|
+
listItems: (ctx, vaultId) => run((provider) => provider.listItems(ctx, vaultId)),
|
|
1794
|
+
readFields: (ctx, vaultId, itemId, fieldIds) => run((provider) => provider.readFields(ctx, vaultId, itemId, fieldIds)),
|
|
1795
|
+
writeItem: (ctx, vaultId, item) => run((provider) => provider.writeItem(ctx, vaultId, item)),
|
|
1796
|
+
archiveItem: (ctx, vaultId, itemId) => run((provider) => provider.archiveItem(ctx, vaultId, itemId))
|
|
1797
|
+
};
|
|
1798
|
+
}
|
|
1799
|
+
return {
|
|
1800
|
+
provider(token, { connectionId, operations } = {}) {
|
|
1801
|
+
const scope = connectionId === void 0 ? `standalone:${randomUUID()}` : `connection:${connectionId}`;
|
|
1802
|
+
return restrictToOperations(tracked(scope, token), operations, FILE_FIELD_PREFIX);
|
|
1803
|
+
},
|
|
1804
|
+
async close() {
|
|
1805
|
+
closed = true;
|
|
1806
|
+
if (sweeper) clearInterval(sweeper);
|
|
1807
|
+
sweeper = null;
|
|
1808
|
+
const all = [...entries.values()];
|
|
1809
|
+
entries.clear();
|
|
1810
|
+
await Promise.all(all.map((entry) => entry.provider.close().catch(() => {
|
|
1811
|
+
})));
|
|
1812
|
+
},
|
|
1813
|
+
size: () => entries.size
|
|
1814
|
+
};
|
|
1815
|
+
}
|
|
1816
|
+
export {
|
|
1817
|
+
FILE_FIELD_PREFIX,
|
|
1818
|
+
LOGIN_FIELD_IDS,
|
|
1819
|
+
NOTE_FIELD_ID,
|
|
1820
|
+
PROTON_PASS_CLI_VERSION,
|
|
1821
|
+
PROTON_PASS_COMMAND_TIMEOUT_MS,
|
|
1822
|
+
PROTON_PASS_IDLE_CLOSE_MS,
|
|
1823
|
+
PROTON_PASS_MAX_ITEM_VIEWS_PER_LIST,
|
|
1824
|
+
PROTON_PASS_MAX_PROCESSES,
|
|
1825
|
+
PROTON_PASS_OPERATIONS,
|
|
1826
|
+
PROTON_PASS_POOL_BUSY_RETRY_MS,
|
|
1827
|
+
PROTON_PASS_POOL_IDLE_MS,
|
|
1828
|
+
PROTON_PASS_POOL_MAX_SESSIONS,
|
|
1829
|
+
PROTON_PASS_SESSION_MAX_AGE_MS,
|
|
1830
|
+
PROTON_PASS_VALUE_CACHE_MS,
|
|
1831
|
+
createProtonPassProviderPool,
|
|
1832
|
+
createProtonPassVaultProvider,
|
|
1833
|
+
protonPassCapabilities,
|
|
1834
|
+
restrictToOperations
|
|
1835
|
+
};
|
|
1836
|
+
//# sourceMappingURL=index.js.map
|