@envseal/core 0.1.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 +201 -0
- package/dist/approvals.d.ts +13 -0
- package/dist/approvals.js +73 -0
- package/dist/audit.d.ts +42 -0
- package/dist/audit.js +37 -0
- package/dist/broker.d.ts +31 -0
- package/dist/broker.js +449 -0
- package/dist/exec.d.ts +18 -0
- package/dist/exec.js +148 -0
- package/dist/guard.d.ts +66 -0
- package/dist/guard.js +157 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +15 -0
- package/dist/manifest.d.ts +24 -0
- package/dist/manifest.js +165 -0
- package/dist/paths.d.ts +14 -0
- package/dist/paths.js +87 -0
- package/dist/presence.d.ts +20 -0
- package/dist/presence.js +58 -0
- package/dist/redact.d.ts +20 -0
- package/dist/redact.js +338 -0
- package/dist/sinks/cli-sink-base.d.ts +88 -0
- package/dist/sinks/cli-sink-base.js +217 -0
- package/dist/sinks/doppler.d.ts +45 -0
- package/dist/sinks/doppler.js +198 -0
- package/dist/sinks/dotenv.d.ts +57 -0
- package/dist/sinks/dotenv.js +407 -0
- package/dist/sinks/keychain.d.ts +21 -0
- package/dist/sinks/keychain.js +333 -0
- package/dist/sinks/onepassword.d.ts +58 -0
- package/dist/sinks/onepassword.js +183 -0
- package/dist/sinks/registry.d.ts +4 -0
- package/dist/sinks/registry.js +63 -0
- package/dist/sinks/sops.d.ts +54 -0
- package/dist/sinks/sops.js +254 -0
- package/dist/sinks/types.d.ts +10 -0
- package/dist/sinks/types.js +2 -0
- package/dist/sinks/vault.d.ts +41 -0
- package/dist/sinks/vault.js +156 -0
- package/dist/tickets.d.ts +49 -0
- package/dist/tickets.js +179 -0
- package/dist/validation-state.d.ts +33 -0
- package/dist/validation-state.js +48 -0
- package/dist/verify.d.ts +8 -0
- package/dist/verify.js +133 -0
- package/package.json +38 -0
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { mkdirSync, writeFileSync, readFileSync, unlinkSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { asSecret } from '@envseal/protocol';
|
|
6
|
+
import { unsafeSecretToUtf8 } from './dotenv.js';
|
|
7
|
+
function execCommand(file, args, input, env) {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
let stdout = '';
|
|
10
|
+
let stderr = '';
|
|
11
|
+
const proc = spawn(file, args, {
|
|
12
|
+
shell: false,
|
|
13
|
+
stdio: [input ? 'pipe' : 'ignore', 'pipe', 'pipe'],
|
|
14
|
+
...(env ? { env } : {}),
|
|
15
|
+
});
|
|
16
|
+
if (proc.stdout) {
|
|
17
|
+
proc.stdout.on('data', (chunk) => {
|
|
18
|
+
stdout += chunk.toString();
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
if (proc.stderr) {
|
|
22
|
+
proc.stderr.on('data', (chunk) => {
|
|
23
|
+
stderr += chunk.toString();
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
proc.on('close', (code) => {
|
|
27
|
+
if (code === 0) {
|
|
28
|
+
resolve(stdout);
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
// Callers distinguish "item absent" (a documented exit code per tool)
|
|
32
|
+
// from real failures, so the code rides on the error itself.
|
|
33
|
+
const err = new Error(`${file} exited with code ${code}: ${stderr}`);
|
|
34
|
+
err.exitCode = code ?? undefined;
|
|
35
|
+
reject(err);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
proc.on('error', (err) => {
|
|
39
|
+
reject(err);
|
|
40
|
+
});
|
|
41
|
+
if (input && proc.stdin) {
|
|
42
|
+
proc.stdin.write(input);
|
|
43
|
+
proc.stdin.end();
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
function exitCodeOf(error) {
|
|
48
|
+
return error?.exitCode;
|
|
49
|
+
}
|
|
50
|
+
async function checkCommandAvailable(cmd) {
|
|
51
|
+
try {
|
|
52
|
+
const isWin = process.platform === 'win32';
|
|
53
|
+
const checkCmd = isWin ? 'where' : 'which';
|
|
54
|
+
await execCommand(checkCmd, [cmd]);
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// macOS `security` reports errSecItemNotFound as exit 44; secret-tool exits 1
|
|
62
|
+
// when lookup/clear finds nothing. Both mean ABSENCE, which read()/remove()
|
|
63
|
+
// must report as null/false rather than a thrown failure.
|
|
64
|
+
const MACOS_ITEM_NOT_FOUND = 44;
|
|
65
|
+
const SECRET_TOOL_NOT_FOUND = 1;
|
|
66
|
+
/**
|
|
67
|
+
* The account name write() filed this key under. mac/linux scope entries by
|
|
68
|
+
* `<projectId>:<key>`; the Windows blob path below stays keyed by <KEY> alone,
|
|
69
|
+
* matching what write() has always written there.
|
|
70
|
+
*/
|
|
71
|
+
function accountFor(paths, key) {
|
|
72
|
+
const projectId = paths.root.split(/[\\/]/).pop() ?? 'unknown';
|
|
73
|
+
return `${projectId}:${key}`;
|
|
74
|
+
}
|
|
75
|
+
/** The directory holding the DPAPI blobs, exactly where write() puts them. */
|
|
76
|
+
function windowsCredsDir() {
|
|
77
|
+
return join(homedir(), 'AppData', 'Local', 'envseal', 'creds');
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Windows pipe transports are codepage-bound: node writes UTF-8 into the
|
|
81
|
+
* child's stdin, but PowerShell 5.1 decodes it with the console's OEM
|
|
82
|
+
* codepage ([Console]::InputEncoding), and re-encodes stdout the same way.
|
|
83
|
+
* ASCII survives every codepage; anything else mojibakes — and the mangled
|
|
84
|
+
* text then gets DPAPI-encrypted, i.e. corrupted at rest.
|
|
85
|
+
*
|
|
86
|
+
* Fix: carry the value across BOTH pipes as lowercase hex of its UTF-8
|
|
87
|
+
* bytes — pure ASCII, immune to codepages — with this marker so read() can
|
|
88
|
+
* tell the new format from blobs written by the pre-hex code (whose
|
|
89
|
+
* plaintext is the raw legacy value, ASCII in practice). A legacy blob that
|
|
90
|
+
* happens to start with the marker AND continues as valid even-length hex
|
|
91
|
+
* would misdecode; that collision requires a plaintext engineered to look
|
|
92
|
+
* like our transport and is handled by re-setting the key.
|
|
93
|
+
*/
|
|
94
|
+
const WIN_HEX_PREFIX = 'ENVSEALHEX1:';
|
|
95
|
+
/**
|
|
96
|
+
* Editors and shells export a PSModulePath that leads with PowerShell 7 module
|
|
97
|
+
* dirs; those shadow 5.1's Security module (duplicate type data) and
|
|
98
|
+
* ConvertTo-SecureString silently vanishes. Dropping the variable makes 5.1
|
|
99
|
+
* rebuild its own defaults.
|
|
100
|
+
*
|
|
101
|
+
* Every CASING must go: some launchers (pnpm on Windows among them) rewrite
|
|
102
|
+
* the name as PSMODULEPATH, and an exact-case destructure then leaves the
|
|
103
|
+
* uppercased twin behind — the child inherits the poisoned path anyway.
|
|
104
|
+
*/
|
|
105
|
+
function childEnvWithoutPSModulePath() {
|
|
106
|
+
const childEnv = {};
|
|
107
|
+
for (const [name, value] of Object.entries(process.env)) {
|
|
108
|
+
if (/^psmodulepath$/i.test(name))
|
|
109
|
+
continue;
|
|
110
|
+
childEnv[name] = value;
|
|
111
|
+
}
|
|
112
|
+
return childEnv;
|
|
113
|
+
}
|
|
114
|
+
class KeychainSink {
|
|
115
|
+
id = 'keychain';
|
|
116
|
+
async available() {
|
|
117
|
+
if (process.platform === 'darwin') {
|
|
118
|
+
return checkCommandAvailable('security');
|
|
119
|
+
}
|
|
120
|
+
else if (process.platform === 'win32') {
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
return checkCommandAvailable('secret-tool');
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async read(paths, key) {
|
|
128
|
+
if (process.platform === 'darwin') {
|
|
129
|
+
try {
|
|
130
|
+
const stdout = await execCommand('security', [
|
|
131
|
+
'find-generic-password',
|
|
132
|
+
'-s',
|
|
133
|
+
'envseal',
|
|
134
|
+
'-a',
|
|
135
|
+
accountFor(paths, key),
|
|
136
|
+
'-w',
|
|
137
|
+
]);
|
|
138
|
+
// security prints the password followed by one newline.
|
|
139
|
+
return asSecret(Buffer.from(stdout.replace(/\n$/, ''), 'utf8'));
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
if (exitCodeOf(error) === MACOS_ITEM_NOT_FOUND)
|
|
143
|
+
return null;
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (process.platform === 'win32') {
|
|
148
|
+
return this.readWindows(key);
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
const stdout = await execCommand('secret-tool', [
|
|
152
|
+
'lookup',
|
|
153
|
+
'service',
|
|
154
|
+
'envseal',
|
|
155
|
+
'account',
|
|
156
|
+
accountFor(paths, key),
|
|
157
|
+
]);
|
|
158
|
+
// secret-tool exits 0 with empty output when the lookup misses.
|
|
159
|
+
if (stdout.length === 0)
|
|
160
|
+
return null;
|
|
161
|
+
return asSecret(Buffer.from(stdout.replace(/\n$/, ''), 'utf8'));
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
if (exitCodeOf(error) === SECRET_TOOL_NOT_FOUND)
|
|
165
|
+
return null;
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Decrypt the DPAPI blob write() left at creds\<KEY>. Absent file means the
|
|
171
|
+
* value is not stored (null); anything present-but-unreadable is a loud
|
|
172
|
+
* error, never a silent null — a corrupt blob pretending to be "absent"
|
|
173
|
+
* would send ensure() back to the prompt instead of telling the user their
|
|
174
|
+
* credential store needs attention.
|
|
175
|
+
*/
|
|
176
|
+
async readWindows(key) {
|
|
177
|
+
const dir = windowsCredsDir();
|
|
178
|
+
const blobPath = join(dir, key);
|
|
179
|
+
let blob;
|
|
180
|
+
try {
|
|
181
|
+
blob = readFileSync(blobPath, 'utf8');
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
if (error.code === 'ENOENT')
|
|
185
|
+
return null;
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
// A 0-byte file is exactly the silent-write failure the write path guards
|
|
189
|
+
// against; refuse it before the hex check can pass on empty input.
|
|
190
|
+
if (blob.trim().length === 0) {
|
|
191
|
+
throw new Error(`Keychain blob for ${key} is empty (${blobPath})`);
|
|
192
|
+
}
|
|
193
|
+
// ConvertFrom-SecureString emits hex digits only; anything else is not a
|
|
194
|
+
// blob this sink wrote.
|
|
195
|
+
if (!/^[0-9a-f]+$/i.test(blob.trim())) {
|
|
196
|
+
throw new Error(`Keychain blob for ${key} is not a hex DPAPI blob (${blobPath})`);
|
|
197
|
+
}
|
|
198
|
+
// The decrypt snippet goes through a temp script file, never argv, where it
|
|
199
|
+
// would be visible to any process listing.
|
|
200
|
+
const escapedPath = blobPath.replace(/\\/g, '\\\\');
|
|
201
|
+
const scriptPath = join(dir, `${key}.read.ps1`);
|
|
202
|
+
const script = [
|
|
203
|
+
"$ErrorActionPreference = 'Stop'",
|
|
204
|
+
`$blob = Get-Content -Raw '${escapedPath}'`,
|
|
205
|
+
'$secure = ConvertTo-SecureString -String $blob',
|
|
206
|
+
'$ptr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)',
|
|
207
|
+
'try {',
|
|
208
|
+
' $plain = [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($ptr)',
|
|
209
|
+
'} finally {',
|
|
210
|
+
' [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr)',
|
|
211
|
+
'}',
|
|
212
|
+
'[Console]::Out.Write($plain)',
|
|
213
|
+
'',
|
|
214
|
+
].join('\n');
|
|
215
|
+
writeFileSync(scriptPath, script);
|
|
216
|
+
// Same scrub write() does: a PSModulePath inherited from an editor leads
|
|
217
|
+
// with PowerShell 7 module dirs and breaks the Security module under 5.1.
|
|
218
|
+
const childEnv = childEnvWithoutPSModulePath();
|
|
219
|
+
try {
|
|
220
|
+
const stdout = await execCommand('powershell', ['-NoProfile', '-File', scriptPath], undefined, childEnv);
|
|
221
|
+
// The decrypted payload is hex (new format) or the legacy plaintext;
|
|
222
|
+
// [Console]::Out.Write emits it verbatim, and trimming would corrupt a
|
|
223
|
+
// legacy value that genuinely ends in whitespace.
|
|
224
|
+
if (stdout.startsWith(WIN_HEX_PREFIX) &&
|
|
225
|
+
/^[0-9a-f]*$/.test(stdout.slice(WIN_HEX_PREFIX.length)) &&
|
|
226
|
+
(stdout.length - WIN_HEX_PREFIX.length) % 2 === 0) {
|
|
227
|
+
return asSecret(Buffer.from(stdout.slice(WIN_HEX_PREFIX.length), 'hex'));
|
|
228
|
+
}
|
|
229
|
+
return asSecret(Buffer.from(stdout, 'utf8'));
|
|
230
|
+
}
|
|
231
|
+
finally {
|
|
232
|
+
try {
|
|
233
|
+
unlinkSync(scriptPath);
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
// ignore
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
async write(_paths, key, value) {
|
|
241
|
+
const account = accountFor(_paths, key);
|
|
242
|
+
const valueStr = unsafeSecretToUtf8(value);
|
|
243
|
+
if (process.platform === 'darwin') {
|
|
244
|
+
await execCommand('security', [
|
|
245
|
+
'add-generic-password',
|
|
246
|
+
'-U',
|
|
247
|
+
'-s',
|
|
248
|
+
'envseal',
|
|
249
|
+
'-a',
|
|
250
|
+
account,
|
|
251
|
+
'-w',
|
|
252
|
+
valueStr,
|
|
253
|
+
]);
|
|
254
|
+
}
|
|
255
|
+
else if (process.platform === 'win32') {
|
|
256
|
+
const dir = windowsCredsDir();
|
|
257
|
+
mkdirSync(dir, { recursive: true });
|
|
258
|
+
const escapedPath = join(dir, key).replace(/\\/g, '\\\\');
|
|
259
|
+
// @($input), not [System.Console]::In: the PowerShell console host reads
|
|
260
|
+
// an empty string from a spawned pipe's stdin. The empty checks exit 1 so
|
|
261
|
+
// that can never again become a silent 0-byte blob. The payload is the
|
|
262
|
+
// hex transport (see WIN_HEX_PREFIX) so the OEM codepage cannot touch it.
|
|
263
|
+
const script = [
|
|
264
|
+
"$ErrorActionPreference = 'Stop'",
|
|
265
|
+
'$value = @($input) -join "`n"',
|
|
266
|
+
'if (-not $value) { exit 1 }',
|
|
267
|
+
'$secure = ConvertTo-SecureString -String $value -AsPlainText -Force',
|
|
268
|
+
'$encrypted = ConvertFrom-SecureString -SecureString $secure',
|
|
269
|
+
'if (-not $encrypted) { exit 1 }',
|
|
270
|
+
`[System.IO.File]::WriteAllText('${escapedPath}', $encrypted)`,
|
|
271
|
+
].join('\n');
|
|
272
|
+
const scriptPath = join(dir, `${key}.ps1`);
|
|
273
|
+
writeFileSync(scriptPath, script);
|
|
274
|
+
const childEnv = childEnvWithoutPSModulePath();
|
|
275
|
+
const payload = WIN_HEX_PREFIX + Buffer.from(valueStr, 'utf8').toString('hex');
|
|
276
|
+
try {
|
|
277
|
+
await execCommand('powershell', ['-NoProfile', '-File', scriptPath], payload, childEnv);
|
|
278
|
+
}
|
|
279
|
+
finally {
|
|
280
|
+
try {
|
|
281
|
+
unlinkSync(scriptPath);
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
// ignore
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
await execCommand('secret-tool', ['store', '--label=envseal', 'service', 'envseal', 'account', account], valueStr);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
async remove(paths, key) {
|
|
293
|
+
if (process.platform === 'darwin') {
|
|
294
|
+
try {
|
|
295
|
+
await execCommand('security', [
|
|
296
|
+
'delete-generic-password',
|
|
297
|
+
'-s',
|
|
298
|
+
'envseal',
|
|
299
|
+
'-a',
|
|
300
|
+
accountFor(paths, key),
|
|
301
|
+
]);
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
catch (error) {
|
|
305
|
+
if (exitCodeOf(error) === MACOS_ITEM_NOT_FOUND)
|
|
306
|
+
return false;
|
|
307
|
+
throw error;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (process.platform === 'win32') {
|
|
311
|
+
try {
|
|
312
|
+
unlinkSync(join(windowsCredsDir(), key));
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
if (error.code === 'ENOENT')
|
|
317
|
+
return false;
|
|
318
|
+
throw error;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
try {
|
|
322
|
+
await execCommand('secret-tool', ['clear', 'service', 'envseal', 'account', accountFor(paths, key)]);
|
|
323
|
+
return true;
|
|
324
|
+
}
|
|
325
|
+
catch (error) {
|
|
326
|
+
if (exitCodeOf(error) === SECRET_TOOL_NOT_FOUND)
|
|
327
|
+
return false;
|
|
328
|
+
throw error;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
export const keychainSink = new KeychainSink();
|
|
333
|
+
//# sourceMappingURL=keychain.js.map
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { SecretValue } from '@envseal/protocol';
|
|
2
|
+
import type { ProjectPaths } from '../paths.js';
|
|
3
|
+
import { CliSinkBase } from './cli-sink-base.js';
|
|
4
|
+
/**
|
|
5
|
+
* The 1Password CLI adapter. Talks to `op` under whatever non-interactive
|
|
6
|
+
* credential the environment carries — a service account token or Connect
|
|
7
|
+
* host/token pair, which `op whoami` validates without ever triggering the
|
|
8
|
+
* interactive unlock a signed-in desktop account would need.
|
|
9
|
+
*
|
|
10
|
+
* One item per credential in the dedicated vault: title `<projectId>:<KEY>`,
|
|
11
|
+
* field `credential` carrying the value (CONCEALED, so 1Password treats it
|
|
12
|
+
* as a secret), field `envseal-key` recording the raw key so items remain
|
|
13
|
+
* attributable even after a project directory is renamed. The value reaches
|
|
14
|
+
* `op item create` inside the item template on stdin (the positional `-`
|
|
15
|
+
* form): op has no --fields-file, and field=value argv pairs land in process
|
|
16
|
+
* listings, while stdin also beats a temp template file by keeping secret
|
|
17
|
+
* bytes off disk entirely.
|
|
18
|
+
*/
|
|
19
|
+
export declare class OnePasswordSink extends CliSinkBase {
|
|
20
|
+
readonly id = "onepassword";
|
|
21
|
+
protected readonly requiredCommands: string[];
|
|
22
|
+
/**
|
|
23
|
+
* Result of this instance's first `op whoami`, remembered for the
|
|
24
|
+
* instance's lifetime: a credential validated at first use is trusted for
|
|
25
|
+
* the session, and every operation skips the extra probe from then on. An
|
|
26
|
+
* auth failure after that point surfaces through the operation's own error
|
|
27
|
+
* mapping instead.
|
|
28
|
+
*/
|
|
29
|
+
private whoamiOk;
|
|
30
|
+
protected unavailableReason(): string;
|
|
31
|
+
/**
|
|
32
|
+
* Binary presence is not enough: op with no usable credential answers every
|
|
33
|
+
* real command with an auth error. `op whoami` is the cheapest command that
|
|
34
|
+
* tells the two states apart, and it only reports — it never prompts.
|
|
35
|
+
*/
|
|
36
|
+
available(_paths: ProjectPaths): Promise<boolean>;
|
|
37
|
+
/**
|
|
38
|
+
* The same probe at operation time: a session that expired (or a binary
|
|
39
|
+
* that vanished) between available() and now must surface as
|
|
40
|
+
* SEP_SINK_UNAVAILABLE — which names the fix — rather than as a cryptic
|
|
41
|
+
* auth failure from deep inside `op item get`.
|
|
42
|
+
*/
|
|
43
|
+
protected requirePrerequisites(): Promise<void>;
|
|
44
|
+
private whoamiSucceeds;
|
|
45
|
+
read(paths: ProjectPaths, key: string): Promise<SecretValue | null>;
|
|
46
|
+
write(paths: ProjectPaths, key: string, value: SecretValue): Promise<void>;
|
|
47
|
+
/**
|
|
48
|
+
* A first write on a fresh deployment has no vault yet. Restricted
|
|
49
|
+
* credentials cannot help here — service accounts and Connect tokens may
|
|
50
|
+
* only touch vaults granted to them, not mint new ones — so a failed
|
|
51
|
+
* creation stays loud with the provider's stderr in the details: the fix
|
|
52
|
+
* (pre-provision the vault and grant the credential) belongs to a human.
|
|
53
|
+
*/
|
|
54
|
+
private ensureVault;
|
|
55
|
+
remove(paths: ProjectPaths, key: string): Promise<boolean>;
|
|
56
|
+
}
|
|
57
|
+
export declare const onepasswordSink: OnePasswordSink;
|
|
58
|
+
//# sourceMappingURL=onepassword.d.ts.map
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { asSecret, SepError } from '@envseal/protocol';
|
|
2
|
+
import { CliCommandFailure, CliSinkBase, commandExists, execCli } from './cli-sink-base.js';
|
|
3
|
+
import { unsafeSecretToUtf8 } from './dotenv.js';
|
|
4
|
+
// One dedicated vault holds every envseal item, so the project-scoped titles
|
|
5
|
+
// below never collide with anything a user keeps in their personal vaults,
|
|
6
|
+
// and a cleanup sweep has an exact boundary.
|
|
7
|
+
const VAULT = 'envseal';
|
|
8
|
+
// Login is the one category every account and Connect deployment accepts;
|
|
9
|
+
// the field labels do the real naming work.
|
|
10
|
+
const CATEGORY = 'login';
|
|
11
|
+
const CREDENTIAL_FIELD = 'credential';
|
|
12
|
+
const KEY_FIELD = 'envseal-key';
|
|
13
|
+
/**
|
|
14
|
+
* op ships no documented exit code for "absent" (unlike security's err 44),
|
|
15
|
+
* so absence rides the provider's own error strings — the same route every
|
|
16
|
+
* serious op wrapper takes. Every fragment stays anchored to an item/vault
|
|
17
|
+
* noun so network noise like "host not found" can never pass for a missing
|
|
18
|
+
* item:
|
|
19
|
+
*
|
|
20
|
+
* `"t" isn't an item in the envseal vault.` / `"v" isn't a vault in this account.`
|
|
21
|
+
*/
|
|
22
|
+
const OP_ABSENCE_RE = /\bisn't an item\b|\bisn't a vault\b|\bno item found\b|\bno vault found\b|\b(?:item|vault)\b[^\n]{0,80}\bnot found\b/i;
|
|
23
|
+
function readsAsAbsent(error) {
|
|
24
|
+
return error instanceof CliCommandFailure && OP_ABSENCE_RE.test(error.stderr);
|
|
25
|
+
}
|
|
26
|
+
/** The title write() files this key under — per-project, like the keychain sink's account name. */
|
|
27
|
+
function itemTitleFor(paths, key) {
|
|
28
|
+
const projectId = paths.root.split(/[\\/]/).pop() ?? 'unknown';
|
|
29
|
+
return `${projectId}:${key}`;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The 1Password CLI adapter. Talks to `op` under whatever non-interactive
|
|
33
|
+
* credential the environment carries — a service account token or Connect
|
|
34
|
+
* host/token pair, which `op whoami` validates without ever triggering the
|
|
35
|
+
* interactive unlock a signed-in desktop account would need.
|
|
36
|
+
*
|
|
37
|
+
* One item per credential in the dedicated vault: title `<projectId>:<KEY>`,
|
|
38
|
+
* field `credential` carrying the value (CONCEALED, so 1Password treats it
|
|
39
|
+
* as a secret), field `envseal-key` recording the raw key so items remain
|
|
40
|
+
* attributable even after a project directory is renamed. The value reaches
|
|
41
|
+
* `op item create` inside the item template on stdin (the positional `-`
|
|
42
|
+
* form): op has no --fields-file, and field=value argv pairs land in process
|
|
43
|
+
* listings, while stdin also beats a temp template file by keeping secret
|
|
44
|
+
* bytes off disk entirely.
|
|
45
|
+
*/
|
|
46
|
+
export class OnePasswordSink extends CliSinkBase {
|
|
47
|
+
id = 'onepassword';
|
|
48
|
+
requiredCommands = ['op'];
|
|
49
|
+
/**
|
|
50
|
+
* Result of this instance's first `op whoami`, remembered for the
|
|
51
|
+
* instance's lifetime: a credential validated at first use is trusted for
|
|
52
|
+
* the session, and every operation skips the extra probe from then on. An
|
|
53
|
+
* auth failure after that point surfaces through the operation's own error
|
|
54
|
+
* mapping instead.
|
|
55
|
+
*/
|
|
56
|
+
whoamiOk = null;
|
|
57
|
+
unavailableReason() {
|
|
58
|
+
return 'the op CLI is not installed or no non-interactive 1Password credential is configured (OP_SERVICE_ACCOUNT_TOKEN or OP_CONNECT_HOST/OP_CONNECT_TOKEN)';
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Binary presence is not enough: op with no usable credential answers every
|
|
62
|
+
* real command with an auth error. `op whoami` is the cheapest command that
|
|
63
|
+
* tells the two states apart, and it only reports — it never prompts.
|
|
64
|
+
*/
|
|
65
|
+
async available(_paths) {
|
|
66
|
+
if (!(await commandExists('op')))
|
|
67
|
+
return false;
|
|
68
|
+
return this.whoamiSucceeds();
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The same probe at operation time: a session that expired (or a binary
|
|
72
|
+
* that vanished) between available() and now must surface as
|
|
73
|
+
* SEP_SINK_UNAVAILABLE — which names the fix — rather than as a cryptic
|
|
74
|
+
* auth failure from deep inside `op item get`.
|
|
75
|
+
*/
|
|
76
|
+
async requirePrerequisites() {
|
|
77
|
+
await super.requirePrerequisites();
|
|
78
|
+
if (!(await this.whoamiSucceeds())) {
|
|
79
|
+
// The base keeps its unavailableError() private; restate its shape.
|
|
80
|
+
throw new SepError({
|
|
81
|
+
code: 'SEP_SINK_UNAVAILABLE',
|
|
82
|
+
userMessage: `The ${this.id} sink is not available — ${this.unavailableReason()}.`,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
whoamiSucceeds() {
|
|
87
|
+
if (this.whoamiOk !== null)
|
|
88
|
+
return Promise.resolve(this.whoamiOk);
|
|
89
|
+
return execCli('op', ['whoami']).then(() => (this.whoamiOk = true), () => (this.whoamiOk = false));
|
|
90
|
+
}
|
|
91
|
+
async read(paths, key) {
|
|
92
|
+
await this.requirePrerequisites();
|
|
93
|
+
try {
|
|
94
|
+
const { stdout } = await execCli('op', [
|
|
95
|
+
'item',
|
|
96
|
+
'get',
|
|
97
|
+
itemTitleFor(paths, key),
|
|
98
|
+
'--vault',
|
|
99
|
+
VAULT,
|
|
100
|
+
'--fields',
|
|
101
|
+
CREDENTIAL_FIELD,
|
|
102
|
+
]);
|
|
103
|
+
// A single --fields selector prints the bare value plus exactly one
|
|
104
|
+
// trailing newline; trimming more would corrupt a value that genuinely
|
|
105
|
+
// ends in whitespace.
|
|
106
|
+
return asSecret(Buffer.from(stdout.replace(/\r?\n$/, ''), 'utf8'));
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
if (readsAsAbsent(error))
|
|
110
|
+
return null;
|
|
111
|
+
throw this.sinkFailure('read', error, key);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async write(paths, key, value) {
|
|
115
|
+
await this.requirePrerequisites();
|
|
116
|
+
await this.ensureVault(key);
|
|
117
|
+
const title = itemTitleFor(paths, key);
|
|
118
|
+
// Replace = delete-then-create inside one logical write. A missing item
|
|
119
|
+
// is the ordinary first-write case, not an error; anything else (an
|
|
120
|
+
// ambiguous title, an unreachable server) stays loud, because stacking a
|
|
121
|
+
// second item onto a store we do not understand only compounds the damage.
|
|
122
|
+
try {
|
|
123
|
+
await execCli('op', ['item', 'delete', title, '--vault', VAULT]);
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
if (!readsAsAbsent(error))
|
|
127
|
+
throw this.sinkFailure('write', error, key);
|
|
128
|
+
}
|
|
129
|
+
// Template on stdin (positional `-`) — template and stdin are mutually
|
|
130
|
+
// exclusive, so no --template flag rides along.
|
|
131
|
+
const template = JSON.stringify({
|
|
132
|
+
title,
|
|
133
|
+
category: CATEGORY,
|
|
134
|
+
fields: [
|
|
135
|
+
{ label: CREDENTIAL_FIELD, type: 'CONCEALED', value: unsafeSecretToUtf8(value) },
|
|
136
|
+
{ label: KEY_FIELD, type: 'STRING', value: key },
|
|
137
|
+
],
|
|
138
|
+
});
|
|
139
|
+
try {
|
|
140
|
+
await execCli('op', ['item', 'create', '--vault', VAULT, '-'], { input: template });
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
throw this.sinkFailure('write', error, key);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* A first write on a fresh deployment has no vault yet. Restricted
|
|
148
|
+
* credentials cannot help here — service accounts and Connect tokens may
|
|
149
|
+
* only touch vaults granted to them, not mint new ones — so a failed
|
|
150
|
+
* creation stays loud with the provider's stderr in the details: the fix
|
|
151
|
+
* (pre-provision the vault and grant the credential) belongs to a human.
|
|
152
|
+
*/
|
|
153
|
+
async ensureVault(key) {
|
|
154
|
+
try {
|
|
155
|
+
await execCli('op', ['vault', 'get', VAULT]);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
if (!readsAsAbsent(error))
|
|
160
|
+
throw this.sinkFailure('write', error, key);
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
await execCli('op', ['vault', 'create', VAULT]);
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
throw this.sinkFailure('write', error, key);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
async remove(paths, key) {
|
|
170
|
+
await this.requirePrerequisites();
|
|
171
|
+
try {
|
|
172
|
+
await execCli('op', ['item', 'delete', itemTitleFor(paths, key), '--vault', VAULT]);
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
if (readsAsAbsent(error))
|
|
177
|
+
return false;
|
|
178
|
+
throw this.sinkFailure('remove', error, key);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
export const onepasswordSink = new OnePasswordSink();
|
|
183
|
+
//# sourceMappingURL=onepassword.js.map
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { SepError } from '@envseal/protocol';
|
|
2
|
+
import { DotenvSink } from './dotenv.js';
|
|
3
|
+
import { keychainSink } from './keychain.js';
|
|
4
|
+
import { vaultSink } from './vault.js';
|
|
5
|
+
import { onepasswordSink } from './onepassword.js';
|
|
6
|
+
import { dopplerSink } from './doppler.js';
|
|
7
|
+
import { sopsSink } from './sops.js';
|
|
8
|
+
/**
|
|
9
|
+
* Placeholder for sinks with no adapter module at all. The CLI-backed stubs
|
|
10
|
+
* (vault, onepassword, doppler, sops) refuse because their provider
|
|
11
|
+
* prerequisite is missing; this one refuses because nobody has written the
|
|
12
|
+
* sink yet.
|
|
13
|
+
*/
|
|
14
|
+
class UnimplementedSink {
|
|
15
|
+
id;
|
|
16
|
+
constructor(id) {
|
|
17
|
+
this.id = id;
|
|
18
|
+
}
|
|
19
|
+
async available() {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
async read() {
|
|
23
|
+
throw new SepError({
|
|
24
|
+
code: 'SEP_SINK_UNAVAILABLE',
|
|
25
|
+
userMessage: `The ${this.id} sink adapter is not implemented yet.`,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
async write() {
|
|
29
|
+
throw new SepError({
|
|
30
|
+
code: 'SEP_SINK_UNAVAILABLE',
|
|
31
|
+
userMessage: `The ${this.id} sink adapter is not implemented yet.`,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
async remove() {
|
|
35
|
+
throw new SepError({
|
|
36
|
+
code: 'SEP_SINK_UNAVAILABLE',
|
|
37
|
+
userMessage: `The ${this.id} sink adapter is not implemented yet.`,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const sinks = new Map([
|
|
42
|
+
['dotenv', new DotenvSink()],
|
|
43
|
+
['keychain', keychainSink],
|
|
44
|
+
['sops', sopsSink],
|
|
45
|
+
['onepassword', onepasswordSink],
|
|
46
|
+
['doppler', dopplerSink],
|
|
47
|
+
['vault', vaultSink],
|
|
48
|
+
['external', new UnimplementedSink('external')],
|
|
49
|
+
]);
|
|
50
|
+
export function getSink(id) {
|
|
51
|
+
const sink = sinks.get(id);
|
|
52
|
+
if (!sink) {
|
|
53
|
+
throw new SepError({
|
|
54
|
+
code: 'SEP_SINK_UNAVAILABLE',
|
|
55
|
+
userMessage: `Unknown sink: ${id}`,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
return sink;
|
|
59
|
+
}
|
|
60
|
+
export function allSinks() {
|
|
61
|
+
return Array.from(sinks.values());
|
|
62
|
+
}
|
|
63
|
+
//# sourceMappingURL=registry.js.map
|