@cortexkit/common-auth 0.9.3 → 0.9.5
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.
|
@@ -91,6 +91,16 @@ export declare function hostEnrollmentPaths(input: {
|
|
|
91
91
|
override?: string;
|
|
92
92
|
cwd?: string;
|
|
93
93
|
}): ClaustrumEnrollmentPaths;
|
|
94
|
+
/** Account-file paths are injectable for isolated setup tests; no environment overrides. */
|
|
95
|
+
export interface EnrollmentAncestorOptions {
|
|
96
|
+
passwdPath?: string;
|
|
97
|
+
groupPath?: string;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Protect setup writes from directory replacement. Group write access is safe
|
|
101
|
+
* only when local account records prove that the effective user's group is private.
|
|
102
|
+
*/
|
|
103
|
+
export declare function refuseWritableAncestor(parent: string, options?: EnrollmentAncestorOptions): Promise<void>;
|
|
94
104
|
export declare function readClaustrumEnrollmentStatus(paths: ClaustrumEnrollmentPaths, proposedName: string): Promise<ClaustrumEnrollmentStatus>;
|
|
95
105
|
/**
|
|
96
106
|
* Read fresh bearer material for one scoped operation; never publish it.
|
|
@@ -111,6 +121,7 @@ export declare class ClaustrumEnrollmentManager {
|
|
|
111
121
|
now?: () => number;
|
|
112
122
|
mintSecret?: () => string;
|
|
113
123
|
writeTokenFile?: typeof writeEnrollmentTokenFile;
|
|
124
|
+
ancestorOptions?: EnrollmentAncestorOptions;
|
|
114
125
|
});
|
|
115
126
|
status(): Promise<ClaustrumEnrollmentStatus>;
|
|
116
127
|
resetTerminal(): Promise<ClaustrumEnrollmentResetResult>;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
2
2
|
import { constants as fsConstants } from 'node:fs';
|
|
3
|
-
import { chmod, mkdir, open, realpath, rename, stat, unlink, } from 'node:fs/promises';
|
|
3
|
+
import { chmod, mkdir, open, readFile, realpath, rename, stat, unlink, } from 'node:fs/promises';
|
|
4
4
|
import { basename, dirname, extname, isAbsolute, join, resolve, } from 'node:path';
|
|
5
5
|
import { ClaustrumCredentialError, ClaustrumClient as ClaustrumWireClient, writeEnrollmentTokenFile, } from '@cortexkit/claustrum-client';
|
|
6
6
|
import { acquireRefreshFileLock } from '../fs/index.js';
|
|
@@ -183,7 +183,7 @@ function validateReadableSecretFile(metadata) {
|
|
|
183
183
|
if ((metadata.mode & 0o077) !== 0) {
|
|
184
184
|
throw new ClaustrumConsumerError('unsafe-file', 'Claustrum enrollment file must be owner-only');
|
|
185
185
|
}
|
|
186
|
-
const expectedUid = process.
|
|
186
|
+
const expectedUid = process.geteuid?.();
|
|
187
187
|
if (expectedUid !== undefined && metadata.uid !== expectedUid) {
|
|
188
188
|
throw new ClaustrumConsumerError('unsafe-file', 'Claustrum enrollment file must be owned by the current user');
|
|
189
189
|
}
|
|
@@ -221,11 +221,61 @@ async function readBoundedJson(path) {
|
|
|
221
221
|
await descriptor.close();
|
|
222
222
|
}
|
|
223
223
|
}
|
|
224
|
+
function accountRecords(text, fields) {
|
|
225
|
+
const records = [];
|
|
226
|
+
for (const line of text.split('\n')) {
|
|
227
|
+
if (line.trim() === '' || line.startsWith('#'))
|
|
228
|
+
continue;
|
|
229
|
+
const parts = line.split(':');
|
|
230
|
+
if (parts.length !== fields ||
|
|
231
|
+
!/^[^\s:,]+$/.test(parts[0] ?? '') ||
|
|
232
|
+
!/^\d+$/.test(parts[2] ?? '') ||
|
|
233
|
+
!Number.isSafeInteger(Number(parts[2])) ||
|
|
234
|
+
(fields === 7 &&
|
|
235
|
+
(!/^\d+$/.test(parts[3] ?? '') ||
|
|
236
|
+
!Number.isSafeInteger(Number(parts[3])))) ||
|
|
237
|
+
(fields === 4 &&
|
|
238
|
+
parts[3] !== '' &&
|
|
239
|
+
!/^[^\s:,]+(?:,[^\s:,]+)*$/.test(parts[3] ?? '')))
|
|
240
|
+
return undefined;
|
|
241
|
+
records.push(parts);
|
|
242
|
+
}
|
|
243
|
+
return records;
|
|
244
|
+
}
|
|
245
|
+
async function isPrivateGroup(uid, gid, options) {
|
|
246
|
+
try {
|
|
247
|
+
const [passwdText, groupText] = await Promise.all([
|
|
248
|
+
readFile(options.passwdPath ?? '/etc/passwd', 'utf8'),
|
|
249
|
+
readFile(options.groupPath ?? '/etc/group', 'utf8'),
|
|
250
|
+
]);
|
|
251
|
+
const users = accountRecords(passwdText, 7);
|
|
252
|
+
const groups = accountRecords(groupText, 4);
|
|
253
|
+
if (!users || !groups)
|
|
254
|
+
return false;
|
|
255
|
+
const owners = users.filter((user) => Number(user[2]) === uid);
|
|
256
|
+
const matchingGroups = groups.filter((group) => Number(group[2]) === gid);
|
|
257
|
+
if (owners.length !== 1 || matchingGroups.length !== 1)
|
|
258
|
+
return false;
|
|
259
|
+
const name = owners[0]?.[0];
|
|
260
|
+
const members = matchingGroups[0]?.[3] ?? '';
|
|
261
|
+
return ((members === '' ||
|
|
262
|
+
members.split(',').every((member) => member === name)) &&
|
|
263
|
+
!users.some((user) => Number(user[2]) !== uid && Number(user[3]) === gid));
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
// Missing local records (including NSS-only accounts) cannot prove exclusivity.
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
224
270
|
/**
|
|
225
|
-
*
|
|
226
|
-
*
|
|
271
|
+
* Protect setup writes from directory replacement. Group write access is safe
|
|
272
|
+
* only when local account records prove that the effective user's group is private.
|
|
227
273
|
*/
|
|
228
|
-
async function refuseWritableAncestor(parent) {
|
|
274
|
+
export async function refuseWritableAncestor(parent, options = {}) {
|
|
275
|
+
const uid = process.geteuid?.();
|
|
276
|
+
if (uid === undefined)
|
|
277
|
+
return;
|
|
278
|
+
const gid = process.getegid?.();
|
|
229
279
|
let component;
|
|
230
280
|
try {
|
|
231
281
|
component = await realpath(parent);
|
|
@@ -233,12 +283,23 @@ async function refuseWritableAncestor(parent) {
|
|
|
233
283
|
catch {
|
|
234
284
|
return;
|
|
235
285
|
}
|
|
286
|
+
// Load lazily, once per traversal: account changes take effect on the next write.
|
|
287
|
+
let privateGroup;
|
|
236
288
|
for (;;) {
|
|
237
289
|
const metadata = await stat(component).catch(() => undefined);
|
|
238
290
|
if (metadata &&
|
|
239
291
|
(metadata.mode & 0o022) !== 0 &&
|
|
240
292
|
(metadata.mode & 0o1000) === 0) {
|
|
241
|
-
|
|
293
|
+
if (privateGroup === undefined) {
|
|
294
|
+
privateGroup =
|
|
295
|
+
gid !== undefined && (await isPrivateGroup(uid, gid, options));
|
|
296
|
+
}
|
|
297
|
+
if ((metadata.mode & 0o002) !== 0 ||
|
|
298
|
+
metadata.uid !== uid ||
|
|
299
|
+
metadata.gid !== gid ||
|
|
300
|
+
!privateGroup) {
|
|
301
|
+
throw new ClaustrumConsumerError('unsafe-file', `Claustrum enrollment path has a writable ancestor: ${component}. Another user could replace it. Run: chmod g-w,o-w ${component}`);
|
|
302
|
+
}
|
|
242
303
|
}
|
|
243
304
|
const next = dirname(component);
|
|
244
305
|
if (next === component)
|
|
@@ -254,17 +315,17 @@ async function refuseWritableAncestor(parent) {
|
|
|
254
315
|
async function ensurePrivateDirectory(directory) {
|
|
255
316
|
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
256
317
|
const metadata = await stat(directory);
|
|
257
|
-
const uid = process.
|
|
318
|
+
const uid = process.geteuid?.();
|
|
258
319
|
if (uid !== undefined && metadata.uid !== uid) {
|
|
259
320
|
throw new ClaustrumConsumerError('unsafe-file', 'Claustrum enrollment directory must be owned by the current user');
|
|
260
321
|
}
|
|
261
322
|
if ((metadata.mode & 0o077) !== 0)
|
|
262
323
|
await chmod(directory, 0o700);
|
|
263
324
|
}
|
|
264
|
-
async function writeStateAtomic(path, state) {
|
|
325
|
+
async function writeStateAtomic(path, state, ancestorOptions = {}) {
|
|
265
326
|
const parent = dirname(path);
|
|
266
327
|
await ensurePrivateDirectory(parent);
|
|
267
|
-
await refuseWritableAncestor(parent);
|
|
328
|
+
await refuseWritableAncestor(parent, ancestorOptions);
|
|
268
329
|
const bytes = `${JSON.stringify(state)}\n`;
|
|
269
330
|
if (Buffer.byteLength(bytes) > ENROLLMENT_FILE_MAX_BYTES) {
|
|
270
331
|
throw new ClaustrumConsumerError('invalid-state', 'Claustrum enrollment state is too large');
|
|
@@ -321,7 +382,10 @@ export async function readClaustrumEnrollmentStatus(paths, proposedName) {
|
|
|
321
382
|
* Re-reading per operation is what lets an operator reissue a token on disk.
|
|
322
383
|
*/
|
|
323
384
|
export async function readClaustrumEnrollmentToken(tokenPath) {
|
|
324
|
-
|
|
385
|
+
// Reads rely on O_NOFOLLOW and regular, owner-only, effective-uid-owned files:
|
|
386
|
+
// another user cannot plant a file owned by us. Ancestor checks protect writes
|
|
387
|
+
// from placing our secret in a swapped directory, not reads; keep their
|
|
388
|
+
// realpath/stat and account-file I/O off the authorization path.
|
|
325
389
|
const value = await readBoundedJson(tokenPath);
|
|
326
390
|
if (value === undefined)
|
|
327
391
|
throw new ClaustrumConsumerError('not-enrolled', 'Claustrum enrollment is not configured');
|
|
@@ -368,7 +432,9 @@ export class ClaustrumEnrollmentManager {
|
|
|
368
432
|
#now;
|
|
369
433
|
#mintSecret;
|
|
370
434
|
#writeTokenFile;
|
|
435
|
+
#ancestorOptions;
|
|
371
436
|
constructor(options) {
|
|
437
|
+
this.#ancestorOptions = options.ancestorOptions ?? {};
|
|
372
438
|
this.#client = options.client;
|
|
373
439
|
this.#paths = options.paths;
|
|
374
440
|
this.#proposedName = options.proposedName;
|
|
@@ -392,7 +458,7 @@ export class ClaustrumEnrollmentManager {
|
|
|
392
458
|
errorCode: code,
|
|
393
459
|
updatedAt: this.#now(),
|
|
394
460
|
};
|
|
395
|
-
await writeStateAtomic(this.#paths.statePath, blocked);
|
|
461
|
+
await writeStateAtomic(this.#paths.statePath, blocked, this.#ancestorOptions);
|
|
396
462
|
return statusFromState(blocked);
|
|
397
463
|
}
|
|
398
464
|
async reconcile() {
|
|
@@ -421,7 +487,7 @@ export class ClaustrumEnrollmentManager {
|
|
|
421
487
|
tokenGeneration: token.token_generation,
|
|
422
488
|
updatedAt: this.#now(),
|
|
423
489
|
};
|
|
424
|
-
await writeStateAtomic(this.#paths.statePath, approved);
|
|
490
|
+
await writeStateAtomic(this.#paths.statePath, approved, this.#ancestorOptions);
|
|
425
491
|
state = approved;
|
|
426
492
|
}
|
|
427
493
|
return {
|
|
@@ -442,7 +508,7 @@ export class ClaustrumEnrollmentManager {
|
|
|
442
508
|
errorCode: 'missing_token',
|
|
443
509
|
updatedAt: this.#now(),
|
|
444
510
|
};
|
|
445
|
-
await writeStateAtomic(this.#paths.statePath, blocked);
|
|
511
|
+
await writeStateAtomic(this.#paths.statePath, blocked, this.#ancestorOptions);
|
|
446
512
|
return statusFromState(blocked);
|
|
447
513
|
}
|
|
448
514
|
if (state && state.phase !== 'pending')
|
|
@@ -462,7 +528,7 @@ export class ClaustrumEnrollmentManager {
|
|
|
462
528
|
// The secret is on disk before the proposal leaves the process: the
|
|
463
529
|
// vault answers a repeated proposal with the same secret with the same
|
|
464
530
|
// request id, so a crash between propose and saving the id costs nothing.
|
|
465
|
-
await writeStateAtomic(this.#paths.statePath, state);
|
|
531
|
+
await writeStateAtomic(this.#paths.statePath, state, this.#ancestorOptions);
|
|
466
532
|
}
|
|
467
533
|
if (!state.requestId) {
|
|
468
534
|
try {
|
|
@@ -478,7 +544,7 @@ export class ClaustrumEnrollmentManager {
|
|
|
478
544
|
requestId: proposed.requestId,
|
|
479
545
|
updatedAt: this.#now(),
|
|
480
546
|
};
|
|
481
|
-
await writeStateAtomic(this.#paths.statePath, state);
|
|
547
|
+
await writeStateAtomic(this.#paths.statePath, state, this.#ancestorOptions);
|
|
482
548
|
}
|
|
483
549
|
catch (error) {
|
|
484
550
|
const refusal = classifyEnrollmentError(error);
|
|
@@ -511,11 +577,13 @@ export class ClaustrumEnrollmentManager {
|
|
|
511
577
|
proposedName: state.proposedName,
|
|
512
578
|
updatedAt: this.#now(),
|
|
513
579
|
};
|
|
514
|
-
await writeStateAtomic(this.#paths.statePath, denied);
|
|
580
|
+
await writeStateAtomic(this.#paths.statePath, denied, this.#ancestorOptions);
|
|
515
581
|
return statusFromState(denied);
|
|
516
582
|
}
|
|
517
583
|
// The vault returns the token exactly once: it reaches disk before the
|
|
518
584
|
// pending metadata (and its secret) is replaced.
|
|
585
|
+
await ensurePrivateDirectory(dirname(this.#paths.tokenPath));
|
|
586
|
+
await refuseWritableAncestor(dirname(this.#paths.tokenPath), this.#ancestorOptions);
|
|
519
587
|
await this.#writeTokenFile(this.#paths.tokenPath, {
|
|
520
588
|
token: outcome.token,
|
|
521
589
|
token_generation: outcome.tokenGeneration,
|
|
@@ -528,7 +596,7 @@ export class ClaustrumEnrollmentManager {
|
|
|
528
596
|
tokenGeneration: outcome.tokenGeneration,
|
|
529
597
|
updatedAt: this.#now(),
|
|
530
598
|
};
|
|
531
|
-
await writeStateAtomic(this.#paths.statePath, approved);
|
|
599
|
+
await writeStateAtomic(this.#paths.statePath, approved, this.#ancestorOptions);
|
|
532
600
|
return statusFromState(approved);
|
|
533
601
|
}
|
|
534
602
|
catch (error) {
|
|
@@ -7,7 +7,7 @@ export { type ClaustrumEnrollmentClient, type ClaustrumEnrollmentConnection, Cla
|
|
|
7
7
|
export { ClaustrumConsumerError, type ClaustrumConsumerFailureKind, type ClaustrumLogger, } from './errors.js';
|
|
8
8
|
export { assertHostSlotMatchesMode, assertNotCustodyPlaceholder, CUSTODY_PLACEHOLDER_PREFIX, classifyHostSlot, custodyPlaceholder, custodyPlaceholderKey, type HostSlotContent, isCustodyPlaceholder, isCustodyPlaceholderValue, } from './host-slot.js';
|
|
9
9
|
export { acceptAccount, type DeclinedAccount, declineAccount, isDeclined, } from './interlock.js';
|
|
10
|
-
export { type AccountMapper, acceptVaultRoute, DEFAULT_ROUTE_PREFIX, declineVaultRoute, mutateVaultRoster, type ProjectionOptions, projectVaultRoster, type QuotaReceipt, readVaultRoster, recordVaultQuota, refreshVaultRoster, resolveVaultPrimary, type VaultPrimary, type VaultPrimaryBinding, type VaultPrimaryUnavailableReason, type VaultRosterFile, type VaultRosterRow, vaultRoutingRows, } from './roster.js';
|
|
10
|
+
export { type AccountMapper, acceptVaultRoute, DEFAULT_ROUTE_PREFIX, declineVaultRoute, mutateVaultRoster, type ProjectionOptions, projectVaultRoster, type QuotaReceipt, readVaultRoster, recordVaultQuota, refreshVaultRoster, resolveVaultPrimary, type VaultPrimary, type VaultPrimaryBinding, type VaultPrimaryUnavailableReason, type VaultRosterFile, type VaultRosterLockContext, type VaultRosterRow, vaultRoutingRows, } from './roster.js';
|
|
11
11
|
/**
|
|
12
12
|
* Connect the client that lists and fetches this consumer's vault credentials
|
|
13
13
|
* on the request path. `connectionFile` is required: this library
|
|
@@ -86,11 +86,16 @@ export declare function projectVaultRoster(previous: VaultRosterFile | undefined
|
|
|
86
86
|
*/
|
|
87
87
|
export declare function vaultRoutingRows(roster: VaultRosterFile | undefined): RoutingRow[];
|
|
88
88
|
export declare function readVaultRoster(path: string): Promise<VaultRosterFile | undefined>;
|
|
89
|
+
/** The ownership assertion available to a roster mutation callback. */
|
|
90
|
+
export interface VaultRosterLockContext {
|
|
91
|
+
assertOwned: () => Promise<void>;
|
|
92
|
+
}
|
|
89
93
|
/**
|
|
90
94
|
* Read, change and write the roster under its write lock. `change` returns
|
|
91
|
-
* undefined to leave the file as it is.
|
|
95
|
+
* undefined to leave the file as it is. Call `assertOwned()` immediately
|
|
96
|
+
* before any rename performed inside `change`; if it throws, do not rename.
|
|
92
97
|
*/
|
|
93
|
-
export declare function mutateVaultRoster<T>(path: string, change: (current: VaultRosterFile | undefined) => Promise<{
|
|
98
|
+
export declare function mutateVaultRoster<T>(path: string, change: (current: VaultRosterFile | undefined, context: VaultRosterLockContext) => Promise<{
|
|
94
99
|
next?: VaultRosterFile;
|
|
95
100
|
result: T;
|
|
96
101
|
}> | {
|
package/dist/claustrum/roster.js
CHANGED
|
@@ -313,12 +313,15 @@ const WRITE_LOCK = {
|
|
|
313
313
|
const DISCOVERY_LEASE = { name: 'claustrum-roster', ttlMs: 30_000 };
|
|
314
314
|
/**
|
|
315
315
|
* Read, change and write the roster under its write lock. `change` returns
|
|
316
|
-
* undefined to leave the file as it is.
|
|
316
|
+
* undefined to leave the file as it is. Call `assertOwned()` immediately
|
|
317
|
+
* before any rename performed inside `change`; if it throws, do not rename.
|
|
317
318
|
*/
|
|
318
319
|
export async function mutateVaultRoster(path, change) {
|
|
319
320
|
return withLock(path, WRITE_LOCK, async (lock) => {
|
|
320
321
|
const current = await readVaultRoster(path);
|
|
321
|
-
const { next, result } = await change(current
|
|
322
|
+
const { next, result } = await change(current, {
|
|
323
|
+
assertOwned: () => lock.assertOwned(),
|
|
324
|
+
});
|
|
322
325
|
if (next) {
|
|
323
326
|
await lock.assertOwned();
|
|
324
327
|
await writeJsonAtomic(path, next);
|
package/dist/rpc/rpc-server.js
CHANGED
|
@@ -61,6 +61,10 @@ async function withDeadline(work, ms) {
|
|
|
61
61
|
let timer;
|
|
62
62
|
const deadline = new Promise((_, reject) => {
|
|
63
63
|
timer = setTimeout(() => reject(new ApplyDeadlineError('apply deadline exceeded')), ms);
|
|
64
|
+
// The deadline only bounds a reply. While a request is open its socket
|
|
65
|
+
// keeps the process alive anyway; after stop() closes it, a handler that
|
|
66
|
+
// never settles must not hold the process open until the deadline.
|
|
67
|
+
timer.unref?.();
|
|
64
68
|
});
|
|
65
69
|
// A handler that fails after its deadline has nobody left to answer.
|
|
66
70
|
work.catch(() => { });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cortexkit/common-auth",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.5",
|
|
4
4
|
"description": "Shared code for the CortexKit auth plugins: account pool, quota and routing, commands and auth menu, OpenCode 2 hooks, Claustrum custody, and plumbing (loopback RPC, file locks, logger, sidebar state, TUI preferences and build).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -94,7 +94,7 @@
|
|
|
94
94
|
"lint": "biome check .",
|
|
95
95
|
"prepublishOnly": "bun run build",
|
|
96
96
|
"check:ranges": "bun scripts/check-installed-ranges.mjs",
|
|
97
|
-
"check:claustrum-
|
|
97
|
+
"check:claustrum-floor": "bun scripts/check-claustrum-floor.mjs",
|
|
98
98
|
"check:opentui-0511": "bun scripts/check-opentui-0511.mjs"
|
|
99
99
|
},
|
|
100
100
|
"dependencies": {
|
|
@@ -104,7 +104,7 @@
|
|
|
104
104
|
"@opentui/core": ">=0.5.11",
|
|
105
105
|
"@opentui/solid": ">=0.5.11",
|
|
106
106
|
"solid-js": "1.9.12",
|
|
107
|
-
"@cortexkit/claustrum-client": ">=0.
|
|
107
|
+
"@cortexkit/claustrum-client": ">=0.6.2",
|
|
108
108
|
"@opencode/plugin": ">=2.0.21"
|
|
109
109
|
},
|
|
110
110
|
"peerDependenciesMeta": {
|
|
@@ -126,7 +126,7 @@
|
|
|
126
126
|
},
|
|
127
127
|
"devDependencies": {
|
|
128
128
|
"@biomejs/biome": "2.5.14",
|
|
129
|
-
"@cortexkit/claustrum-client": "0.
|
|
129
|
+
"@cortexkit/claustrum-client": "0.6.2",
|
|
130
130
|
"@opencode/plugin": "2.0.21",
|
|
131
131
|
"@opentui/core": ">=0.5.12",
|
|
132
132
|
"@opentui/solid": ">=0.5.12",
|