@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/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opengeni/vaults-protonpass",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "3.3.0",
|
|
4
|
+
"description": "Proton Pass provider for Opengeni vaults over an operator-installed, version-pinned pass-cli and a scoped personal access token. Load it only through dynamic import() from the API and worker.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/Cloudgeni-ai/opengeni.git",
|
|
9
|
+
"directory": "packages/vaults-protonpass"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"src",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"type": "module",
|
|
17
|
+
"sideEffects": false,
|
|
18
|
+
"main": "./dist/index.js",
|
|
19
|
+
"module": "./dist/index.js",
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"import": "./dist/index.js"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public",
|
|
29
|
+
"provenance": true
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "bun ../../scripts/build-typescript-package.ts",
|
|
33
|
+
"typecheck": "tsc --noEmit",
|
|
34
|
+
"prepublishOnly": "bash ../../scripts/prepublish-guard"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@opengeni/contracts": "3.3.0",
|
|
38
|
+
"@opengeni/vaults": "3.3.0",
|
|
39
|
+
"zod": "^4.2.1"
|
|
40
|
+
},
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=20"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/contract.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared vault contracts this adapter implements, re-exported for the
|
|
3
|
+
* package's modules so every file names one source.
|
|
4
|
+
*/
|
|
5
|
+
export {
|
|
6
|
+
VaultProviderError,
|
|
7
|
+
type ProviderCallBudget,
|
|
8
|
+
type ProviderCallOperation,
|
|
9
|
+
type ProviderFieldMeta,
|
|
10
|
+
type ProviderFieldValue,
|
|
11
|
+
type ProviderItemMeta,
|
|
12
|
+
type ProviderVault,
|
|
13
|
+
type ProviderWriteField,
|
|
14
|
+
type ProviderWriteItem,
|
|
15
|
+
type VaultProvider,
|
|
16
|
+
type VaultProviderAuthority,
|
|
17
|
+
type VaultProviderCapabilities,
|
|
18
|
+
type VaultProviderContext,
|
|
19
|
+
type VaultProviderErrorCode,
|
|
20
|
+
} from "@opengeni/vaults";
|
|
21
|
+
export {
|
|
22
|
+
VAULT_LIMITS,
|
|
23
|
+
parseTotpInput,
|
|
24
|
+
type SecretMaterial,
|
|
25
|
+
type TotpParams,
|
|
26
|
+
type VaultFieldKind,
|
|
27
|
+
type VaultItemType,
|
|
28
|
+
type VaultProviderOperations,
|
|
29
|
+
} from "@opengeni/contracts";
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { VaultProviderError } from "./contract";
|
|
2
|
+
import type { ProcessResult } from "./runner";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* pass-cli 2.4.2 prints anyhow errors as `Error: <context>\n\nCaused by:\n …`.
|
|
6
|
+
* API failures surface as `Could not perform operation. Reason: <ProtonApiErrorCode>`
|
|
7
|
+
* (pass/src/macros.rs `assert_response!`) or `Invalid status code: <status>`.
|
|
8
|
+
* The reason form appears whenever a non-2xx body carries a `Code` that
|
|
9
|
+
* pass/src/error.rs `ProtonApiErrorCode` knows, whatever the HTTP status, so
|
|
10
|
+
* a reason alone does not prove a 4xx; the status form appears otherwise.
|
|
11
|
+
* stderr is never returned to callers; only the classification below is.
|
|
12
|
+
*/
|
|
13
|
+
const REASON = /Could not perform operation\. Reason: ([A-Za-z]+)/;
|
|
14
|
+
const STATUS = /Invalid status code(?: for download chunk)?: (\d{3})/;
|
|
15
|
+
|
|
16
|
+
/** The session can no longer be used; a fresh login is required. */
|
|
17
|
+
export const SESSION_INVALIDATED = /Your session has been invalidated/;
|
|
18
|
+
/** Pre-flight: the CLI found no usable session before contacting the API. */
|
|
19
|
+
export const NOT_AUTHENTICATED = /This operation requires an authenticated client/;
|
|
20
|
+
|
|
21
|
+
const NOT_FOUND =
|
|
22
|
+
/Item not found|Share with id \S+ not found|Could not find vault|Could not find item|Attachment not found/;
|
|
23
|
+
const NETWORK =
|
|
24
|
+
/error sending request|dns error|failed to lookup address|Connection refused|connection (?:reset|closed)|operation timed out|tcp connect error|certificate/i;
|
|
25
|
+
|
|
26
|
+
const PERMISSION_REASONS = new Set([
|
|
27
|
+
"PermissionDenied",
|
|
28
|
+
"NotAllowed",
|
|
29
|
+
"InsufficientScope",
|
|
30
|
+
"AdminPermissionMissing",
|
|
31
|
+
"Banned",
|
|
32
|
+
"FeatureDisabled",
|
|
33
|
+
]);
|
|
34
|
+
const NOT_FOUND_REASONS = new Set(["NotExists", "DeletedShare"]);
|
|
35
|
+
const CONFLICT_REASONS = new Set(["NotLatestRevision", "NotLatestKey", "AlreadyExists"]);
|
|
36
|
+
const QUOTA_REASONS = new Set(["NoQuotaLeft", "ResourceLimitExceeded", "PayloadTooLarge"]);
|
|
37
|
+
|
|
38
|
+
/** The Proton API error code name in stderr, if any (a closed enum, safe to report). */
|
|
39
|
+
export function protonReason(stderr: string): string | null {
|
|
40
|
+
return REASON.exec(stderr)?.[1] ?? null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function protonStatus(stderr: string): number | null {
|
|
44
|
+
const match = STATUS.exec(stderr);
|
|
45
|
+
return match ? Number(match[1]) : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Statuses with which Proton refuses a request before applying it: a bad or
|
|
50
|
+
* invalid request (400, 422), an unauthorised or forbidden one (401, 403), a
|
|
51
|
+
* missing target (404), a conflict (409), an oversized payload (413) or
|
|
52
|
+
* throttling (429). Any other status (500, 502, 503, 504 from Proton or a
|
|
53
|
+
* proxy, or anything unexpected) may follow an applied change.
|
|
54
|
+
*/
|
|
55
|
+
export const PROTON_DEFINITE_REJECTION_STATUSES: ReadonlySet<number> = new Set([
|
|
56
|
+
400, 401, 403, 404, 409, 413, 422, 429,
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* `ProtonApiErrorCode` names (pass-cli 2.4.2 pass/src/error.rs) that describe
|
|
61
|
+
* a refusal of the request itself: permission, a missing or deleted target, a
|
|
62
|
+
* stale revision or key, a lock, a quota, or invalid input. Server-side
|
|
63
|
+
* failures that may follow a partial write (`Timeout`, `InsertFailed`,
|
|
64
|
+
* `SelectFailed`, `UploadFailure`, the `Provider*` family) and any name not
|
|
65
|
+
* listed here leave a mutation's outcome unknown.
|
|
66
|
+
*/
|
|
67
|
+
export const PROTON_DEFINITE_REJECTION_REASONS: ReadonlySet<string> = new Set([
|
|
68
|
+
...PERMISSION_REASONS,
|
|
69
|
+
...NOT_FOUND_REASONS,
|
|
70
|
+
...CONFLICT_REASONS,
|
|
71
|
+
...QUOTA_REASONS,
|
|
72
|
+
"SessionLocked",
|
|
73
|
+
"IsLocked",
|
|
74
|
+
"InvalidSignature",
|
|
75
|
+
"MissingKeys",
|
|
76
|
+
"RotationPayloadIncomplete",
|
|
77
|
+
"IncompatibleState",
|
|
78
|
+
"BadLockId",
|
|
79
|
+
"InvalidRequirements",
|
|
80
|
+
"InvalidValue",
|
|
81
|
+
"InvalidType",
|
|
82
|
+
"ValueOutOfBounds",
|
|
83
|
+
"NotNull",
|
|
84
|
+
"NotEmpty",
|
|
85
|
+
"NotTrue",
|
|
86
|
+
"NotFalse",
|
|
87
|
+
"NotEqual",
|
|
88
|
+
"Equal",
|
|
89
|
+
"NotSameAsField",
|
|
90
|
+
"RegexError",
|
|
91
|
+
"InvalidNumber",
|
|
92
|
+
"InvalidLength",
|
|
93
|
+
"TooShort",
|
|
94
|
+
"TooLong",
|
|
95
|
+
"HashNotEqual",
|
|
96
|
+
"EmailFormat",
|
|
97
|
+
"IpFormat",
|
|
98
|
+
"UrlFormat",
|
|
99
|
+
"CurrencyFormat",
|
|
100
|
+
"LocaleFormat",
|
|
101
|
+
"DateFormat",
|
|
102
|
+
"JsonFormat",
|
|
103
|
+
"MimeFormat",
|
|
104
|
+
"PhoneFormat",
|
|
105
|
+
"DomainFormat",
|
|
106
|
+
"PgpFormat",
|
|
107
|
+
"IdFormat",
|
|
108
|
+
"HexFormat",
|
|
109
|
+
"Base64Format",
|
|
110
|
+
"VersionFormat",
|
|
111
|
+
"ImageFormat",
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* True when stderr shows Proton refused the request without applying it: a
|
|
116
|
+
* known rejection reason, or (when no reason was decoded) a rejection status.
|
|
117
|
+
*/
|
|
118
|
+
export function isDefiniteRejection(stderr: string): boolean {
|
|
119
|
+
const reason = protonReason(stderr);
|
|
120
|
+
if (reason !== null) return PROTON_DEFINITE_REJECTION_REASONS.has(reason);
|
|
121
|
+
const status = protonStatus(stderr);
|
|
122
|
+
return status !== null && PROTON_DEFINITE_REJECTION_STATUSES.has(status);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Maps a failed pass-cli run to a provider error. `subject` names the action in
|
|
127
|
+
* the message (for example "list items"); stderr content never leaves here
|
|
128
|
+
* except the closed Proton error-code name.
|
|
129
|
+
*/
|
|
130
|
+
export function mapProtonFailure(result: ProcessResult, subject: string): VaultProviderError {
|
|
131
|
+
if (result.spawnError) {
|
|
132
|
+
return new VaultProviderError("provider_unavailable", "pass-cli could not be started");
|
|
133
|
+
}
|
|
134
|
+
if (result.timedOut) {
|
|
135
|
+
return new VaultProviderError("provider_unavailable", `Proton Pass timed out (${subject})`);
|
|
136
|
+
}
|
|
137
|
+
if (result.aborted) {
|
|
138
|
+
return new VaultProviderError(
|
|
139
|
+
"provider_unavailable",
|
|
140
|
+
`Proton Pass call cancelled (${subject})`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
const stderr = result.stderr;
|
|
144
|
+
if (SESSION_INVALIDATED.test(stderr) || NOT_AUTHENTICATED.test(stderr)) {
|
|
145
|
+
return new VaultProviderError(
|
|
146
|
+
"provider_auth_expired",
|
|
147
|
+
"The Proton Pass session ended; sign in again with the personal access token",
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
const reason = protonReason(stderr);
|
|
151
|
+
if (reason) {
|
|
152
|
+
if (reason === "SessionLocked" || reason === "IsLocked") {
|
|
153
|
+
return new VaultProviderError("provider_locked", "The Proton Pass session is locked");
|
|
154
|
+
}
|
|
155
|
+
if (PERMISSION_REASONS.has(reason)) {
|
|
156
|
+
return new VaultProviderError(
|
|
157
|
+
"provider_permission_denied",
|
|
158
|
+
`The Proton Pass token may not ${subject} (${reason})`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
if (NOT_FOUND_REASONS.has(reason)) {
|
|
162
|
+
return new VaultProviderError("provider_not_found", "No such Proton Pass vault or item");
|
|
163
|
+
}
|
|
164
|
+
if (CONFLICT_REASONS.has(reason)) {
|
|
165
|
+
return new VaultProviderError(
|
|
166
|
+
"provider_conflict",
|
|
167
|
+
`Proton Pass rejected the change because the item changed (${reason})`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
if (QUOTA_REASONS.has(reason)) {
|
|
171
|
+
return new VaultProviderError(
|
|
172
|
+
"provider_unavailable",
|
|
173
|
+
`Proton Pass refused the request: account limit reached (${reason})`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
return new VaultProviderError(
|
|
177
|
+
"provider_unavailable",
|
|
178
|
+
`Proton Pass rejected the request (${reason})`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
const status = protonStatus(stderr);
|
|
182
|
+
if (status !== null) {
|
|
183
|
+
if (status === 429) {
|
|
184
|
+
return new VaultProviderError(
|
|
185
|
+
"provider_rate_limited",
|
|
186
|
+
"Proton Pass is rate limiting",
|
|
187
|
+
60_000,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
if (status === 401) {
|
|
191
|
+
return new VaultProviderError("provider_auth_expired", "The Proton Pass session expired");
|
|
192
|
+
}
|
|
193
|
+
if (status === 403) {
|
|
194
|
+
return new VaultProviderError(
|
|
195
|
+
"provider_permission_denied",
|
|
196
|
+
`The Proton Pass token may not ${subject}`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
if (status === 404) {
|
|
200
|
+
return new VaultProviderError("provider_not_found", "No such Proton Pass vault or item");
|
|
201
|
+
}
|
|
202
|
+
if (status === 409) {
|
|
203
|
+
return new VaultProviderError("provider_conflict", "Proton Pass reported a conflict");
|
|
204
|
+
}
|
|
205
|
+
return new VaultProviderError("provider_unavailable", `Proton Pass returned HTTP ${status}`);
|
|
206
|
+
}
|
|
207
|
+
if (NOT_FOUND.test(stderr)) {
|
|
208
|
+
return new VaultProviderError("provider_not_found", "No such Proton Pass vault or item");
|
|
209
|
+
}
|
|
210
|
+
if (NETWORK.test(stderr)) {
|
|
211
|
+
return new VaultProviderError("provider_unavailable", "Proton Pass could not be reached");
|
|
212
|
+
}
|
|
213
|
+
return new VaultProviderError("provider_unavailable", `Proton Pass could not ${subject}`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Markers proving a mutation failed before anything was sent: bad input, no
|
|
218
|
+
* session, or the agent-reason check (`ensure_reason_if_agent`) that runs first.
|
|
219
|
+
*/
|
|
220
|
+
const PRE_FLIGHT =
|
|
221
|
+
/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/;
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Whether a failed mutation is known not to have taken effect. Only a server
|
|
225
|
+
* rejection inside the mutation's own error context (`Error creating …`,
|
|
226
|
+
* `Error trashing item`) with a known rejection reason or status, or a
|
|
227
|
+
* pre-flight marker, counts. A server error (500, 502, 503, 504 or any other
|
|
228
|
+
* status or reason outside the rejection sets), a timeout, a signal, a
|
|
229
|
+
* cancelled call or an error after the request (for example the agent monitor
|
|
230
|
+
* event sent after a create) leaves the outcome unknown.
|
|
231
|
+
*/
|
|
232
|
+
export function isDefiniteMutationFailure(result: ProcessResult, mutationContext: RegExp): boolean {
|
|
233
|
+
if (result.spawnError) return true;
|
|
234
|
+
if (result.timedOut || result.aborted || result.signal !== null || result.stdoutOverflow) {
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
if (result.exitCode === null) return false;
|
|
238
|
+
const stderr = result.stderr;
|
|
239
|
+
if (SESSION_INVALIDATED.test(stderr)) return false;
|
|
240
|
+
if (PRE_FLIGHT.test(stderr)) return true;
|
|
241
|
+
return mutationContext.test(stderr) && isDefiniteRejection(stderr);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function outcomeUnknown(subject: string): VaultProviderError {
|
|
245
|
+
return new VaultProviderError(
|
|
246
|
+
"provider_outcome_unknown",
|
|
247
|
+
`Proton Pass may or may not have completed "${subject}"; check the vault before retrying`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** pass-cli's context for a token login the API answered (`Error logging in with personal access token`). */
|
|
252
|
+
const LOGIN_CONTEXT = /Error logging in with personal access token/;
|
|
253
|
+
/** Statuses with which Proton's API refuses the token itself. */
|
|
254
|
+
const LOGIN_REJECTED_STATUSES = new Set([400, 401, 403, 422]);
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Maps a failed `pass-cli login`. Only Proton's own refusal of the token
|
|
258
|
+
* (a 400, 401, 403 or 422 answer, or a permission or not-found reason, in
|
|
259
|
+
* the token-login context) asks the customer for a new token. Local
|
|
260
|
+
* failures (key provider, disk, an unexpected CLI error), network trouble,
|
|
261
|
+
* throttling and server errors are `provider_unavailable` or
|
|
262
|
+
* `provider_rate_limited`; reconnecting would not help with them.
|
|
263
|
+
*/
|
|
264
|
+
export function loginFailure(result: ProcessResult): VaultProviderError {
|
|
265
|
+
const stderr = result.stderr;
|
|
266
|
+
const answered =
|
|
267
|
+
!result.spawnError && !result.timedOut && !result.aborted && LOGIN_CONTEXT.test(stderr);
|
|
268
|
+
if (answered) {
|
|
269
|
+
const status = protonStatus(stderr);
|
|
270
|
+
const reason = protonReason(stderr);
|
|
271
|
+
if (
|
|
272
|
+
(status !== null && LOGIN_REJECTED_STATUSES.has(status)) ||
|
|
273
|
+
(reason !== null && (PERMISSION_REASONS.has(reason) || NOT_FOUND_REASONS.has(reason)))
|
|
274
|
+
) {
|
|
275
|
+
return new VaultProviderError(
|
|
276
|
+
"provider_auth_invalid",
|
|
277
|
+
"Proton Pass rejected the personal access token (revoked, expired or mistyped)",
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const mapped = mapProtonFailure(result, "sign in");
|
|
282
|
+
// Outside the API's verdict on the token, no other mapping (expired
|
|
283
|
+
// session, permission, not found) describes a failed login accurately.
|
|
284
|
+
if (mapped.code === "provider_rate_limited") return mapped;
|
|
285
|
+
if (mapped.code === "provider_unavailable") return mapped;
|
|
286
|
+
return new VaultProviderError("provider_unavailable", "Proton Pass could not sign in");
|
|
287
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export {
|
|
2
|
+
PROTON_PASS_MAX_ITEM_VIEWS_PER_LIST,
|
|
3
|
+
PROTON_PASS_OPERATIONS,
|
|
4
|
+
PROTON_PASS_VALUE_CACHE_MS,
|
|
5
|
+
createProtonPassVaultProvider,
|
|
6
|
+
protonPassCapabilities,
|
|
7
|
+
type ProtonPassVaultProvider,
|
|
8
|
+
type ProtonPassVaultProviderOptions,
|
|
9
|
+
} from "./provider";
|
|
10
|
+
export {
|
|
11
|
+
PROTON_PASS_CLI_VERSION,
|
|
12
|
+
PROTON_PASS_COMMAND_TIMEOUT_MS,
|
|
13
|
+
PROTON_PASS_IDLE_CLOSE_MS,
|
|
14
|
+
PROTON_PASS_MAX_PROCESSES,
|
|
15
|
+
PROTON_PASS_SESSION_MAX_AGE_MS,
|
|
16
|
+
type ProtonCliOptions,
|
|
17
|
+
} from "./session";
|
|
18
|
+
export { FILE_FIELD_PREFIX, LOGIN_FIELD_IDS, NOTE_FIELD_ID } from "./mapping";
|
|
19
|
+
export {
|
|
20
|
+
PROTON_PASS_POOL_BUSY_RETRY_MS,
|
|
21
|
+
PROTON_PASS_POOL_IDLE_MS,
|
|
22
|
+
PROTON_PASS_POOL_MAX_SESSIONS,
|
|
23
|
+
createProtonPassProviderPool,
|
|
24
|
+
type ProtonPassPoolProviderOptions,
|
|
25
|
+
type ProtonPassProviderPool,
|
|
26
|
+
type ProtonPassProviderPoolOptions,
|
|
27
|
+
} from "./pool";
|
|
28
|
+
export { restrictToOperations } from "./operations";
|
|
29
|
+
export type { ProcessRequest, ProcessResult, ProcessRunner } from "./runner";
|
package/src/mapping.ts
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ProviderFieldMeta,
|
|
3
|
+
type ProviderItemMeta,
|
|
4
|
+
type VaultFieldKind,
|
|
5
|
+
type VaultItemType,
|
|
6
|
+
} from "./contract";
|
|
7
|
+
import { safeFieldName, safeSecretName, uniqueName } from "./naming";
|
|
8
|
+
import type { Attachment, ExtraField, Item, ItemContentVariant, Section } from "./schema";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* External field ids. They are positional names, not upstream ids: Proton
|
|
12
|
+
* items have no per-field identifiers, so ids are derived from the field's
|
|
13
|
+
* place in the item (`login.password`, `x:<extra field name>`,
|
|
14
|
+
* `s:<section>/<field>`, `@file:<attachment id>`), with `#n` for repeats.
|
|
15
|
+
*/
|
|
16
|
+
export const NOTE_FIELD_ID = "@note";
|
|
17
|
+
export const FILE_FIELD_PREFIX = "@file:";
|
|
18
|
+
export const LOGIN_FIELD_IDS = {
|
|
19
|
+
username: "login.username",
|
|
20
|
+
email: "login.email",
|
|
21
|
+
password: "login.password",
|
|
22
|
+
totp: "login.totp",
|
|
23
|
+
} as const;
|
|
24
|
+
export const TOTP_FIELD_NAME = "one-time password";
|
|
25
|
+
|
|
26
|
+
export type MappedValue =
|
|
27
|
+
| { type: "text"; value: string }
|
|
28
|
+
| { type: "totp"; uri: string }
|
|
29
|
+
| { type: "file"; attachment: Attachment };
|
|
30
|
+
|
|
31
|
+
export interface MappedField extends ProviderFieldMeta {
|
|
32
|
+
value: MappedValue;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface MappedItem {
|
|
36
|
+
meta: ProviderItemMeta;
|
|
37
|
+
fields: MappedField[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const IDENTITY_CONCEALED = new Set(["social_security_number", "passport_number", "license_number"]);
|
|
41
|
+
|
|
42
|
+
class FieldCollector {
|
|
43
|
+
readonly fields: MappedField[] = [];
|
|
44
|
+
private readonly names = new Set<string>();
|
|
45
|
+
private readonly ids = new Map<string, number>();
|
|
46
|
+
|
|
47
|
+
add(baseId: string, rawName: string, kind: VaultFieldKind, value: MappedValue) {
|
|
48
|
+
const seen = (this.ids.get(baseId) ?? 0) + 1;
|
|
49
|
+
this.ids.set(baseId, seen);
|
|
50
|
+
const externalFieldId = seen === 1 ? baseId : `${baseId}#${seen}`;
|
|
51
|
+
const name = uniqueName(safeFieldName(rawName, "field"), this.names);
|
|
52
|
+
this.fields.push({ externalFieldId, name, kind, value });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
extra(prefix: string, field: ExtraField) {
|
|
56
|
+
const content = field.content;
|
|
57
|
+
const id = `${prefix}${field.name}`;
|
|
58
|
+
if ("Text" in content) this.add(id, field.name, "text", { type: "text", value: content.Text });
|
|
59
|
+
else if ("Hidden" in content) {
|
|
60
|
+
this.add(id, field.name, "concealed", { type: "text", value: content.Hidden });
|
|
61
|
+
} else if ("Totp" in content) {
|
|
62
|
+
// An empty TOTP field holds no seed; it reads as empty text.
|
|
63
|
+
if (content.Totp) this.add(id, field.name, "totp", { type: "totp", uri: content.Totp });
|
|
64
|
+
else this.add(id, field.name, "text", { type: "text", value: "" });
|
|
65
|
+
} else {
|
|
66
|
+
this.add(id, field.name, "text", { type: "text", value: timestampText(content.Timestamp) });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
sections(sections: readonly Section[]) {
|
|
71
|
+
for (const section of sections) {
|
|
72
|
+
for (const field of section.section_fields) this.extra(`s:${section.section_name}/`, field);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function timestampText(seconds: number): string {
|
|
78
|
+
const date = new Date(seconds * 1000);
|
|
79
|
+
return Number.isNaN(date.getTime()) ? String(seconds) : date.toISOString();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function variantName(content: ItemContentVariant): string {
|
|
83
|
+
if (typeof content === "string") return content;
|
|
84
|
+
return Object.keys(content)[0] ?? "Note";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function itemTypeForVariant(variant: string): VaultItemType {
|
|
88
|
+
switch (variant) {
|
|
89
|
+
case "Login":
|
|
90
|
+
return "login";
|
|
91
|
+
case "Note":
|
|
92
|
+
return "note";
|
|
93
|
+
case "Custom":
|
|
94
|
+
case "SshKey":
|
|
95
|
+
return "api_key";
|
|
96
|
+
default:
|
|
97
|
+
// Alias, CreditCard, Identity, Wifi: generic fields, shown as notes.
|
|
98
|
+
return "note";
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Proton summary `item_type` (snake case) to an Opengeni item type. */
|
|
103
|
+
export function itemTypeForSummary(itemType: string): VaultItemType {
|
|
104
|
+
switch (itemType) {
|
|
105
|
+
case "login":
|
|
106
|
+
return "login";
|
|
107
|
+
case "custom":
|
|
108
|
+
case "ssh_key":
|
|
109
|
+
return "api_key";
|
|
110
|
+
default:
|
|
111
|
+
return "note";
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** `YYYY-MM-DDTHH:MM:SS` (UTC under TZ=UTC) to an ISO instant. */
|
|
116
|
+
export function civilToIso(civil: string): string | null {
|
|
117
|
+
const date = new Date(`${civil}Z`);
|
|
118
|
+
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function concealedText(collector: FieldCollector, id: string, name: string, value: string) {
|
|
122
|
+
collector.add(id, name, "concealed", { type: "text", value });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function plainText(collector: FieldCollector, id: string, name: string, value: string) {
|
|
126
|
+
collector.add(id, name, "text", { type: "text", value });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function identityFields(collector: FieldCollector, identity: Record<string, unknown>) {
|
|
130
|
+
for (const [key, value] of Object.entries(identity)) {
|
|
131
|
+
if (typeof value === "string") {
|
|
132
|
+
if (!value) continue;
|
|
133
|
+
if (IDENTITY_CONCEALED.has(key)) concealedText(collector, `identity.${key}`, key, value);
|
|
134
|
+
else plainText(collector, `identity.${key}`, key, value);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (!Array.isArray(value)) continue;
|
|
138
|
+
for (const entry of value) {
|
|
139
|
+
if (!entry || typeof entry !== "object") continue;
|
|
140
|
+
const record = entry as Record<string, unknown>;
|
|
141
|
+
if (typeof record.section_name === "string" && Array.isArray(record.section_fields)) {
|
|
142
|
+
collector.sections([
|
|
143
|
+
{ section_name: record.section_name, section_fields: extraFields(record.section_fields) },
|
|
144
|
+
]);
|
|
145
|
+
} else {
|
|
146
|
+
for (const field of extraFields([record])) collector.extra(`s:${key}/`, field);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function extraFields(values: readonly unknown[]): ExtraField[] {
|
|
153
|
+
const out: ExtraField[] = [];
|
|
154
|
+
for (const value of values) {
|
|
155
|
+
if (!value || typeof value !== "object") continue;
|
|
156
|
+
const record = value as { name?: unknown; content?: unknown };
|
|
157
|
+
if (typeof record.name !== "string" || !record.content || typeof record.content !== "object") {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const content = record.content as Record<string, unknown>;
|
|
161
|
+
if (typeof content.Text === "string")
|
|
162
|
+
out.push({ name: record.name, content: { Text: content.Text } });
|
|
163
|
+
else if (typeof content.Hidden === "string") {
|
|
164
|
+
out.push({ name: record.name, content: { Hidden: content.Hidden } });
|
|
165
|
+
} else if (typeof content.Totp === "string") {
|
|
166
|
+
out.push({ name: record.name, content: { Totp: content.Totp } });
|
|
167
|
+
} else if (typeof content.Timestamp === "number") {
|
|
168
|
+
out.push({ name: record.name, content: { Timestamp: content.Timestamp } });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Maps an item (and, from `item view`, its attachments) to fields and metadata. */
|
|
175
|
+
export function mapItem(item: Item, attachments: readonly Attachment[] = []): MappedItem {
|
|
176
|
+
const collector = new FieldCollector();
|
|
177
|
+
const content = item.content.content;
|
|
178
|
+
const variant = variantName(content);
|
|
179
|
+
const websites: string[] = [];
|
|
180
|
+
|
|
181
|
+
if (typeof content === "object") {
|
|
182
|
+
if ("Login" in content) {
|
|
183
|
+
const login = content.Login;
|
|
184
|
+
plainText(collector, LOGIN_FIELD_IDS.username, "username", login.username);
|
|
185
|
+
if (login.email) plainText(collector, LOGIN_FIELD_IDS.email, "email", login.email);
|
|
186
|
+
concealedText(collector, LOGIN_FIELD_IDS.password, "password", login.password);
|
|
187
|
+
if (login.totp_uri) {
|
|
188
|
+
collector.add(LOGIN_FIELD_IDS.totp, TOTP_FIELD_NAME, "totp", {
|
|
189
|
+
type: "totp",
|
|
190
|
+
uri: login.totp_uri,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
websites.push(...login.urls.filter((url) => url.trim().length > 0));
|
|
194
|
+
} else if ("SshKey" in content) {
|
|
195
|
+
concealedText(collector, "ssh.private_key", "private key", content.SshKey.private_key);
|
|
196
|
+
plainText(collector, "ssh.public_key", "public key", content.SshKey.public_key);
|
|
197
|
+
collector.sections(content.SshKey.sections);
|
|
198
|
+
} else if ("Wifi" in content) {
|
|
199
|
+
plainText(collector, "wifi.ssid", "ssid", content.Wifi.ssid);
|
|
200
|
+
concealedText(collector, "wifi.password", "password", content.Wifi.password);
|
|
201
|
+
collector.sections(content.Wifi.sections);
|
|
202
|
+
} else if ("CreditCard" in content) {
|
|
203
|
+
const card = content.CreditCard;
|
|
204
|
+
plainText(collector, "card.cardholder_name", "cardholder name", card.cardholder_name);
|
|
205
|
+
concealedText(collector, "card.number", "number", card.number);
|
|
206
|
+
concealedText(
|
|
207
|
+
collector,
|
|
208
|
+
"card.verification_number",
|
|
209
|
+
"verification number",
|
|
210
|
+
card.verification_number,
|
|
211
|
+
);
|
|
212
|
+
plainText(collector, "card.expiration_date", "expiration date", card.expiration_date);
|
|
213
|
+
if (card.pin) concealedText(collector, "card.pin", "PIN", card.pin);
|
|
214
|
+
} else if ("Identity" in content) {
|
|
215
|
+
identityFields(collector, content.Identity);
|
|
216
|
+
} else if ("Custom" in content) {
|
|
217
|
+
collector.sections(content.Custom.sections);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const note = item.content.note;
|
|
222
|
+
if (variant === "Note" || note) {
|
|
223
|
+
collector.add(NOTE_FIELD_ID, "note", "concealed", { type: "text", value: note });
|
|
224
|
+
}
|
|
225
|
+
for (const field of item.content.extra_fields) collector.extra("x:", field);
|
|
226
|
+
for (const attachment of attachments) {
|
|
227
|
+
collector.add(`${FILE_FIELD_PREFIX}${attachment.id}`, attachment.content.name, "file", {
|
|
228
|
+
type: "file",
|
|
229
|
+
attachment,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const meta: ProviderItemMeta = {
|
|
234
|
+
externalItemId: item.id,
|
|
235
|
+
name: safeSecretName(item.content.title, item.id),
|
|
236
|
+
type: itemTypeForVariant(variant),
|
|
237
|
+
websites,
|
|
238
|
+
fields: collector.fields.map(({ externalFieldId, name, kind }) => ({
|
|
239
|
+
externalFieldId,
|
|
240
|
+
name,
|
|
241
|
+
kind,
|
|
242
|
+
})),
|
|
243
|
+
version: item.modify_time,
|
|
244
|
+
updatedAt: civilToIso(item.modify_time),
|
|
245
|
+
};
|
|
246
|
+
return { meta, fields: collector.fields };
|
|
247
|
+
}
|
package/src/naming.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type TotpParams, VAULT_LIMITS } from "./contract";
|
|
2
|
+
|
|
3
|
+
// Mirrors contracts isValidSecretName (Q-10): no / { } # \ or control chars,
|
|
4
|
+
// trimmed, 1..120 chars, not "id:…"; field names additionally may not be "otp".
|
|
5
|
+
const FORBIDDEN_NAME_CHARS = /[/{}#\\\u0000-\u001f\u007f-\u009f]/g;
|
|
6
|
+
|
|
7
|
+
export function nameKey(name: string): string {
|
|
8
|
+
return name.normalize("NFC").trim().toLowerCase();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function safeSecretName(raw: string, fallback: string): string {
|
|
12
|
+
const max = VAULT_LIMITS.nameMaxChars;
|
|
13
|
+
let name = raw.replace(FORBIDDEN_NAME_CHARS, "-").trim().slice(0, max).trim();
|
|
14
|
+
if (!name) name = fallback.replace(FORBIDDEN_NAME_CHARS, "-").trim().slice(0, max) || "item";
|
|
15
|
+
if (/^id:/i.test(name)) name = `id-${name.slice(3)}`;
|
|
16
|
+
return name;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function safeFieldName(raw: string, fallback: string): string {
|
|
20
|
+
const name = safeSecretName(raw, fallback);
|
|
21
|
+
return nameKey(name) === "otp" ? "otp-code" : name;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Appends " (2)", " (3)" … so names are unique case-insensitively. */
|
|
25
|
+
export function uniqueName(name: string, taken: Set<string>): string {
|
|
26
|
+
let candidate = name;
|
|
27
|
+
for (let n = 2; taken.has(nameKey(candidate)); n += 1) {
|
|
28
|
+
const suffix = ` (${n})`;
|
|
29
|
+
candidate = `${name.slice(0, VAULT_LIMITS.nameMaxChars - suffix.length)}${suffix}`;
|
|
30
|
+
}
|
|
31
|
+
taken.add(nameKey(candidate));
|
|
32
|
+
return candidate;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A canonical otpauth URI for a parsed seed, for stores that require URIs. */
|
|
36
|
+
export function totpUri(params: TotpParams, label: string): string {
|
|
37
|
+
const query = new URLSearchParams({
|
|
38
|
+
secret: params.secretBase32,
|
|
39
|
+
algorithm: params.algorithm,
|
|
40
|
+
digits: String(params.digits),
|
|
41
|
+
period: String(params.periodSeconds),
|
|
42
|
+
});
|
|
43
|
+
return `otpauth://totp/${encodeURIComponent(label || "Opengeni")}?${query.toString()}`;
|
|
44
|
+
}
|