@dsh-enhanced/lark-channel 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -279
- package/bin/dsh-lark-setup.js +2 -2
- package/cordis.patch.yml +1 -0
- package/docs/operations.md +118 -0
- package/docs/progress-security.md +123 -0
- package/docs/setup.md +281 -0
- package/docs/supervised-growth.md +56 -0
- package/lib/adapter.d.ts +10 -18
- package/lib/adapter.d.ts.map +1 -1
- package/lib/adapter.js +152 -15
- package/lib/adapter.js.map +1 -1
- package/lib/config.d.ts +1 -0
- package/lib/config.d.ts.map +1 -1
- package/lib/config.js +2 -0
- package/lib/config.js.map +1 -1
- package/lib/index.d.ts +2 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +1 -1
- package/lib/index.js.map +1 -1
- package/lib/progress.d.ts +3 -1
- package/lib/progress.d.ts.map +1 -1
- package/lib/progress.js +107 -20
- package/lib/progress.js.map +1 -1
- package/lib/sdk.d.ts +9 -0
- package/lib/sdk.d.ts.map +1 -1
- package/lib/sdk.js +103 -0
- package/lib/sdk.js.map +1 -1
- package/lib/service.d.ts.map +1 -1
- package/lib/service.js +2 -0
- package/lib/service.js.map +1 -1
- package/lib/setup-profile.d.ts +1 -1
- package/lib/setup-profile.d.ts.map +1 -1
- package/lib/setup-profile.js +8 -4
- package/lib/setup-profile.js.map +1 -1
- package/lib/setup.d.ts +78 -1
- package/lib/setup.d.ts.map +1 -1
- package/lib/setup.js +963 -86
- package/lib/setup.js.map +1 -1
- package/lib/systemd.d.ts +25 -4
- package/lib/systemd.d.ts.map +1 -1
- package/lib/systemd.js +168 -19
- package/lib/systemd.js.map +1 -1
- package/lib/types.d.ts +18 -0
- package/lib/types.d.ts.map +1 -1
- package/lib/types.js.map +1 -1
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/lib/windows-task.d.ts +3 -0
- package/lib/windows-task.d.ts.map +1 -1
- package/lib/windows-task.js +27 -1
- package/lib/windows-task.js.map +1 -1
- package/package.json +6 -5
package/lib/setup.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createHash, randomBytes } from 'node:crypto';
|
|
3
|
-
import { realpathSync } from 'node:fs';
|
|
4
|
-
import { chmod, mkdir, open, readFile, readdir, rename, rm, unlink } from 'node:fs/promises';
|
|
3
|
+
import { accessSync, constants, realpathSync } from 'node:fs';
|
|
4
|
+
import { chmod, lstat, mkdir, open, readFile, readdir, rename, rm, unlink } from 'node:fs/promises';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
|
-
import { basename, dirname, isAbsolute, join, win32 } from 'node:path';
|
|
6
|
+
import { basename, dirname, isAbsolute, join, normalize, win32 } from 'node:path';
|
|
7
7
|
import { DatabaseSync } from 'node:sqlite';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
import { createInterface } from 'node:readline/promises';
|
|
@@ -13,6 +13,7 @@ import * as QRCode from 'qrcode';
|
|
|
13
13
|
import { isMap, isSeq, parseDocument } from 'yaml';
|
|
14
14
|
import { createOfficialLarkTransport } from './sdk.js';
|
|
15
15
|
import { installDshResidentService } from './resident.js';
|
|
16
|
+
import { prepareDshSystemdUserService } from './systemd.js';
|
|
16
17
|
import { configureLarkProfilePatch, refreshLarkAgentPolicyPatch } from './setup-profile.js';
|
|
17
18
|
import { assertRawManagedProfileIntegrity, materializeManagedProfileOverride, } from './setup-materialization.js';
|
|
18
19
|
const setupKeyPattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u;
|
|
@@ -36,6 +37,7 @@ export function parseLarkSetupArgs(argv) {
|
|
|
36
37
|
installServiceOnly: false,
|
|
37
38
|
refreshAgentPolicy: false,
|
|
38
39
|
manageService: true,
|
|
40
|
+
linuxCredentialProvider: 'auto',
|
|
39
41
|
agentTools: 'preserve',
|
|
40
42
|
help: false,
|
|
41
43
|
};
|
|
@@ -67,6 +69,9 @@ export function parseLarkSetupArgs(argv) {
|
|
|
67
69
|
result.refreshAgentPolicy = true;
|
|
68
70
|
else if (option === '--no-service')
|
|
69
71
|
result.manageService = false;
|
|
72
|
+
else if (option === '--linux-credential-provider') {
|
|
73
|
+
result.linuxCredentialProvider = argumentValue(argv, index++, option);
|
|
74
|
+
}
|
|
70
75
|
else if (option === '--allow-agent-tools' || option === '--disable-agent-tools') {
|
|
71
76
|
if (result.agentTools !== 'preserve') {
|
|
72
77
|
throw new Error('lark-channel setup: --allow-agent-tools and --disable-agent-tools are mutually exclusive');
|
|
@@ -92,7 +97,7 @@ export function parseLarkSetupArgs(argv) {
|
|
|
92
97
|
if (!result.help && result.refreshAgentPolicy) {
|
|
93
98
|
const incompatible = [
|
|
94
99
|
'--domain', '--tenant', '--app-id', '--create-app', '--install-service',
|
|
95
|
-
'--no-service', '--app-name', '--timeout-ms',
|
|
100
|
+
'--no-service', '--linux-credential-provider', '--app-name', '--timeout-ms',
|
|
96
101
|
].find(option => argv.includes(option));
|
|
97
102
|
if (incompatible !== undefined) {
|
|
98
103
|
throw new Error(`lark-channel setup: --refresh-agent-policy cannot be combined with ${incompatible}`);
|
|
@@ -107,6 +112,9 @@ export function parseLarkSetupArgs(argv) {
|
|
|
107
112
|
if (result.installServiceOnly && (result.createApp || result.appId !== undefined)) {
|
|
108
113
|
throw new Error('lark-channel setup: --install-service cannot be combined with application setup options');
|
|
109
114
|
}
|
|
115
|
+
if (result.installServiceOnly && argv.includes('--linux-credential-provider')) {
|
|
116
|
+
throw new Error('lark-channel setup: --install-service cannot be combined with --linux-credential-provider');
|
|
117
|
+
}
|
|
110
118
|
if (result.installServiceOnly && result.agentTools !== 'preserve') {
|
|
111
119
|
throw new Error('lark-channel setup: --install-service cannot be combined with agent-tools options');
|
|
112
120
|
}
|
|
@@ -117,6 +125,9 @@ export function parseLarkSetupArgs(argv) {
|
|
|
117
125
|
}
|
|
118
126
|
if (result.domain !== 'feishu' && result.domain !== 'lark')
|
|
119
127
|
throw new Error('lark-channel setup: domain must be feishu or lark');
|
|
128
|
+
if (!['auto', 'protected-file', 'secret-service'].includes(result.linuxCredentialProvider)) {
|
|
129
|
+
throw new Error('lark-channel setup: linux-credential-provider must be auto, protected-file, or secret-service');
|
|
130
|
+
}
|
|
120
131
|
if (!Number.isSafeInteger(result.timeoutMs) || result.timeoutMs < 30_000 || result.timeoutMs > 900_000) {
|
|
121
132
|
throw new Error('lark-channel setup: timeout-ms must be an integer from 30000 to 900000');
|
|
122
133
|
}
|
|
@@ -168,6 +179,12 @@ export function createVersionedCredentialLocator(input) {
|
|
|
168
179
|
if (!credentialVersionPattern.test(version)) {
|
|
169
180
|
throw new Error('lark-channel setup: invalid credential version');
|
|
170
181
|
}
|
|
182
|
+
if (input.provider === 'linux-protected-file') {
|
|
183
|
+
return {
|
|
184
|
+
provider: input.provider,
|
|
185
|
+
path: join(input.dshHome, 'credentials-keychain', `lark-${input.profile}-${input.account}-${version}.secret`),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
171
188
|
if (input.provider === 'windows-dpapi') {
|
|
172
189
|
return {
|
|
173
190
|
provider: input.provider,
|
|
@@ -264,6 +281,8 @@ Options:
|
|
|
264
281
|
--install-service Only install/restart the profile's resident service
|
|
265
282
|
--refresh-agent-policy Only refresh policy from the existing channel binding
|
|
266
283
|
--no-service Configure Lark without installing a resident service
|
|
284
|
+
--linux-credential-provider <mode>
|
|
285
|
+
auto, protected-file, or secret-service (default: auto)
|
|
267
286
|
--allow-agent-tools Allow mounted foreground/external Agent capabilities
|
|
268
287
|
--disable-agent-tools Remove setup-managed Agent capability rules
|
|
269
288
|
--timeout-ms <ms> Owner DM wait, 30000..900000 (default: 300000)
|
|
@@ -271,7 +290,8 @@ Options:
|
|
|
271
290
|
|
|
272
291
|
Without --create-app or --app-id, press Enter at the App ID prompt to select or create an app.
|
|
273
292
|
App Secret is intentionally not accepted as an argument. It is stored in macOS Keychain,
|
|
274
|
-
Linux Secret Service
|
|
293
|
+
Linux Secret Service (with an automatic private-file fallback for headless Linux),
|
|
294
|
+
or a per-user Windows DPAPI file.`;
|
|
275
295
|
}
|
|
276
296
|
function runSecurity(args, stdio) {
|
|
277
297
|
const result = spawnSync('/usr/bin/security', [...args], {
|
|
@@ -284,45 +304,556 @@ function runSecurity(args, stdio) {
|
|
|
284
304
|
throw new Error('lark-channel setup: macOS Keychain operation failed');
|
|
285
305
|
return typeof result.stdout === 'string' ? result.stdout.trimEnd() : '';
|
|
286
306
|
}
|
|
287
|
-
function linuxCredentialEnv() {
|
|
307
|
+
function linuxCredentialEnv(environment = process.env) {
|
|
288
308
|
return {
|
|
289
309
|
PATH: '/usr/bin:/bin',
|
|
290
|
-
...(
|
|
291
|
-
? {} : { DBUS_SESSION_BUS_ADDRESS:
|
|
292
|
-
...(
|
|
310
|
+
...(environment.DBUS_SESSION_BUS_ADDRESS === undefined
|
|
311
|
+
? {} : { DBUS_SESSION_BUS_ADDRESS: environment.DBUS_SESSION_BUS_ADDRESS }),
|
|
312
|
+
...(environment.XDG_RUNTIME_DIR === undefined ? {} : { XDG_RUNTIME_DIR: environment.XDG_RUNTIME_DIR }),
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
const linuxSecretTool = '/usr/bin/secret-tool';
|
|
316
|
+
/** Bound every Secret Service client call, including transaction recovery. */
|
|
317
|
+
const linuxSecretServiceTimeoutMs = 30_000;
|
|
318
|
+
/** A deliberately static diagnostic: never include a provider's stderr or a secret value. */
|
|
319
|
+
class LarkSetupDiagnosticError extends Error {
|
|
320
|
+
diagnostic;
|
|
321
|
+
constructor(diagnostic) {
|
|
322
|
+
super(`lark-channel setup: ${diagnostic}`);
|
|
323
|
+
this.diagnostic = diagnostic;
|
|
324
|
+
this.name = 'LarkSetupDiagnosticError';
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
function commandErrorCode(result) {
|
|
328
|
+
const error = result?.error;
|
|
329
|
+
if (error === null || typeof error !== 'object' || !('code' in error))
|
|
330
|
+
return undefined;
|
|
331
|
+
const code = error.code;
|
|
332
|
+
return typeof code === 'string' ? code : undefined;
|
|
333
|
+
}
|
|
334
|
+
function hasLinuxSecretServiceSession(environment) {
|
|
335
|
+
const dbus = environment.DBUS_SESSION_BUS_ADDRESS?.trim() ?? '';
|
|
336
|
+
const runtimeDirectory = environment.XDG_RUNTIME_DIR?.trim() ?? '';
|
|
337
|
+
return dbus !== '' || runtimeDirectory !== '';
|
|
338
|
+
}
|
|
339
|
+
function linuxSecretServiceDiagnostic(operation, result, environment) {
|
|
340
|
+
const common = 'Run setup as the target logged-in user (not via sudo or a detached SSH session). '
|
|
341
|
+
+ 'For a headless server or container, use the default auto fallback or rerun with '
|
|
342
|
+
+ '--linux-credential-provider protected-file.';
|
|
343
|
+
if (commandErrorCode(result) === 'ENOENT') {
|
|
344
|
+
return new LarkSetupDiagnosticError(`Linux Secret Service ${operation} cannot find ${linuxSecretTool}. Install libsecret-tools and a Secret Service provider, then retry. ${common}`);
|
|
345
|
+
}
|
|
346
|
+
if (!hasLinuxSecretServiceSession(environment)) {
|
|
347
|
+
return new LarkSetupDiagnosticError(`Linux Secret Service ${operation} has no user D-Bus session. Start from the target user's desktop/login session with DBUS_SESSION_BUS_ADDRESS or XDG_RUNTIME_DIR set. ${common}`);
|
|
348
|
+
}
|
|
349
|
+
const wasSignalled = result?.signal !== undefined && result.signal !== null;
|
|
350
|
+
if (commandErrorCode(result) === 'ETIMEDOUT' || wasSignalled) {
|
|
351
|
+
return new LarkSetupDiagnosticError(`Linux Secret Service ${operation} did not respond. Unlock or start a Secret Service provider such as GNOME Keyring, KWallet, or KeePassXC, then retry. ${common}`);
|
|
352
|
+
}
|
|
353
|
+
return new LarkSetupDiagnosticError(`Linux Secret Service ${operation} failed. Ensure ${linuxSecretTool} is installed and an unlocked org.freedesktop.secrets provider is available in this user's session, then retry. ${common}`);
|
|
354
|
+
}
|
|
355
|
+
function defaultLinuxSecretToolAvailable(path) {
|
|
356
|
+
try {
|
|
357
|
+
accessSync(path, constants.X_OK);
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
function defaultLinuxSecretServiceCommand(input) {
|
|
365
|
+
const result = spawnSync(input.executable, [...input.args], {
|
|
366
|
+
encoding: 'utf8',
|
|
367
|
+
env: input.environment,
|
|
368
|
+
timeout: linuxSecretServiceTimeoutMs,
|
|
369
|
+
maxBuffer: 128 * 1024,
|
|
370
|
+
...(input.input === undefined ? {} : { input: input.input }),
|
|
371
|
+
stdio: [input.input === undefined ? 'ignore' : 'pipe', input.captureStdout ? 'pipe' : 'ignore', 'pipe'],
|
|
372
|
+
});
|
|
373
|
+
return {
|
|
374
|
+
status: result.status,
|
|
375
|
+
signal: result.signal,
|
|
376
|
+
...(typeof result.stdout === 'string' ? { stdout: result.stdout } : {}),
|
|
377
|
+
...(typeof result.stderr === 'string' ? { stderr: result.stderr } : {}),
|
|
378
|
+
...(result.error === undefined ? {} : { error: result.error }),
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
function captureLinuxSecretServiceCommand(run, input) {
|
|
382
|
+
try {
|
|
383
|
+
return run(input);
|
|
384
|
+
}
|
|
385
|
+
catch (error) {
|
|
386
|
+
// Test seams and alternate runners may throw rather than return spawnSync's
|
|
387
|
+
// structured error. Preserve only the error code for static diagnostics;
|
|
388
|
+
// provider messages can contain secret material and must never be rendered.
|
|
389
|
+
return { status: null, signal: null, error };
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
/** libsecret reports a clear with no matching item as status 1 and no diagnostics. */
|
|
393
|
+
export function isLinuxSecretServiceClearAbsent(result) {
|
|
394
|
+
return result.status === 1
|
|
395
|
+
&& result.signal === null
|
|
396
|
+
&& result.error === undefined
|
|
397
|
+
&& typeof result.stderr === 'string'
|
|
398
|
+
&& result.stderr.trim() === '';
|
|
399
|
+
}
|
|
400
|
+
function verifyLinuxSecretServiceWriteCanary(input) {
|
|
401
|
+
const service = `dsh/lark/setup-preflight/${input.random(16).toString('hex')}`;
|
|
402
|
+
const account = 'setup-probe';
|
|
403
|
+
const secret = input.random(32).toString('hex');
|
|
404
|
+
const write = createSecretServiceWriteRequest(service, account, secret);
|
|
405
|
+
let primaryError;
|
|
406
|
+
try {
|
|
407
|
+
const stored = captureLinuxSecretServiceCommand(input.run, {
|
|
408
|
+
executable: linuxSecretTool,
|
|
409
|
+
args: write.args,
|
|
410
|
+
environment: input.environment,
|
|
411
|
+
input: write.input,
|
|
412
|
+
});
|
|
413
|
+
if (stored.status !== 0) {
|
|
414
|
+
throw linuxSecretServiceDiagnostic('credential store', stored, input.environment);
|
|
415
|
+
}
|
|
416
|
+
const read = captureLinuxSecretServiceCommand(input.run, {
|
|
417
|
+
executable: linuxSecretTool,
|
|
418
|
+
args: ['lookup', 'service', service, 'account', account],
|
|
419
|
+
environment: input.environment,
|
|
420
|
+
captureStdout: true,
|
|
421
|
+
});
|
|
422
|
+
if (read.status !== 0) {
|
|
423
|
+
throw linuxSecretServiceDiagnostic('credential lookup', read, input.environment);
|
|
424
|
+
}
|
|
425
|
+
if ((read.stdout ?? '').trimEnd() !== secret) {
|
|
426
|
+
throw new LarkSetupDiagnosticError('Linux Secret Service preflight could not read back its generated test credential. '
|
|
427
|
+
+ 'Unlock or repair the Secret Service provider, then retry.');
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
catch (error) {
|
|
431
|
+
primaryError = error;
|
|
432
|
+
}
|
|
433
|
+
finally {
|
|
434
|
+
write.input.fill(0);
|
|
435
|
+
}
|
|
436
|
+
const cleared = captureLinuxSecretServiceCommand(input.run, {
|
|
437
|
+
executable: linuxSecretTool,
|
|
438
|
+
args: ['clear', 'service', service, 'account', account],
|
|
439
|
+
environment: input.environment,
|
|
440
|
+
});
|
|
441
|
+
if (cleared.status !== 0 && !isLinuxSecretServiceClearAbsent(cleared)) {
|
|
442
|
+
const cleanupError = linuxSecretServiceDiagnostic('credential cleanup', cleared, input.environment);
|
|
443
|
+
if (primaryError !== undefined) {
|
|
444
|
+
throw new AggregateError([primaryError, cleanupError], 'lark-channel setup: Linux Secret Service preflight failed and canary cleanup also failed');
|
|
445
|
+
}
|
|
446
|
+
throw cleanupError;
|
|
447
|
+
}
|
|
448
|
+
if (primaryError !== undefined)
|
|
449
|
+
throw primaryError;
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Checks the Linux credential prerequisites before an OAuth flow changes a
|
|
453
|
+
* cloud app. It stores, reads, and clears a random non-production canary, and
|
|
454
|
+
* never reads, writes, or prints an application credential.
|
|
455
|
+
*/
|
|
456
|
+
export function assertLinuxSecretServiceAvailable(options = {}) {
|
|
457
|
+
const environment = linuxCredentialEnv(options.environment ?? process.env);
|
|
458
|
+
const available = options.executableAvailable ?? defaultLinuxSecretToolAvailable;
|
|
459
|
+
if (!available(linuxSecretTool)) {
|
|
460
|
+
throw linuxSecretServiceDiagnostic('preflight', {
|
|
461
|
+
status: null, signal: null, error: { code: 'ENOENT' },
|
|
462
|
+
}, environment);
|
|
463
|
+
}
|
|
464
|
+
if (!hasLinuxSecretServiceSession(environment)) {
|
|
465
|
+
throw linuxSecretServiceDiagnostic('preflight', undefined, environment);
|
|
466
|
+
}
|
|
467
|
+
verifyLinuxSecretServiceWriteCanary({
|
|
468
|
+
environment,
|
|
469
|
+
random: options.randomBytes ?? randomBytes,
|
|
470
|
+
run: options.run ?? defaultLinuxSecretServiceCommand,
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
export function preflightLarkCredentialProvider(provider) {
|
|
474
|
+
if (provider === 'linux-secret-service')
|
|
475
|
+
assertLinuxSecretServiceAvailable();
|
|
476
|
+
}
|
|
477
|
+
async function assertLinuxProtectedFileRuntimeSupported() {
|
|
478
|
+
let providers;
|
|
479
|
+
try {
|
|
480
|
+
const keychain = await import('@dsh-enhanced/credentials-keychain/capabilities');
|
|
481
|
+
providers = keychain.supportedCredentialProviders;
|
|
482
|
+
}
|
|
483
|
+
catch (error) {
|
|
484
|
+
throw new Error('lark-channel setup: cannot load a compatible credentials-keychain package; reinstall matching plugin versions', { cause: error });
|
|
485
|
+
}
|
|
486
|
+
if (!Array.isArray(providers) || !providers.includes('linux-protected-file')) {
|
|
487
|
+
throw new Error('lark-channel setup: installed credentials-keychain does not support headless Linux; reinstall matching plugin versions');
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
/** Verifies that the headless backend can survive the same write/read/delete lifecycle as onboarding. */
|
|
491
|
+
export async function assertLinuxProtectedFileAvailable(input) {
|
|
492
|
+
if (!isAbsolute(input.dshHome))
|
|
493
|
+
throw new Error('lark-channel setup: DSH_HOME must be absolute');
|
|
494
|
+
const random = input.randomBytes ?? randomBytes;
|
|
495
|
+
const path = join(input.dshHome, 'credentials-keychain', `.lark-setup-preflight-${random(16).toString('hex')}.secret`);
|
|
496
|
+
const secret = random(32).toString('hex');
|
|
497
|
+
let stored = false;
|
|
498
|
+
let primaryError;
|
|
499
|
+
try {
|
|
500
|
+
await storeLinuxProtectedCredential(path, secret);
|
|
501
|
+
stored = true;
|
|
502
|
+
await input.afterStore?.(path);
|
|
503
|
+
if (await readLinuxProtectedCredential(path) !== secret) {
|
|
504
|
+
throw new Error('lark-channel setup: Linux protected credential preflight readback did not match');
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
catch (error) {
|
|
508
|
+
primaryError = error;
|
|
509
|
+
}
|
|
510
|
+
if (stored) {
|
|
511
|
+
try {
|
|
512
|
+
await removeCredential({ provider: 'linux-protected-file', path });
|
|
513
|
+
}
|
|
514
|
+
catch (cleanupError) {
|
|
515
|
+
if (primaryError !== undefined) {
|
|
516
|
+
throw new AggregateError([primaryError, cleanupError], 'lark-channel setup: Linux protected credential preflight and cleanup both failed');
|
|
517
|
+
}
|
|
518
|
+
throw cleanupError;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
if (primaryError !== undefined)
|
|
522
|
+
throw primaryError;
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Prefers the desktop keyring but lets a headless Linux login continue without
|
|
526
|
+
* weakening the profile to an ambient environment variable. Unexpected setup
|
|
527
|
+
* errors remain fail-closed; only our static Secret Service diagnostics select
|
|
528
|
+
* the protected-file backend.
|
|
529
|
+
*/
|
|
530
|
+
export async function selectLarkCredentialProvider(preferred, options) {
|
|
531
|
+
const mode = options.mode ?? 'auto';
|
|
532
|
+
if (preferred !== 'linux-secret-service') {
|
|
533
|
+
if (mode !== 'auto') {
|
|
534
|
+
throw new Error('lark-channel setup: --linux-credential-provider is only valid on Linux');
|
|
535
|
+
}
|
|
536
|
+
return { provider: preferred };
|
|
537
|
+
}
|
|
538
|
+
if (mode === 'protected-file') {
|
|
539
|
+
await assertLinuxProtectedFileRuntimeSupported();
|
|
540
|
+
await assertLinuxProtectedFileAvailable(options);
|
|
541
|
+
return { provider: 'linux-protected-file' };
|
|
542
|
+
}
|
|
543
|
+
try {
|
|
544
|
+
assertLinuxSecretServiceAvailable(options);
|
|
545
|
+
return { provider: preferred };
|
|
546
|
+
}
|
|
547
|
+
catch (error) {
|
|
548
|
+
if (mode === 'secret-service' || safeDiagnosticMessages(error).length === 0)
|
|
549
|
+
throw error;
|
|
550
|
+
await assertLinuxProtectedFileRuntimeSupported();
|
|
551
|
+
await assertLinuxProtectedFileAvailable(options);
|
|
552
|
+
return { provider: 'linux-protected-file', fallbackReason: error };
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
function safeDiagnosticMessages(error) {
|
|
556
|
+
const messages = new Set();
|
|
557
|
+
const visited = new Set();
|
|
558
|
+
const walk = (current) => {
|
|
559
|
+
if (current === null || typeof current !== 'object' || visited.has(current))
|
|
560
|
+
return;
|
|
561
|
+
visited.add(current);
|
|
562
|
+
if (current instanceof LarkSetupDiagnosticError) {
|
|
563
|
+
messages.add(current.diagnostic);
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
if (current instanceof AggregateError) {
|
|
567
|
+
for (const nested of current.errors)
|
|
568
|
+
walk(nested);
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
if (current instanceof Error)
|
|
572
|
+
walk(current.cause);
|
|
293
573
|
};
|
|
574
|
+
walk(error);
|
|
575
|
+
return [...messages];
|
|
576
|
+
}
|
|
577
|
+
/** Adds only static, credential-safe diagnostics to AggregateError CLI output. */
|
|
578
|
+
export function formatLarkSetupError(error) {
|
|
579
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
580
|
+
if (!(error instanceof AggregateError))
|
|
581
|
+
return message;
|
|
582
|
+
const diagnostics = safeDiagnosticMessages(error);
|
|
583
|
+
return diagnostics.length === 0 ? message : `${message}\n${diagnostics.map(value => `Hint: ${value}`).join('\n')}`;
|
|
584
|
+
}
|
|
585
|
+
const protectedCredentialMaxBytes = 65_536;
|
|
586
|
+
function currentLinuxCredentialUid() {
|
|
587
|
+
const uid = process.getuid?.();
|
|
588
|
+
const effectiveUid = process.geteuid?.();
|
|
589
|
+
if (uid === undefined || effectiveUid === undefined || uid !== effectiveUid) {
|
|
590
|
+
throw new Error('lark-channel setup: Linux protected credentials require matching real and effective user IDs');
|
|
591
|
+
}
|
|
592
|
+
return uid;
|
|
593
|
+
}
|
|
594
|
+
function linuxProtectedCredentialDirectory(path) {
|
|
595
|
+
if (!isAbsolute(path) || normalize(path) !== path || path.length > 1_024
|
|
596
|
+
|| path.includes('\0') || !path.endsWith('.secret')) {
|
|
597
|
+
throw new Error('lark-channel setup: invalid Linux protected credential path');
|
|
598
|
+
}
|
|
599
|
+
return dirname(path);
|
|
600
|
+
}
|
|
601
|
+
async function ensureLinuxProtectedCredentialDirectory(path) {
|
|
602
|
+
const directory = linuxProtectedCredentialDirectory(path);
|
|
603
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
604
|
+
let metadata = await lstat(directory);
|
|
605
|
+
const uid = currentLinuxCredentialUid();
|
|
606
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink()
|
|
607
|
+
|| metadata.uid !== uid) {
|
|
608
|
+
throw new Error('lark-channel setup: Linux protected credential directory is not private to the current user');
|
|
609
|
+
}
|
|
610
|
+
if ((metadata.mode & 0o7777) !== 0o700) {
|
|
611
|
+
await chmod(directory, 0o700);
|
|
612
|
+
metadata = await lstat(directory);
|
|
613
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink()
|
|
614
|
+
|| metadata.uid !== uid || (metadata.mode & 0o7777) !== 0o700) {
|
|
615
|
+
throw new Error('lark-channel setup: Linux protected credential directory could not be secured');
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
async function assertLinuxProtectedCredentialDirectory(path) {
|
|
620
|
+
const directory = linuxProtectedCredentialDirectory(path);
|
|
621
|
+
const metadata = await lstat(directory);
|
|
622
|
+
const uid = currentLinuxCredentialUid();
|
|
623
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink()
|
|
624
|
+
|| metadata.uid !== uid || (metadata.mode & 0o7777) !== 0o700) {
|
|
625
|
+
throw new Error('lark-channel setup: Linux protected credential directory failed its security check');
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
async function storeLinuxProtectedCredential(path, secret) {
|
|
629
|
+
const value = Buffer.from(secret, 'utf8');
|
|
630
|
+
if (value.byteLength < 1 || value.byteLength > protectedCredentialMaxBytes || value.includes(0)) {
|
|
631
|
+
value.fill(0);
|
|
632
|
+
throw new Error('lark-channel setup: Linux protected credential value is invalid');
|
|
633
|
+
}
|
|
634
|
+
let file;
|
|
635
|
+
let created = false;
|
|
636
|
+
try {
|
|
637
|
+
await ensureLinuxProtectedCredentialDirectory(path);
|
|
638
|
+
// The locator contains a fresh random version, so an existing final item
|
|
639
|
+
// is a collision or interference. O_EXCL avoids following or replacing it.
|
|
640
|
+
file = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
641
|
+
created = true;
|
|
642
|
+
await file.chmod(0o600);
|
|
643
|
+
await file.writeFile(value);
|
|
644
|
+
await file.sync();
|
|
645
|
+
const metadata = await file.stat();
|
|
646
|
+
const uid = currentLinuxCredentialUid();
|
|
647
|
+
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.nlink !== 1
|
|
648
|
+
|| metadata.uid !== uid || (metadata.mode & 0o7777) !== 0o600
|
|
649
|
+
|| metadata.size !== value.byteLength) {
|
|
650
|
+
throw new Error('lark-channel setup: Linux protected credential file failed its security check');
|
|
651
|
+
}
|
|
652
|
+
await file.close();
|
|
653
|
+
file = undefined;
|
|
654
|
+
await syncDirectory(dirname(path));
|
|
655
|
+
}
|
|
656
|
+
catch (error) {
|
|
657
|
+
try {
|
|
658
|
+
await file?.close();
|
|
659
|
+
}
|
|
660
|
+
catch { }
|
|
661
|
+
file = undefined;
|
|
662
|
+
if (created) {
|
|
663
|
+
try {
|
|
664
|
+
await unlink(path);
|
|
665
|
+
await syncDirectory(dirname(path));
|
|
666
|
+
}
|
|
667
|
+
catch (cleanupError) {
|
|
668
|
+
if (fileSystemErrorCode(cleanupError) !== 'ENOENT') {
|
|
669
|
+
throw new AggregateError([error, cleanupError], 'lark-channel setup: Linux protected credential write and cleanup both failed');
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
throw error;
|
|
674
|
+
}
|
|
675
|
+
finally {
|
|
676
|
+
value.fill(0);
|
|
677
|
+
await file?.close();
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
async function readLinuxProtectedCredential(path) {
|
|
681
|
+
await assertLinuxProtectedCredentialDirectory(path);
|
|
682
|
+
let file;
|
|
683
|
+
let value;
|
|
684
|
+
try {
|
|
685
|
+
file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
686
|
+
const before = await file.stat();
|
|
687
|
+
const uid = currentLinuxCredentialUid();
|
|
688
|
+
if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1
|
|
689
|
+
|| before.uid !== uid || (before.mode & 0o7777) !== 0o600
|
|
690
|
+
|| before.size < 1 || before.size > protectedCredentialMaxBytes) {
|
|
691
|
+
throw new Error('lark-channel setup: Linux protected credential file failed its security check');
|
|
692
|
+
}
|
|
693
|
+
value = Buffer.alloc(protectedCredentialMaxBytes + 1);
|
|
694
|
+
let bytesRead = 0;
|
|
695
|
+
while (bytesRead < value.byteLength) {
|
|
696
|
+
const result = await file.read(value, bytesRead, value.byteLength - bytesRead, bytesRead);
|
|
697
|
+
if (result.bytesRead === 0)
|
|
698
|
+
break;
|
|
699
|
+
bytesRead += result.bytesRead;
|
|
700
|
+
}
|
|
701
|
+
const after = await file.stat();
|
|
702
|
+
if (bytesRead < 1 || bytesRead > protectedCredentialMaxBytes
|
|
703
|
+
|| bytesRead !== after.size
|
|
704
|
+
|| after.dev !== before.dev || after.ino !== before.ino
|
|
705
|
+
|| !after.isFile() || after.nlink !== 1 || after.uid !== uid || (after.mode & 0o7777) !== 0o600
|
|
706
|
+
|| value.subarray(0, bytesRead).includes(0)) {
|
|
707
|
+
throw new Error('lark-channel setup: Linux protected credential value is invalid');
|
|
708
|
+
}
|
|
709
|
+
return value.toString('utf8', 0, bytesRead);
|
|
710
|
+
}
|
|
711
|
+
catch (error) {
|
|
712
|
+
throw new Error('lark-channel setup: Linux protected credential lookup failed', { cause: error });
|
|
713
|
+
}
|
|
714
|
+
finally {
|
|
715
|
+
value?.fill(0);
|
|
716
|
+
await file?.close();
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
export async function readSecretFromRawTerminal(prompt, options = {}) {
|
|
720
|
+
const input = options.input ?? process.stdin;
|
|
721
|
+
const output = options.output ?? process.stdout;
|
|
722
|
+
const signals = options.signals ?? process;
|
|
723
|
+
if (!input.isTTY || !output.isTTY || typeof input.setRawMode !== 'function') {
|
|
724
|
+
throw new Error('lark-channel setup: no secure interactive terminal is available for the App Secret');
|
|
725
|
+
}
|
|
726
|
+
output.write(prompt);
|
|
727
|
+
return new Promise((resolve, reject) => {
|
|
728
|
+
const bytes = [];
|
|
729
|
+
const wasRaw = input.isRaw;
|
|
730
|
+
const wasPaused = input.isPaused();
|
|
731
|
+
let settled = false;
|
|
732
|
+
const signalHandlers = new Map();
|
|
733
|
+
let onData;
|
|
734
|
+
const finish = (error) => {
|
|
735
|
+
if (settled)
|
|
736
|
+
return;
|
|
737
|
+
settled = true;
|
|
738
|
+
let restorationError;
|
|
739
|
+
try {
|
|
740
|
+
input.off('data', onData);
|
|
741
|
+
for (const [signal, listener] of signalHandlers)
|
|
742
|
+
signals.off(signal, listener);
|
|
743
|
+
input.setRawMode(Boolean(wasRaw));
|
|
744
|
+
if (wasPaused)
|
|
745
|
+
input.pause();
|
|
746
|
+
}
|
|
747
|
+
catch (caught) {
|
|
748
|
+
restorationError = caught;
|
|
749
|
+
}
|
|
750
|
+
finally {
|
|
751
|
+
output.write('\n');
|
|
752
|
+
}
|
|
753
|
+
const secretBytes = Buffer.from(bytes);
|
|
754
|
+
const secret = secretBytes.toString('utf8');
|
|
755
|
+
secretBytes.fill(0);
|
|
756
|
+
bytes.fill(0);
|
|
757
|
+
if (restorationError !== undefined) {
|
|
758
|
+
reject(new Error('lark-channel setup: failed to restore terminal input mode', { cause: restorationError }));
|
|
759
|
+
}
|
|
760
|
+
else if (error !== undefined)
|
|
761
|
+
reject(error);
|
|
762
|
+
else if (secret.length === 0)
|
|
763
|
+
reject(new Error('lark-channel setup: Linux could not read the App Secret securely'));
|
|
764
|
+
else
|
|
765
|
+
resolve(secret);
|
|
766
|
+
};
|
|
767
|
+
onData = (chunk) => {
|
|
768
|
+
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
769
|
+
try {
|
|
770
|
+
for (const byte of value) {
|
|
771
|
+
if (byte === 3) {
|
|
772
|
+
finish(new Error('lark-channel setup: App Secret input was cancelled'));
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
if (byte === 10 || byte === 13) {
|
|
776
|
+
finish();
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
if (byte === 8 || byte === 127) {
|
|
780
|
+
bytes.pop();
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
783
|
+
if (byte < 32 || bytes.length >= protectedCredentialMaxBytes) {
|
|
784
|
+
finish(new Error('lark-channel setup: App Secret input is invalid or too large'));
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
bytes.push(byte);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
finally {
|
|
791
|
+
value.fill(0);
|
|
792
|
+
}
|
|
793
|
+
};
|
|
794
|
+
for (const signal of ['SIGHUP', 'SIGINT', 'SIGTERM']) {
|
|
795
|
+
const listener = () => finish(new Error(`lark-channel setup: App Secret input interrupted by ${signal}`));
|
|
796
|
+
signalHandlers.set(signal, listener);
|
|
797
|
+
signals.once(signal, listener);
|
|
798
|
+
}
|
|
799
|
+
try {
|
|
800
|
+
input.setRawMode(true);
|
|
801
|
+
input.on('data', onData);
|
|
802
|
+
input.resume();
|
|
803
|
+
}
|
|
804
|
+
catch (error) {
|
|
805
|
+
finish(new Error('lark-channel setup: could not enter secure terminal input mode', { cause: error }));
|
|
806
|
+
}
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
async function readLinuxManualSecret() {
|
|
810
|
+
if (defaultLinuxSecretToolAvailable('/usr/bin/systemd-ask-password')) {
|
|
811
|
+
const prompt = spawnSync('/usr/bin/systemd-ask-password', ['飞书 App Secret:'], {
|
|
812
|
+
encoding: 'utf8', env: linuxCredentialEnv(), maxBuffer: 128 * 1024, stdio: ['inherit', 'pipe', 'inherit'],
|
|
813
|
+
});
|
|
814
|
+
const secret = typeof prompt.stdout === 'string' ? prompt.stdout.replace(/[\r\n]+$/u, '') : '';
|
|
815
|
+
if (prompt.status !== 0 || secret.length === 0) {
|
|
816
|
+
throw new Error('lark-channel setup: Linux could not read the App Secret securely');
|
|
817
|
+
}
|
|
818
|
+
return secret;
|
|
819
|
+
}
|
|
820
|
+
return readSecretFromRawTerminal('\n飞书 App Secret(输入不会回显):');
|
|
294
821
|
}
|
|
295
822
|
function executeSecretWrite(request, env, failure) {
|
|
296
823
|
try {
|
|
297
824
|
const result = spawnSync(request.executable, request.args, {
|
|
298
825
|
encoding: 'utf8',
|
|
299
826
|
env,
|
|
827
|
+
...(request.executable === linuxSecretTool ? { timeout: linuxSecretServiceTimeoutMs } : {}),
|
|
300
828
|
input: request.input,
|
|
301
829
|
maxBuffer: 128 * 1024,
|
|
302
830
|
stdio: ['pipe', 'ignore', 'pipe'],
|
|
303
831
|
});
|
|
304
|
-
if (result.status !== 0)
|
|
305
|
-
throw new Error(failure);
|
|
832
|
+
if (result.status !== 0) {
|
|
833
|
+
throw typeof failure === 'string' ? new Error(failure) : failure(result);
|
|
834
|
+
}
|
|
306
835
|
}
|
|
307
836
|
finally {
|
|
308
837
|
request.input.fill(0);
|
|
309
838
|
}
|
|
310
839
|
}
|
|
311
|
-
function storeSecret(provider, service, account, credentialPath) {
|
|
840
|
+
async function storeSecret(provider, service, account, credentialPath) {
|
|
312
841
|
if (provider === 'macos-keychain') {
|
|
313
842
|
process.stdout.write('\n请在 macOS Keychain 的安全提示中输入 App Secret(输入不会回显):\n');
|
|
314
843
|
runSecurity(['add-generic-password', '-U', '-a', account, '-s', service, '-w'], 'inherit');
|
|
315
844
|
return;
|
|
316
845
|
}
|
|
317
|
-
if (provider === 'linux-secret-service') {
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
846
|
+
if (provider === 'linux-secret-service' || provider === 'linux-protected-file') {
|
|
847
|
+
const secret = await readLinuxManualSecret();
|
|
848
|
+
if (provider === 'linux-protected-file') {
|
|
849
|
+
if (credentialPath === undefined)
|
|
850
|
+
throw new Error('lark-channel setup: missing Linux protected credential path');
|
|
851
|
+
await storeLinuxProtectedCredential(credentialPath, secret);
|
|
852
|
+
}
|
|
853
|
+
else {
|
|
854
|
+
const environment = linuxCredentialEnv();
|
|
855
|
+
executeSecretWrite(createSecretServiceWriteRequest(service, account, secret), environment, result => linuxSecretServiceDiagnostic('credential store', result, environment));
|
|
324
856
|
}
|
|
325
|
-
executeSecretWrite(createSecretServiceWriteRequest(service, account, secret), linuxCredentialEnv(), 'lark-channel setup: Linux Secret Service operation failed');
|
|
326
857
|
return;
|
|
327
858
|
}
|
|
328
859
|
if (credentialPath === undefined || (!isAbsolute(credentialPath) && !win32.isAbsolute(credentialPath))) {
|
|
@@ -335,7 +866,7 @@ function storeSecret(provider, service, account, credentialPath) {
|
|
|
335
866
|
if (result.status !== 0)
|
|
336
867
|
throw new Error('lark-channel setup: Windows DPAPI operation failed');
|
|
337
868
|
}
|
|
338
|
-
function storeGeneratedSecret(provider, service, account, secret, credentialPath) {
|
|
869
|
+
async function storeGeneratedSecret(provider, service, account, secret, credentialPath) {
|
|
339
870
|
if (provider === 'macos-keychain') {
|
|
340
871
|
const request = createKeychainWriteRequest(service, account, secret);
|
|
341
872
|
try {
|
|
@@ -352,7 +883,14 @@ function storeGeneratedSecret(provider, service, account, secret, credentialPath
|
|
|
352
883
|
return;
|
|
353
884
|
}
|
|
354
885
|
if (provider === 'linux-secret-service') {
|
|
355
|
-
|
|
886
|
+
const environment = linuxCredentialEnv();
|
|
887
|
+
executeSecretWrite(createSecretServiceWriteRequest(service, account, secret), environment, result => linuxSecretServiceDiagnostic('credential store', result, environment));
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
if (provider === 'linux-protected-file') {
|
|
891
|
+
if (credentialPath === undefined)
|
|
892
|
+
throw new Error('lark-channel setup: missing Linux protected credential path');
|
|
893
|
+
await storeLinuxProtectedCredential(credentialPath, secret);
|
|
356
894
|
return;
|
|
357
895
|
}
|
|
358
896
|
if (credentialPath === undefined)
|
|
@@ -361,19 +899,26 @@ function storeGeneratedSecret(provider, service, account, secret, credentialPath
|
|
|
361
899
|
PATH: 'C:\\Windows\\System32;C:\\Windows', SystemRoot: 'C:\\Windows',
|
|
362
900
|
}, 'lark-channel setup: Windows DPAPI operation failed');
|
|
363
901
|
}
|
|
364
|
-
function readSecret(provider, service, account, credentialPath) {
|
|
902
|
+
async function readSecret(provider, service, account, credentialPath) {
|
|
365
903
|
let value = '';
|
|
366
904
|
if (provider === 'macos-keychain') {
|
|
367
905
|
value = runSecurity(['find-generic-password', '-w', '-a', account, '-s', service], 'pipe');
|
|
368
906
|
}
|
|
369
907
|
else if (provider === 'linux-secret-service') {
|
|
370
|
-
const
|
|
371
|
-
|
|
908
|
+
const environment = linuxCredentialEnv();
|
|
909
|
+
const result = spawnSync(linuxSecretTool, ['lookup', 'service', service, 'account', account], {
|
|
910
|
+
encoding: 'utf8', env: environment, timeout: linuxSecretServiceTimeoutMs,
|
|
911
|
+
maxBuffer: 128 * 1024, stdio: ['ignore', 'pipe', 'pipe'],
|
|
372
912
|
});
|
|
373
913
|
if (result.status !== 0)
|
|
374
|
-
throw
|
|
914
|
+
throw linuxSecretServiceDiagnostic('credential lookup', result, environment);
|
|
375
915
|
value = typeof result.stdout === 'string' ? result.stdout.trimEnd() : '';
|
|
376
916
|
}
|
|
917
|
+
else if (provider === 'linux-protected-file') {
|
|
918
|
+
if (credentialPath === undefined)
|
|
919
|
+
throw new Error('lark-channel setup: missing Linux protected credential path');
|
|
920
|
+
value = await readLinuxProtectedCredential(credentialPath);
|
|
921
|
+
}
|
|
377
922
|
else {
|
|
378
923
|
if (credentialPath === undefined)
|
|
379
924
|
throw new Error('lark-channel setup: missing DPAPI credential path');
|
|
@@ -412,7 +957,7 @@ function exactYamlStringSequence(node, expected) {
|
|
|
412
957
|
return node.items.every((item, index) => item?.toJSON() === expected[index]);
|
|
413
958
|
}
|
|
414
959
|
function exactManagedCredentialHandleShape(handle, provider) {
|
|
415
|
-
const expectedKeys = provider === 'windows-dpapi'
|
|
960
|
+
const expectedKeys = provider === 'windows-dpapi' || provider === 'linux-protected-file'
|
|
416
961
|
? ['consumers', 'id', 'maxLeaseMs', 'path', 'provider', 'purposes']
|
|
417
962
|
: ['account', 'consumers', 'id', 'maxLeaseMs', 'provider', 'purposes', 'service'];
|
|
418
963
|
const value = handle.toJSON();
|
|
@@ -424,8 +969,9 @@ function exactManagedCredentialHandleShape(handle, provider) {
|
|
|
424
969
|
function credentialHandleAliasesLocator(handle, locator) {
|
|
425
970
|
if (!isMap(handle) || handle.get('provider') !== locator.provider)
|
|
426
971
|
return false;
|
|
427
|
-
if (locator.provider === 'windows-dpapi')
|
|
972
|
+
if (locator.provider === 'windows-dpapi' || locator.provider === 'linux-protected-file') {
|
|
428
973
|
return handle.get('path') === locator.path;
|
|
974
|
+
}
|
|
429
975
|
return handle.get('service') === locator.service && handle.get('account') === locator.account;
|
|
430
976
|
}
|
|
431
977
|
/**
|
|
@@ -460,7 +1006,10 @@ export function findManagedLarkCredentialLocator(input) {
|
|
|
460
1006
|
if (!isMap(handle))
|
|
461
1007
|
return undefined;
|
|
462
1008
|
const provider = handle.get('provider');
|
|
463
|
-
if ((provider !== 'macos-keychain'
|
|
1009
|
+
if ((provider !== 'macos-keychain'
|
|
1010
|
+
&& provider !== 'linux-protected-file'
|
|
1011
|
+
&& provider !== 'linux-secret-service'
|
|
1012
|
+
&& provider !== 'windows-dpapi')
|
|
464
1013
|
|| !exactManagedCredentialHandleShape(handle, provider))
|
|
465
1014
|
return undefined;
|
|
466
1015
|
if (provider === 'macos-keychain' || provider === 'linux-secret-service') {
|
|
@@ -480,17 +1029,19 @@ export function findManagedLarkCredentialLocator(input) {
|
|
|
480
1029
|
return undefined;
|
|
481
1030
|
return locator;
|
|
482
1031
|
}
|
|
483
|
-
if (provider === 'windows-dpapi') {
|
|
1032
|
+
if (provider === 'windows-dpapi' || provider === 'linux-protected-file') {
|
|
484
1033
|
const path = handle.get('path');
|
|
485
1034
|
if (typeof path !== 'string')
|
|
486
1035
|
return undefined;
|
|
487
1036
|
const directory = platformJoin(input.dshHome, 'credentials-keychain');
|
|
488
|
-
const
|
|
1037
|
+
const extension = provider === 'windows-dpapi' ? '.clixml' : '.secret';
|
|
1038
|
+
const legacyPath = platformJoin(directory, `lark-${input.profile}-${account}${extension}`);
|
|
489
1039
|
const versionPrefix = platformJoin(directory, `lark-${input.profile}-${account}-`);
|
|
490
|
-
const version = path.startsWith(versionPrefix) && path.endsWith(
|
|
491
|
-
? path.slice(versionPrefix.length, -
|
|
1040
|
+
const version = path.startsWith(versionPrefix) && path.endsWith(extension)
|
|
1041
|
+
? path.slice(versionPrefix.length, -extension.length)
|
|
492
1042
|
: undefined;
|
|
493
|
-
|
|
1043
|
+
const acceptsLegacy = provider === 'windows-dpapi' && path === legacyPath;
|
|
1044
|
+
if (!acceptsLegacy && (version === undefined || !credentialVersionPattern.test(version)))
|
|
494
1045
|
return undefined;
|
|
495
1046
|
const locator = { provider, path };
|
|
496
1047
|
if (handles.items.some(item => item !== handle && credentialHandleAliasesLocator(item, locator)))
|
|
@@ -500,33 +1051,43 @@ export function findManagedLarkCredentialLocator(input) {
|
|
|
500
1051
|
return undefined;
|
|
501
1052
|
}
|
|
502
1053
|
async function removeCredential(locator) {
|
|
503
|
-
if (locator.provider === 'windows-dpapi') {
|
|
1054
|
+
if (locator.provider === 'windows-dpapi' || locator.provider === 'linux-protected-file') {
|
|
504
1055
|
try {
|
|
505
1056
|
await unlink(locator.path);
|
|
1057
|
+
if (locator.provider === 'linux-protected-file')
|
|
1058
|
+
await syncDirectory(dirname(locator.path));
|
|
506
1059
|
}
|
|
507
1060
|
catch (error) {
|
|
508
1061
|
if (fileSystemErrorCode(error) !== 'ENOENT') {
|
|
509
|
-
|
|
1062
|
+
const backend = locator.provider === 'windows-dpapi' ? 'Windows DPAPI' : 'Linux protected file';
|
|
1063
|
+
throw new Error(`lark-channel setup: ${backend} credential cleanup failed`, { cause: error });
|
|
510
1064
|
}
|
|
511
1065
|
}
|
|
512
1066
|
return;
|
|
513
1067
|
}
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
], {
|
|
518
|
-
encoding: 'utf8', env: { PATH: '/usr/bin:/bin' }, maxBuffer: 128 * 1024, stdio: ['ignore', 'ignore', 'pipe'],
|
|
519
|
-
})
|
|
520
|
-
: spawnSync('/usr/bin/secret-tool', [
|
|
1068
|
+
if (locator.provider === 'linux-secret-service') {
|
|
1069
|
+
const environment = linuxCredentialEnv();
|
|
1070
|
+
const result = spawnSync(linuxSecretTool, [
|
|
521
1071
|
'clear', 'service', locator.service, 'account', locator.account,
|
|
522
1072
|
], {
|
|
523
|
-
encoding: 'utf8', env:
|
|
1073
|
+
encoding: 'utf8', env: environment, timeout: linuxSecretServiceTimeoutMs,
|
|
1074
|
+
maxBuffer: 128 * 1024, stdio: ['ignore', 'ignore', 'pipe'],
|
|
524
1075
|
});
|
|
525
|
-
|
|
1076
|
+
if (result.status !== 0 && !isLinuxSecretServiceClearAbsent(result)) {
|
|
1077
|
+
throw linuxSecretServiceDiagnostic('credential cleanup', result, environment);
|
|
1078
|
+
}
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
const result = spawnSync('/usr/bin/security', [
|
|
1082
|
+
'delete-generic-password', '-a', locator.account, '-s', locator.service,
|
|
1083
|
+
], {
|
|
1084
|
+
encoding: 'utf8', env: { PATH: '/usr/bin:/bin' }, maxBuffer: 128 * 1024, stdio: ['ignore', 'ignore', 'pipe'],
|
|
1085
|
+
});
|
|
1086
|
+
const missingMacosItem = result.status === 44;
|
|
526
1087
|
const missingItemMessage = typeof result.stderr === 'string'
|
|
527
1088
|
&& /(?:could not be found|not found|no matching|no such)/iu.test(result.stderr);
|
|
528
1089
|
if (result.status !== 0 && !missingMacosItem && !missingItemMessage) {
|
|
529
|
-
throw new Error(
|
|
1090
|
+
throw new Error('lark-channel setup: macos-keychain credential cleanup failed');
|
|
530
1091
|
}
|
|
531
1092
|
}
|
|
532
1093
|
function credentialIsAlreadyAbsent(error) {
|
|
@@ -542,14 +1103,27 @@ async function removeCredentialIdempotently(locator, operation) {
|
|
|
542
1103
|
throw error;
|
|
543
1104
|
}
|
|
544
1105
|
}
|
|
545
|
-
async function
|
|
1106
|
+
async function retirePreviousCredential(locator, operation) {
|
|
546
1107
|
try {
|
|
547
1108
|
await removeCredentialIdempotently(locator, operation);
|
|
548
1109
|
}
|
|
1110
|
+
catch (error) {
|
|
1111
|
+
// The profile and Delivery owner already reference the staged credential,
|
|
1112
|
+
// so rollback is unsafe. Callers retain their paired journal and retry the
|
|
1113
|
+
// exact old locator before allowing another credential rotation.
|
|
1114
|
+
throw new AggregateError([error], 'lark-channel setup: profile and owner were committed, but previous credential cleanup is pending; repair the credential provider and rerun setup');
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
/**
|
|
1118
|
+
* Compatibility behavior for the legacy non-journal commit helper below.
|
|
1119
|
+
* Full onboarding uses retirePreviousCredential directly so it can retain its
|
|
1120
|
+
* paired journal and retry the exact locator on the next invocation.
|
|
1121
|
+
*/
|
|
1122
|
+
async function retirePreviousCredentialBestEffort(locator, operation) {
|
|
1123
|
+
try {
|
|
1124
|
+
await retirePreviousCredential(locator, operation);
|
|
1125
|
+
}
|
|
549
1126
|
catch {
|
|
550
|
-
// The profile and Delivery owner already reference the staged credential.
|
|
551
|
-
// Retaining an inactive old secret is safer than blocking all future setup
|
|
552
|
-
// recovery or falsely reporting that the committed rotation failed.
|
|
553
1127
|
process.stderr.write('lark-channel setup: previous credential cleanup failed after commit\n');
|
|
554
1128
|
}
|
|
555
1129
|
}
|
|
@@ -728,9 +1302,12 @@ export async function withProfileSetupLock(patchPath, operation, options = {}) {
|
|
|
728
1302
|
}
|
|
729
1303
|
async function atomicWrite(path, value) {
|
|
730
1304
|
const temporary = join(dirname(path), `.cordis.patch.yml.lark-setup-${process.pid}-${randomBytes(8).toString('hex')}`);
|
|
1305
|
+
let created = false;
|
|
731
1306
|
try {
|
|
732
|
-
const file = await open(temporary, '
|
|
1307
|
+
const file = await open(temporary, 'wx', 0o600);
|
|
1308
|
+
created = true;
|
|
733
1309
|
try {
|
|
1310
|
+
await file.chmod(0o600);
|
|
734
1311
|
await file.writeFile(value, 'utf8');
|
|
735
1312
|
await file.sync();
|
|
736
1313
|
}
|
|
@@ -741,7 +1318,8 @@ async function atomicWrite(path, value) {
|
|
|
741
1318
|
await syncDirectory(dirname(path));
|
|
742
1319
|
}
|
|
743
1320
|
catch (error) {
|
|
744
|
-
|
|
1321
|
+
if (created)
|
|
1322
|
+
await rm(temporary, { force: true });
|
|
745
1323
|
throw error;
|
|
746
1324
|
}
|
|
747
1325
|
}
|
|
@@ -762,6 +1340,9 @@ async function syncDirectory(path) {
|
|
|
762
1340
|
function setupJournalPath(patchPath) {
|
|
763
1341
|
return `${patchPath}.lark-setup.journal.json`;
|
|
764
1342
|
}
|
|
1343
|
+
function deferredCredentialCleanupPath(patchPath) {
|
|
1344
|
+
return `${patchPath}.lark-credential-cleanup.json`;
|
|
1345
|
+
}
|
|
765
1346
|
function textSha256(value) {
|
|
766
1347
|
return createHash('sha256').update(value).digest('hex');
|
|
767
1348
|
}
|
|
@@ -771,9 +1352,14 @@ function escapeRegularExpression(value) {
|
|
|
771
1352
|
function credentialLocatorEquals(left, right) {
|
|
772
1353
|
if (left.provider !== right.provider)
|
|
773
1354
|
return false;
|
|
774
|
-
if (left.provider === 'windows-dpapi' && right.provider === 'windows-dpapi')
|
|
1355
|
+
if ((left.provider === 'windows-dpapi' && right.provider === 'windows-dpapi')
|
|
1356
|
+
|| (left.provider === 'linux-protected-file' && right.provider === 'linux-protected-file')) {
|
|
775
1357
|
return left.path === right.path;
|
|
776
|
-
|
|
1358
|
+
}
|
|
1359
|
+
if (left.provider === 'windows-dpapi'
|
|
1360
|
+
|| left.provider === 'linux-protected-file'
|
|
1361
|
+
|| right.provider === 'windows-dpapi'
|
|
1362
|
+
|| right.provider === 'linux-protected-file')
|
|
777
1363
|
return false;
|
|
778
1364
|
return left.service === right.service && left.account === right.account;
|
|
779
1365
|
}
|
|
@@ -801,28 +1387,202 @@ function validateJournalCredentialLocator(input) {
|
|
|
801
1387
|
}
|
|
802
1388
|
return { provider: locator.provider, service: locator.service, account: locator.account };
|
|
803
1389
|
}
|
|
804
|
-
if (locator.provider === 'windows-dpapi') {
|
|
1390
|
+
if (locator.provider === 'windows-dpapi' || locator.provider === 'linux-protected-file') {
|
|
805
1391
|
if (typeof locator.path !== 'string') {
|
|
806
|
-
throw new Error('lark-channel setup: setup journal
|
|
1392
|
+
throw new Error('lark-channel setup: setup journal file credential locator is invalid');
|
|
807
1393
|
}
|
|
808
1394
|
const directory = platformJoin(input.dshHome, 'credentials-keychain');
|
|
809
1395
|
if (dirname(locator.path) !== directory) {
|
|
810
|
-
throw new Error('lark-channel setup: setup journal
|
|
1396
|
+
throw new Error('lark-channel setup: setup journal file credential locator is outside the credential directory');
|
|
811
1397
|
}
|
|
812
1398
|
const name = locator.path.slice(directory.length + 1);
|
|
813
1399
|
const profile = escapeRegularExpression(input.profile);
|
|
814
1400
|
const account = input.stagedAccount === undefined
|
|
815
1401
|
? '[A-Za-z0-9][A-Za-z0-9._-]{0,63}'
|
|
816
1402
|
: escapeRegularExpression(input.stagedAccount);
|
|
817
|
-
const
|
|
1403
|
+
const extension = locator.provider === 'windows-dpapi' ? 'clixml' : 'secret';
|
|
1404
|
+
const versioned = new RegExp(`^lark-${profile}-${account}-[0-9a-f]{32}\\.${extension}$`, 'u');
|
|
818
1405
|
const legacy = new RegExp(`^lark-${profile}-${account}\\.clixml$`, 'u');
|
|
819
|
-
|
|
820
|
-
|
|
1406
|
+
const acceptsLegacy = locator.provider === 'windows-dpapi'
|
|
1407
|
+
&& input.stagedAccount === undefined
|
|
1408
|
+
&& legacy.test(name);
|
|
1409
|
+
if (!versioned.test(name) && !acceptsLegacy) {
|
|
1410
|
+
throw new Error('lark-channel setup: setup journal file credential locator is not setup-managed');
|
|
821
1411
|
}
|
|
822
1412
|
return { provider: locator.provider, path: locator.path };
|
|
823
1413
|
}
|
|
824
1414
|
throw new Error('lark-channel setup: setup journal credential provider is invalid');
|
|
825
1415
|
}
|
|
1416
|
+
function profileReferencesCredentialLocator(profilePatch, locator) {
|
|
1417
|
+
const document = parseDocument(profilePatch, { uniqueKeys: true });
|
|
1418
|
+
if (document.errors.length > 0 || !isSeq(document.contents)) {
|
|
1419
|
+
throw new Error('lark-channel setup: cannot prove a deferred credential is inactive in an invalid profile');
|
|
1420
|
+
}
|
|
1421
|
+
const rows = document.contents.items.filter(item => isMap(item)
|
|
1422
|
+
&& item.get('id') === 'dsh-enhanced-credentials-keychain');
|
|
1423
|
+
for (const row of rows) {
|
|
1424
|
+
const config = row.get('config', true);
|
|
1425
|
+
if (!isMap(config)) {
|
|
1426
|
+
throw new Error('lark-channel setup: cannot prove a deferred credential is inactive in malformed credentials config');
|
|
1427
|
+
}
|
|
1428
|
+
const handles = config.get('handles', true);
|
|
1429
|
+
if (handles === undefined)
|
|
1430
|
+
continue;
|
|
1431
|
+
if (!isSeq(handles)) {
|
|
1432
|
+
throw new Error('lark-channel setup: cannot prove a deferred credential is inactive in malformed handles config');
|
|
1433
|
+
}
|
|
1434
|
+
if (handles.items.some(handle => credentialHandleAliasesLocator(handle, locator)))
|
|
1435
|
+
return true;
|
|
1436
|
+
}
|
|
1437
|
+
return false;
|
|
1438
|
+
}
|
|
1439
|
+
function deferredCleanupChecksum(locators) {
|
|
1440
|
+
return textSha256(JSON.stringify(locators));
|
|
1441
|
+
}
|
|
1442
|
+
async function readDeferredCredentialCleanup(input) {
|
|
1443
|
+
const path = deferredCredentialCleanupPath(input.patchPath);
|
|
1444
|
+
let file;
|
|
1445
|
+
let buffer;
|
|
1446
|
+
let text;
|
|
1447
|
+
try {
|
|
1448
|
+
file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
1449
|
+
const before = await file.stat();
|
|
1450
|
+
const uid = currentLinuxCredentialUid();
|
|
1451
|
+
if (!before.isFile() || before.nlink !== 1 || before.uid !== uid
|
|
1452
|
+
|| (before.mode & 0o7777) !== 0o600 || before.size < 1 || before.size > 1024 * 1024) {
|
|
1453
|
+
throw new Error('lark-channel setup: deferred credential cleanup record failed its security check');
|
|
1454
|
+
}
|
|
1455
|
+
buffer = Buffer.alloc(1024 * 1024 + 1);
|
|
1456
|
+
let bytesRead = 0;
|
|
1457
|
+
while (bytesRead < buffer.byteLength) {
|
|
1458
|
+
const result = await file.read(buffer, bytesRead, buffer.byteLength - bytesRead, bytesRead);
|
|
1459
|
+
if (result.bytesRead === 0)
|
|
1460
|
+
break;
|
|
1461
|
+
bytesRead += result.bytesRead;
|
|
1462
|
+
}
|
|
1463
|
+
const after = await file.stat();
|
|
1464
|
+
if (bytesRead !== after.size || after.dev !== before.dev || after.ino !== before.ino
|
|
1465
|
+
|| !after.isFile() || after.nlink !== 1 || after.uid !== uid || (after.mode & 0o7777) !== 0o600) {
|
|
1466
|
+
throw new Error('lark-channel setup: deferred credential cleanup record changed while being read');
|
|
1467
|
+
}
|
|
1468
|
+
text = buffer.toString('utf8', 0, bytesRead);
|
|
1469
|
+
}
|
|
1470
|
+
catch (error) {
|
|
1471
|
+
if (fileSystemErrorCode(error) === 'ENOENT')
|
|
1472
|
+
return undefined;
|
|
1473
|
+
throw error;
|
|
1474
|
+
}
|
|
1475
|
+
finally {
|
|
1476
|
+
buffer?.fill(0);
|
|
1477
|
+
await file?.close();
|
|
1478
|
+
}
|
|
1479
|
+
let value;
|
|
1480
|
+
try {
|
|
1481
|
+
value = JSON.parse(text);
|
|
1482
|
+
}
|
|
1483
|
+
catch (error) {
|
|
1484
|
+
throw new Error('lark-channel setup: deferred credential cleanup record is not valid JSON', { cause: error });
|
|
1485
|
+
}
|
|
1486
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
1487
|
+
throw new Error('lark-channel setup: deferred credential cleanup record is invalid');
|
|
1488
|
+
}
|
|
1489
|
+
const record = value;
|
|
1490
|
+
const keys = Object.keys(record).sort();
|
|
1491
|
+
if (keys.join('\0') !== ['dshHome', 'locators', 'patchPath', 'profile', 'sha256', 'version'].join('\0')
|
|
1492
|
+
|| record.version !== 1
|
|
1493
|
+
|| record.patchPath !== input.patchPath
|
|
1494
|
+
|| record.dshHome !== input.dshHome
|
|
1495
|
+
|| record.profile !== input.profile
|
|
1496
|
+
|| !Array.isArray(record.locators)
|
|
1497
|
+
|| record.locators.length < 1
|
|
1498
|
+
|| record.locators.length > 256) {
|
|
1499
|
+
throw new Error('lark-channel setup: deferred credential cleanup record identity is invalid');
|
|
1500
|
+
}
|
|
1501
|
+
const locators = record.locators.map(locator => validateJournalCredentialLocator({
|
|
1502
|
+
locator,
|
|
1503
|
+
dshHome: input.dshHome,
|
|
1504
|
+
profile: input.profile,
|
|
1505
|
+
}));
|
|
1506
|
+
if (record.sha256 !== deferredCleanupChecksum(locators)
|
|
1507
|
+
|| new Set(locators.map(locator => JSON.stringify(locator))).size !== locators.length) {
|
|
1508
|
+
throw new Error('lark-channel setup: deferred credential cleanup record checksum or entries are invalid');
|
|
1509
|
+
}
|
|
1510
|
+
return { version: 1, ...input, locators, sha256: record.sha256 };
|
|
1511
|
+
}
|
|
1512
|
+
async function persistDeferredCredentialCleanup(input) {
|
|
1513
|
+
const identifiers = new Set();
|
|
1514
|
+
const locators = input.locators.map(locator => validateJournalCredentialLocator({
|
|
1515
|
+
locator,
|
|
1516
|
+
dshHome: input.dshHome,
|
|
1517
|
+
profile: input.profile,
|
|
1518
|
+
})).filter(locator => {
|
|
1519
|
+
const identifier = JSON.stringify(locator);
|
|
1520
|
+
if (identifiers.has(identifier))
|
|
1521
|
+
return false;
|
|
1522
|
+
identifiers.add(identifier);
|
|
1523
|
+
return true;
|
|
1524
|
+
});
|
|
1525
|
+
const path = deferredCredentialCleanupPath(input.patchPath);
|
|
1526
|
+
if (locators.length === 0) {
|
|
1527
|
+
try {
|
|
1528
|
+
await unlink(path);
|
|
1529
|
+
await syncDirectory(dirname(path));
|
|
1530
|
+
}
|
|
1531
|
+
catch (error) {
|
|
1532
|
+
if (fileSystemErrorCode(error) !== 'ENOENT')
|
|
1533
|
+
throw error;
|
|
1534
|
+
}
|
|
1535
|
+
return;
|
|
1536
|
+
}
|
|
1537
|
+
if (locators.length > 256)
|
|
1538
|
+
throw new Error('lark-channel setup: too many deferred credential cleanup entries');
|
|
1539
|
+
const record = {
|
|
1540
|
+
version: 1,
|
|
1541
|
+
patchPath: input.patchPath,
|
|
1542
|
+
dshHome: input.dshHome,
|
|
1543
|
+
profile: input.profile,
|
|
1544
|
+
locators,
|
|
1545
|
+
sha256: deferredCleanupChecksum(locators),
|
|
1546
|
+
};
|
|
1547
|
+
await atomicWrite(path, `${JSON.stringify(record)}\n`);
|
|
1548
|
+
}
|
|
1549
|
+
function canDeferSecretServiceCleanup(locator, error) {
|
|
1550
|
+
return locator.provider === 'linux-secret-service' && safeDiagnosticMessages(error).length > 0;
|
|
1551
|
+
}
|
|
1552
|
+
async function deferInactiveCredentialCleanup(input) {
|
|
1553
|
+
if (profileReferencesCredentialLocator(input.profilePatch, input.locator)) {
|
|
1554
|
+
throw new Error('lark-channel setup: refusing to defer cleanup for a credential still referenced by the profile');
|
|
1555
|
+
}
|
|
1556
|
+
const current = await readDeferredCredentialCleanup(input);
|
|
1557
|
+
await persistDeferredCredentialCleanup({
|
|
1558
|
+
patchPath: input.patchPath,
|
|
1559
|
+
dshHome: input.dshHome,
|
|
1560
|
+
profile: input.profile,
|
|
1561
|
+
locators: [...(current?.locators ?? []), input.locator],
|
|
1562
|
+
});
|
|
1563
|
+
}
|
|
1564
|
+
/** Best-effort retry for already proven-inactive credentials. Call while holding the profile lock. */
|
|
1565
|
+
export async function retryDeferredLarkCredentialCleanup(input) {
|
|
1566
|
+
const pending = await readDeferredCredentialCleanup(input);
|
|
1567
|
+
if (pending === undefined)
|
|
1568
|
+
return 0;
|
|
1569
|
+
const profilePatch = await readFile(input.patchPath, 'utf8');
|
|
1570
|
+
const remove = input.removeCredential ?? removeCredential;
|
|
1571
|
+
const remaining = [];
|
|
1572
|
+
for (const locator of pending.locators) {
|
|
1573
|
+
if (profileReferencesCredentialLocator(profilePatch, locator)) {
|
|
1574
|
+
throw new Error('lark-channel setup: a deferred credential cleanup entry became active; refusing cleanup');
|
|
1575
|
+
}
|
|
1576
|
+
try {
|
|
1577
|
+
await removeCredentialIdempotently(locator, remove);
|
|
1578
|
+
}
|
|
1579
|
+
catch {
|
|
1580
|
+
remaining.push(locator);
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
await persistDeferredCredentialCleanup({ ...input, locators: remaining });
|
|
1584
|
+
return remaining.length;
|
|
1585
|
+
}
|
|
826
1586
|
function normalizeSetupJournalInput(input) {
|
|
827
1587
|
if ((!isAbsolute(input.patchPath) && !win32.isAbsolute(input.patchPath))
|
|
828
1588
|
|| (!isAbsolute(input.dshHome) && !win32.isAbsolute(input.dshHome))
|
|
@@ -1406,6 +2166,23 @@ async function withDeliveryOwnerSetupLock(databasePath, operation, options = {})
|
|
|
1406
2166
|
await mkdir(dirname(resourcePath), { recursive: true, mode: 0o700 });
|
|
1407
2167
|
return withProfileSetupLock(`${resourcePath}.lark-owner`, operation, options);
|
|
1408
2168
|
}
|
|
2169
|
+
async function removeInactiveCredentialForRecovery(input) {
|
|
2170
|
+
try {
|
|
2171
|
+
await removeCredentialIdempotently(input.locator, input.remove);
|
|
2172
|
+
}
|
|
2173
|
+
catch (error) {
|
|
2174
|
+
if (!input.recovery.allowDeferredSecretServiceCleanup
|
|
2175
|
+
|| !canDeferSecretServiceCleanup(input.locator, error))
|
|
2176
|
+
throw error;
|
|
2177
|
+
await deferInactiveCredentialCleanup({
|
|
2178
|
+
patchPath: input.recovery.patchPath,
|
|
2179
|
+
dshHome: input.recovery.dshHome,
|
|
2180
|
+
profile: input.recovery.profile,
|
|
2181
|
+
profilePatch: input.profilePatch,
|
|
2182
|
+
locator: input.locator,
|
|
2183
|
+
});
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
1409
2186
|
async function recoverLarkSetupJournalRecordUnlocked(input, journal) {
|
|
1410
2187
|
const remove = input.operations?.removeCredential ?? removeCredential;
|
|
1411
2188
|
const pair = input.operations?.pairPrincipal ?? defaultPairPrincipal;
|
|
@@ -1416,7 +2193,12 @@ async function recoverLarkSetupJournalRecordUnlocked(input, journal) {
|
|
|
1416
2193
|
if (current !== journal.originalPatch) {
|
|
1417
2194
|
throw new Error('lark-channel setup: staging journal conflicts with the live profile');
|
|
1418
2195
|
}
|
|
1419
|
-
await
|
|
2196
|
+
await removeInactiveCredentialForRecovery({
|
|
2197
|
+
recovery: input,
|
|
2198
|
+
locator: journal.stagedCredential,
|
|
2199
|
+
profilePatch: current,
|
|
2200
|
+
remove,
|
|
2201
|
+
});
|
|
1420
2202
|
await clearLarkSetupJournal(input.patchPath);
|
|
1421
2203
|
return true;
|
|
1422
2204
|
}
|
|
@@ -1427,7 +2209,12 @@ async function recoverLarkSetupJournalRecordUnlocked(input, journal) {
|
|
|
1427
2209
|
throw new Error('lark-channel setup: candidate recovery refused because the live profile diverged');
|
|
1428
2210
|
}
|
|
1429
2211
|
if (journal.stagedCredential !== undefined) {
|
|
1430
|
-
await
|
|
2212
|
+
await removeInactiveCredentialForRecovery({
|
|
2213
|
+
recovery: input,
|
|
2214
|
+
locator: journal.stagedCredential,
|
|
2215
|
+
profilePatch: journal.originalPatch,
|
|
2216
|
+
remove,
|
|
2217
|
+
});
|
|
1431
2218
|
}
|
|
1432
2219
|
await clearLarkSetupJournal(input.patchPath);
|
|
1433
2220
|
return true;
|
|
@@ -1446,7 +2233,22 @@ async function recoverLarkSetupJournalRecordUnlocked(input, journal) {
|
|
|
1446
2233
|
await persistLarkSetupJournal(journalPersistInput(journal, 'paired'));
|
|
1447
2234
|
if (journal.previousCredential !== undefined
|
|
1448
2235
|
&& !credentialLocatorEquals(journal.previousCredential, journal.stagedCredential)) {
|
|
1449
|
-
|
|
2236
|
+
try {
|
|
2237
|
+
await retirePreviousCredential(journal.previousCredential, remove);
|
|
2238
|
+
}
|
|
2239
|
+
catch (error) {
|
|
2240
|
+
const migratingToProtectedFile = journal.stagedCredential?.provider === 'linux-protected-file';
|
|
2241
|
+
if ((!input.allowDeferredSecretServiceCleanup && !migratingToProtectedFile)
|
|
2242
|
+
|| !canDeferSecretServiceCleanup(journal.previousCredential, error))
|
|
2243
|
+
throw error;
|
|
2244
|
+
await deferInactiveCredentialCleanup({
|
|
2245
|
+
patchPath: input.patchPath,
|
|
2246
|
+
dshHome: input.dshHome,
|
|
2247
|
+
profile: input.profile,
|
|
2248
|
+
profilePatch: journal.updatedPatch,
|
|
2249
|
+
locator: journal.previousCredential,
|
|
2250
|
+
});
|
|
2251
|
+
}
|
|
1450
2252
|
}
|
|
1451
2253
|
if (journal.installService)
|
|
1452
2254
|
await install();
|
|
@@ -1493,6 +2295,14 @@ async function recoverValidatedRefreshJournalUnlocked(input, journal) {
|
|
|
1493
2295
|
return true;
|
|
1494
2296
|
}
|
|
1495
2297
|
async function recoverLarkSetupJournalUnlocked(input) {
|
|
2298
|
+
await retryDeferredLarkCredentialCleanup({
|
|
2299
|
+
patchPath: input.patchPath,
|
|
2300
|
+
dshHome: input.dshHome,
|
|
2301
|
+
profile: input.profile,
|
|
2302
|
+
...(input.operations?.removeCredential === undefined
|
|
2303
|
+
? {}
|
|
2304
|
+
: { removeCredential: input.operations.removeCredential }),
|
|
2305
|
+
});
|
|
1496
2306
|
const journal = await readLarkSetupJournal(input);
|
|
1497
2307
|
if (journal === undefined)
|
|
1498
2308
|
return false;
|
|
@@ -1626,15 +2436,7 @@ export async function commitValidatedLarkOwnerSetup(input) {
|
|
|
1626
2436
|
}
|
|
1627
2437
|
const previous = input.credentialTransition?.previous;
|
|
1628
2438
|
if (previous !== undefined && JSON.stringify(previous) !== JSON.stringify(input.credentialTransition?.staged)) {
|
|
1629
|
-
|
|
1630
|
-
await remove(previous);
|
|
1631
|
-
}
|
|
1632
|
-
catch {
|
|
1633
|
-
// The profile and owner are already committed. Keep the now-inactive
|
|
1634
|
-
// credential rather than reporting a false setup failure or rolling back
|
|
1635
|
-
// a successful handoff; a later setup can retire the orphan safely.
|
|
1636
|
-
process.stderr.write('lark-channel setup: previous credential cleanup failed after commit\n');
|
|
1637
|
-
}
|
|
2439
|
+
await retirePreviousCredentialBestEffort(previous, remove);
|
|
1638
2440
|
}
|
|
1639
2441
|
}
|
|
1640
2442
|
async function abortStagingJournal(input) {
|
|
@@ -1644,6 +2446,22 @@ async function abortStagingJournal(input) {
|
|
|
1644
2446
|
cause: input.setupError,
|
|
1645
2447
|
});
|
|
1646
2448
|
}
|
|
2449
|
+
if (input.stagedCredential.provider === 'linux-protected-file'
|
|
2450
|
+
&& fileSystemErrorCode(input.setupError) === 'EEXIST') {
|
|
2451
|
+
// O_EXCL proves this setup never created the colliding path. Removing it
|
|
2452
|
+
// here would delete somebody else's file merely because our random locator
|
|
2453
|
+
// collided (or was deliberately pre-created). Clear only our journal; the
|
|
2454
|
+
// next invocation will generate a new versioned locator.
|
|
2455
|
+
try {
|
|
2456
|
+
await clearLarkSetupJournal(input.patchPath);
|
|
2457
|
+
}
|
|
2458
|
+
catch (journalError) {
|
|
2459
|
+
throw new AggregateError([input.setupError, journalError], 'lark-channel setup: protected credential path collided and setup journal cleanup also failed');
|
|
2460
|
+
}
|
|
2461
|
+
throw new Error('lark-channel setup: protected credential path collision; rerun setup', {
|
|
2462
|
+
cause: input.setupError,
|
|
2463
|
+
});
|
|
2464
|
+
}
|
|
1647
2465
|
try {
|
|
1648
2466
|
await removeCredentialIdempotently(input.stagedCredential, input.remove);
|
|
1649
2467
|
await clearLarkSetupJournal(input.patchPath);
|
|
@@ -1690,6 +2508,7 @@ export async function executeLarkSetupProfileTransaction(input) {
|
|
|
1690
2508
|
readEffectiveProfile,
|
|
1691
2509
|
...(input.operations.afterCommit === undefined ? {} : { installService: input.operations.afterCommit }),
|
|
1692
2510
|
},
|
|
2511
|
+
allowDeferredSecretServiceCleanup: input.credentialProvider === 'linux-protected-file',
|
|
1693
2512
|
});
|
|
1694
2513
|
const originalPatch = await readFile(input.patchPath, 'utf8');
|
|
1695
2514
|
const effectiveProfile = await readEffectiveProfile(input.args.profile);
|
|
@@ -1716,7 +2535,8 @@ export async function executeLarkSetupProfileTransaction(input) {
|
|
|
1716
2535
|
profile: input.args.profile,
|
|
1717
2536
|
account: input.args.account,
|
|
1718
2537
|
});
|
|
1719
|
-
if (stagedCredential.provider === 'windows-dpapi'
|
|
2538
|
+
if (stagedCredential.provider === 'windows-dpapi'
|
|
2539
|
+
|| stagedCredential.provider === 'linux-protected-file') {
|
|
1720
2540
|
await mkdir(dirname(stagedCredential.path), { recursive: true, mode: 0o700 });
|
|
1721
2541
|
}
|
|
1722
2542
|
const transactionId = randomBytes(16).toString('hex');
|
|
@@ -1756,7 +2576,9 @@ export async function executeLarkSetupProfileTransaction(input) {
|
|
|
1756
2576
|
}
|
|
1757
2577
|
const keychainService = stagedCredential.provider === 'windows-dpapi'
|
|
1758
2578
|
? `dsh/lark/${input.args.profile}/${input.args.account}/versions/windows-dpapi`
|
|
1759
|
-
: stagedCredential.
|
|
2579
|
+
: stagedCredential.provider === 'linux-protected-file'
|
|
2580
|
+
? `dsh/lark/${input.args.profile}/${input.args.account}/versions/linux-protected-file`
|
|
2581
|
+
: stagedCredential.service;
|
|
1760
2582
|
return {
|
|
1761
2583
|
owner,
|
|
1762
2584
|
updatedPatch: configureLarkProfilePatch({
|
|
@@ -1771,7 +2593,10 @@ export async function executeLarkSetupProfileTransaction(input) {
|
|
|
1771
2593
|
keychainAccount: input.args.account,
|
|
1772
2594
|
credentialProvider: input.credentialProvider,
|
|
1773
2595
|
agentTools: input.args.agentTools,
|
|
1774
|
-
...(stagedCredential.provider === 'windows-dpapi'
|
|
2596
|
+
...(stagedCredential.provider === 'windows-dpapi'
|
|
2597
|
+
|| stagedCredential.provider === 'linux-protected-file'
|
|
2598
|
+
? { credentialPath: stagedCredential.path }
|
|
2599
|
+
: {}),
|
|
1775
2600
|
}),
|
|
1776
2601
|
};
|
|
1777
2602
|
}
|
|
@@ -1876,7 +2701,21 @@ export async function executeLarkSetupProfileTransaction(input) {
|
|
|
1876
2701
|
// the idempotent exact-owner handoff and converges forward.
|
|
1877
2702
|
await persistLarkSetupJournal({ ...candidateJournal, phase: 'paired' });
|
|
1878
2703
|
if (previousCredential !== undefined && !credentialLocatorEquals(previousCredential, stagedCredential)) {
|
|
1879
|
-
|
|
2704
|
+
try {
|
|
2705
|
+
await retirePreviousCredential(previousCredential, remove);
|
|
2706
|
+
}
|
|
2707
|
+
catch (error) {
|
|
2708
|
+
if (input.credentialProvider !== 'linux-protected-file'
|
|
2709
|
+
|| !canDeferSecretServiceCleanup(previousCredential, error))
|
|
2710
|
+
throw error;
|
|
2711
|
+
await deferInactiveCredentialCleanup({
|
|
2712
|
+
patchPath: input.patchPath,
|
|
2713
|
+
dshHome: input.dshHome,
|
|
2714
|
+
profile: input.args.profile,
|
|
2715
|
+
profilePatch: updatedPatch,
|
|
2716
|
+
locator: previousCredential,
|
|
2717
|
+
});
|
|
2718
|
+
}
|
|
1880
2719
|
}
|
|
1881
2720
|
await input.operations.afterCommit?.();
|
|
1882
2721
|
await clearLarkSetupJournal(input.patchPath);
|
|
@@ -1896,6 +2735,7 @@ export async function runLarkSetup(argv = process.argv.slice(2), runtime = {}) {
|
|
|
1896
2735
|
const validate = runtime.validateProfile ?? validateProfile;
|
|
1897
2736
|
const readEffectiveProfile = runtime.readEffectiveProfile ?? dumpProfile;
|
|
1898
2737
|
const installResidentService = runtime.installResidentService ?? installDshResidentService;
|
|
2738
|
+
const prepareResidentService = runtime.prepareResidentService ?? prepareDshSystemdUserService;
|
|
1899
2739
|
const recoveryOperations = {
|
|
1900
2740
|
...runtime.journalOperations,
|
|
1901
2741
|
readEffectiveProfile: runtime.journalOperations?.readEffectiveProfile ?? readEffectiveProfile,
|
|
@@ -1986,6 +2826,11 @@ export async function runLarkSetup(argv = process.argv.slice(2), runtime = {}) {
|
|
|
1986
2826
|
return;
|
|
1987
2827
|
}
|
|
1988
2828
|
if (args.installServiceOnly) {
|
|
2829
|
+
if (process.platform === 'linux') {
|
|
2830
|
+
const prepared = await prepareResidentService({ dshHome, profile: args.profile });
|
|
2831
|
+
if (prepared?.enabledLinger)
|
|
2832
|
+
process.stdout.write('已为当前 Linux 用户启用 linger,注销 SSH 后服务仍会保持。\n');
|
|
2833
|
+
}
|
|
1989
2834
|
const service = await withProfileSetupLock(patchPath, async () => {
|
|
1990
2835
|
await recoverLarkSetupJournalUnlocked({
|
|
1991
2836
|
patchPath,
|
|
@@ -2017,6 +2862,31 @@ export async function runLarkSetup(argv = process.argv.slice(2), runtime = {}) {
|
|
|
2017
2862
|
+ `状态:${service.statusCommand}\n日志:${service.logCommand}\n`);
|
|
2018
2863
|
return;
|
|
2019
2864
|
}
|
|
2865
|
+
const preferredCredentialProvider = credentialProviderForPlatform(process.platform);
|
|
2866
|
+
if (process.platform === 'linux' && args.manageService) {
|
|
2867
|
+
const prepared = await prepareResidentService({ dshHome, profile: args.profile });
|
|
2868
|
+
if (prepared?.enabledLinger)
|
|
2869
|
+
process.stdout.write('已为当前 Linux 用户启用 linger,注销 SSH 后服务仍会保持。\n');
|
|
2870
|
+
}
|
|
2871
|
+
let credentialProvider;
|
|
2872
|
+
if (runtime.preflightCredentialProvider === undefined) {
|
|
2873
|
+
const selection = await selectLarkCredentialProvider(preferredCredentialProvider, {
|
|
2874
|
+
dshHome,
|
|
2875
|
+
mode: args.linuxCredentialProvider,
|
|
2876
|
+
});
|
|
2877
|
+
credentialProvider = selection.provider;
|
|
2878
|
+
if (selection.fallbackReason !== undefined) {
|
|
2879
|
+
process.stdout.write('Linux Secret Service 当前不可用;向导已自动切换到当前用户 0600 私有凭据文件。'
|
|
2880
|
+
+ '该文件未额外加密,同一 UID 与 root 可读取,但不会进入 profile、命令参数、环境变量或日志。\n');
|
|
2881
|
+
}
|
|
2882
|
+
}
|
|
2883
|
+
else {
|
|
2884
|
+
if (args.linuxCredentialProvider !== 'auto') {
|
|
2885
|
+
throw new Error('lark-channel setup: an injected credential preflight cannot be combined with an explicit Linux provider');
|
|
2886
|
+
}
|
|
2887
|
+
credentialProvider = await runtime.preflightCredentialProvider(preferredCredentialProvider)
|
|
2888
|
+
?? preferredCredentialProvider;
|
|
2889
|
+
}
|
|
2020
2890
|
// Resolve a prior crash before prompting the user or mutating a cloud app.
|
|
2021
2891
|
// The mutation transaction repeats this check after registration to close
|
|
2022
2892
|
// the gap against a different setup process that completed in the meantime.
|
|
@@ -2026,6 +2896,7 @@ export async function runLarkSetup(argv = process.argv.slice(2), runtime = {}) {
|
|
|
2026
2896
|
dshHome,
|
|
2027
2897
|
profile: args.profile,
|
|
2028
2898
|
operations: recoveryOperations,
|
|
2899
|
+
allowDeferredSecretServiceCleanup: credentialProvider === 'linux-protected-file',
|
|
2029
2900
|
});
|
|
2030
2901
|
const databasePath = deliveryDatabasePathFromEffectiveProfile(await readEffectiveProfile(args.profile), dshHome);
|
|
2031
2902
|
await withDeliveryOwnerSetupLock(databasePath, async () => {
|
|
@@ -2037,7 +2908,6 @@ export async function runLarkSetup(argv = process.argv.slice(2), runtime = {}) {
|
|
|
2037
2908
|
});
|
|
2038
2909
|
}, runtime.profileLockOptions);
|
|
2039
2910
|
}, runtime.profileLockOptions);
|
|
2040
|
-
const credentialProvider = credentialProviderForPlatform(process.platform);
|
|
2041
2911
|
let appId = args.appId;
|
|
2042
2912
|
let createApp = args.createApp;
|
|
2043
2913
|
if (appId === undefined && !createApp) {
|
|
@@ -2081,24 +2951,31 @@ export async function runLarkSetup(argv = process.argv.slice(2), runtime = {}) {
|
|
|
2081
2951
|
credentialProvider,
|
|
2082
2952
|
...(runtime.profileLockOptions === undefined ? {} : { profileLockOptions: runtime.profileLockOptions }),
|
|
2083
2953
|
operations: {
|
|
2084
|
-
storeCredential(locator) {
|
|
2954
|
+
async storeCredential(locator) {
|
|
2085
2955
|
const service = locator.provider === 'windows-dpapi'
|
|
2086
2956
|
? `dsh/lark/${args.profile}/${args.account}/versions/windows-dpapi`
|
|
2087
|
-
: locator.
|
|
2088
|
-
|
|
2957
|
+
: locator.provider === 'linux-protected-file'
|
|
2958
|
+
? `dsh/lark/${args.profile}/${args.account}/versions/linux-protected-file`
|
|
2959
|
+
: locator.service;
|
|
2960
|
+
const credentialPath = locator.provider === 'windows-dpapi'
|
|
2961
|
+
|| locator.provider === 'linux-protected-file' ? locator.path : undefined;
|
|
2089
2962
|
if (generatedSecret === undefined) {
|
|
2090
|
-
storeSecret(credentialProvider, service, args.account, credentialPath);
|
|
2963
|
+
await storeSecret(credentialProvider, service, args.account, credentialPath);
|
|
2091
2964
|
}
|
|
2092
2965
|
else {
|
|
2093
|
-
storeGeneratedSecret(credentialProvider, service, args.account, generatedSecret, credentialPath);
|
|
2966
|
+
await storeGeneratedSecret(credentialProvider, service, args.account, generatedSecret, credentialPath);
|
|
2094
2967
|
}
|
|
2095
|
-
process.stdout.write(
|
|
2968
|
+
process.stdout.write(`候选凭据已暂存到 ${credentialProvider};旧凭据仍保持有效。\n`);
|
|
2096
2969
|
},
|
|
2097
2970
|
readCredential(locator) {
|
|
2098
2971
|
const service = locator.provider === 'windows-dpapi'
|
|
2099
2972
|
? `dsh/lark/${args.profile}/${args.account}/versions/windows-dpapi`
|
|
2100
|
-
: locator.
|
|
2101
|
-
|
|
2973
|
+
: locator.provider === 'linux-protected-file'
|
|
2974
|
+
? `dsh/lark/${args.profile}/${args.account}/versions/linux-protected-file`
|
|
2975
|
+
: locator.service;
|
|
2976
|
+
return readSecret(credentialProvider, service, args.account, locator.provider === 'windows-dpapi' || locator.provider === 'linux-protected-file'
|
|
2977
|
+
? locator.path
|
|
2978
|
+
: undefined);
|
|
2102
2979
|
},
|
|
2103
2980
|
readEffectiveProfile,
|
|
2104
2981
|
async discoverOwner(ownerInput) {
|
|
@@ -2136,7 +3013,7 @@ export async function runLarkSetup(argv = process.argv.slice(2), runtime = {}) {
|
|
|
2136
3013
|
}
|
|
2137
3014
|
if (process.argv[1] !== undefined && isMainEntry(import.meta.url, process.argv[1])) {
|
|
2138
3015
|
void runLarkSetup().catch(error => {
|
|
2139
|
-
process.stderr.write(`${
|
|
3016
|
+
process.stderr.write(`${formatLarkSetupError(error)}\n`);
|
|
2140
3017
|
process.exitCode = 1;
|
|
2141
3018
|
});
|
|
2142
3019
|
}
|