From 1391daae4c9d41d8ed6583ce64f86df8975e9fd2 Mon Sep 17 00:00:00 2001 From: be-coder-06 Date: Wed, 5 Aug 2026 13:32:57 -0500 Subject: [PATCH] fix(mosaic): serialize credential lifecycle mutations --- .../framework/tools/git/detect-platform.sh | 6 +- .../framework/tools/git/git-credential-mosaic | 5 +- .../tools/git/resolve-legacy-token.py | 49 +++++++++++++++ .../tools/git/test-git-credential-mosaic.sh | 2 + .../tools/git/test-gitea-token-identity.sh | 1 + packages/mosaic/src/commands/cred.ts | 50 +++++++++++++-- .../src/credentials/estate-registry.dto.ts | 1 + .../mosaic/src/credentials/estate-registry.ts | 12 ++++ .../src/credentials/file-credential-store.ts | 63 +++++++++++++++---- packages/mosaic/src/credentials/lifecycle.ts | 19 ++++-- 10 files changed, 181 insertions(+), 27 deletions(-) create mode 100644 packages/mosaic/framework/tools/git/resolve-legacy-token.py diff --git a/packages/mosaic/framework/tools/git/detect-platform.sh b/packages/mosaic/framework/tools/git/detect-platform.sh index 5519bd29..3baa13f7 100755 --- a/packages/mosaic/framework/tools/git/detect-platform.sh +++ b/packages/mosaic/framework/tools/git/detect-platform.sh @@ -548,10 +548,12 @@ get_gitea_token() { printf '%s\n' "$_resolved_token" return 0 fi - if [[ -r "$_idtok" ]]; then + if [[ -e "$_idtok" || -L "$_idtok" ]]; then + local _resolved_token + _resolved_token=$(python3 "$script_dir/resolve-legacy-token.py" "$_idtok") || return 1 _resolution_path=identity _trace_credential_resolution credential-resolved "$_ident" "$host" "$_ident_src" - cat "$_idtok" + printf '%s\n' "$_resolved_token" return 0 fi # FAIL LOUD: an explicit git identity was requested for a recognized Gitea host, diff --git a/packages/mosaic/framework/tools/git/git-credential-mosaic b/packages/mosaic/framework/tools/git/git-credential-mosaic index a8cce2a9..ec87df2b 100755 --- a/packages/mosaic/framework/tools/git/git-credential-mosaic +++ b/packages/mosaic/framework/tools/git/git-credential-mosaic @@ -66,11 +66,12 @@ if [ -n "$ident" ]; then echo "password=${token}" exit 0 fi - if [ -r "$idtok" ]; then + if [ -e "$idtok" ] || [ -L "$idtok" ]; then + token=$(python3 "$script_dir/resolve-legacy-token.py" "$idtok") || exit 1 resolution_path=identity trace_resolution credential-resolved "$ident" "$host" git-credential-mosaic echo "username=${ident}" - echo "password=$(cat "$idtok")" + echo "password=${token}" exit 0 fi if [ -n "${MOSAIC_AGENT_NAME:-}" ]; then diff --git a/packages/mosaic/framework/tools/git/resolve-legacy-token.py b/packages/mosaic/framework/tools/git/resolve-legacy-token.py new file mode 100644 index 00000000..2f445d5a --- /dev/null +++ b/packages/mosaic/framework/tools/git/resolve-legacy-token.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Fail-closed reader for one legacy per-seat token file.""" + +import os +import stat +import sys + +MAX_BYTES = 16 * 1024 + + +def refuse(message: str) -> None: + print(f"legacy credential refused: {message}", file=sys.stderr) + raise SystemExit(1) + + +if len(sys.argv) != 2: + refuse("expected token path") +path = sys.argv[1] +parent = os.path.dirname(path) +try: + parent_stat = os.stat(parent, follow_symlinks=False) +except OSError: + refuse("credential directory unavailable") +if not stat.S_ISDIR(parent_stat.st_mode) or stat.S_ISLNK(parent_stat.st_mode): + refuse("credential directory is not a real directory") +if parent_stat.st_uid != os.getuid() or parent_stat.st_mode & 0o022: + refuse("credential directory owner or mode is unsafe") +try: + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC) +except OSError: + refuse("credential file unavailable or symbolic") +try: + file_stat = os.fstat(fd) + if not stat.S_ISREG(file_stat.st_mode): + refuse("credential is not a regular file") + if file_stat.st_uid != os.getuid() or file_stat.st_mode & 0o077: + refuse("credential owner or mode is unsafe") + content = os.read(fd, MAX_BYTES + 1) + if len(content) > MAX_BYTES: + refuse("credential exceeds size limit") +finally: + os.close(fd) +try: + token = content.decode("utf-8").strip() +except UnicodeDecodeError: + refuse("credential is not UTF-8") +if not token or any(ch.isspace() for ch in token): + refuse("credential token is invalid") +sys.stdout.write(token + "\n") diff --git a/packages/mosaic/framework/tools/git/test-git-credential-mosaic.sh b/packages/mosaic/framework/tools/git/test-git-credential-mosaic.sh index df852e24..c765b9ae 100755 --- a/packages/mosaic/framework/tools/git/test-git-credential-mosaic.sh +++ b/packages/mosaic/framework/tools/git/test-git-credential-mosaic.sh @@ -16,6 +16,7 @@ # NEVER reads real secrets or touches the real ~/.config/mosaic/secrets. set -euo pipefail +umask 077 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/git-credential-mosaic}" @@ -35,6 +36,7 @@ mkdir -p "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens" \ cp "$SCRIPT_DIR/git-credential-mosaic" "$HELPER" cp "$SCRIPT_DIR/resolve-credential-envelope.py" "$FAKE_HOME/.config/mosaic/tools/git/resolve-credential-envelope.py" +cp "$SCRIPT_DIR/resolve-legacy-token.py" "$FAKE_HOME/.config/mosaic/tools/git/resolve-legacy-token.py" chmod +x "$HELPER" git -C "$REPO_DIR" init -q diff --git a/packages/mosaic/framework/tools/git/test-gitea-token-identity.sh b/packages/mosaic/framework/tools/git/test-gitea-token-identity.sh index 1db4bb39..fe39a53a 100755 --- a/packages/mosaic/framework/tools/git/test-gitea-token-identity.sh +++ b/packages/mosaic/framework/tools/git/test-gitea-token-identity.sh @@ -28,6 +28,7 @@ # HOME. NEVER reads real secrets or touches the real ~/.config/mosaic/secrets. set -euo pipefail +umask 077 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/gitea-token-identity}" diff --git a/packages/mosaic/src/commands/cred.ts b/packages/mosaic/src/commands/cred.ts index d6255b7a..fe20c2de 100644 --- a/packages/mosaic/src/commands/cred.ts +++ b/packages/mosaic/src/commands/cred.ts @@ -1,5 +1,5 @@ import { timingSafeEqual } from 'node:crypto'; -import { fstatSync, writeSync } from 'node:fs'; +import { fstatSync, lstatSync, writeSync } from 'node:fs'; import { open, rename } from 'node:fs/promises'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -9,7 +9,7 @@ import { CredentialJournalError, listCredentialJournals, } from '../credentials/audit-journal.js'; -import { readRegularFileSecure } from '../fleet/secure-file.js'; +import { ensureManagedDirectory, readRegularFileSecure } from '../fleet/secure-file.js'; import { runCredentialReadValidation, runCredentialValidation, @@ -542,10 +542,15 @@ export async function executeCredentialRotate( context.provider, context.store, context.teaStore, - { stateRoot: context.stateRoot, actor: options.actor, allowReplace: true }, + { + stateRoot: context.stateRoot, + actor: options.actor, + allowReplace: true, + journal, + deferSuccessSeal: true, + }, ); if (provisioned.outcome !== 'ok') { - await journal.seal(provisioned.outcome, provisioned.reason.code); old.secret.fill(0); return { ...provisioned, @@ -616,7 +621,25 @@ export async function executeCredentialWire( }); } const locations = lifecycleLocations(options); + const seatEnvironmentRoot = join(defaultMosaicHome(options), 'fleet', 'agents'); + if (dirname(options.seatEnv) !== seatEnvironmentRoot) { + return localLifecycleResult('wire', identity, options, { + outcome: 'refused', + code: 'insecure-credential-destination', + message: 'Seat environment must be directly beneath the governed fleet agent directory.', + }); + } try { + ensureManagedDirectory(seatEnvironmentRoot, seatEnvironmentRoot); + const parent = lstatSync(seatEnvironmentRoot); + if ( + !parent.isDirectory() || + parent.isSymbolicLink() || + parent.uid !== process.getuid?.() || + (parent.mode & 0o022) !== 0 + ) { + throw new Error('seat environment directory is unsafe'); + } const context = await lifecycleContext(options); if (context.registry.resolve(options.estate, options.host) === undefined) { return localLifecycleResult('wire', identity, options, { @@ -668,6 +691,7 @@ export async function executeCredentialWire( `MOSAIC_CREDENTIAL_ESTATE=${options.estate}`, `GITEA_LOGIN=${identity}--${options.host}`, ); + const parentBefore = lstatSync(seatEnvironmentRoot); const temp = `${options.seatEnv}.${process.pid.toString()}.tmp`; const handle = await open(temp, 'wx', 0o600); try { @@ -676,6 +700,15 @@ export async function executeCredentialWire( } finally { await handle.close(); } + const parentAfter = lstatSync(seatEnvironmentRoot); + if ( + parentAfter.dev !== parentBefore.dev || + parentAfter.ino !== parentBefore.ino || + parentAfter.uid !== process.getuid?.() || + (parentAfter.mode & 0o022) !== 0 + ) { + throw new Error('seat environment directory changed during mutation'); + } await rename(temp, options.seatEnv); await journal.recordMutation('wire-applied'); await journal.seal('ok', 'wire-verified'); @@ -813,6 +846,15 @@ async function executeAuthorizedInventoryRead( }); } const context = await lifecycleContext(options); + if (context.registry.inventoryAuthority(options.estate) !== options.actor) { + await journal.seal('refused', 'permission-denied'); + return localLifecycleResult(operation, 'all', options, { + outcome: 'refused', + code: 'permission-denied', + message: 'Explicit actor is not the configured estate inventory authority.', + audit: { journalId: journal.journalId(), state: 'sealed' }, + }); + } const authority = await lifecycleAuthority(options.actor, options); const providerIdentity = await context.provider.readIdentity(authority); if (providerIdentity.login !== options.actor) { diff --git a/packages/mosaic/src/credentials/estate-registry.dto.ts b/packages/mosaic/src/credentials/estate-registry.dto.ts index 60a7adb9..80e808a6 100644 --- a/packages/mosaic/src/credentials/estate-registry.dto.ts +++ b/packages/mosaic/src/credentials/estate-registry.dto.ts @@ -10,5 +10,6 @@ export interface CredentialHostConfigDto { export interface CredentialEstateConfigDto { readonly name: string; readonly readOnlyControlIdentity?: string; + readonly inventoryAuthorityIdentity?: string; readonly hosts: readonly CredentialHostConfigDto[]; } diff --git a/packages/mosaic/src/credentials/estate-registry.ts b/packages/mosaic/src/credentials/estate-registry.ts index f1349624..de3cedf2 100644 --- a/packages/mosaic/src/credentials/estate-registry.ts +++ b/packages/mosaic/src/credentials/estate-registry.ts @@ -19,6 +19,7 @@ const estateSchema = z .object({ name: z.string().regex(NAME), readOnlyControlIdentity: z.string().regex(IDENTITY).optional(), + inventoryAuthorityIdentity: z.string().regex(IDENTITY).optional(), hosts: z.array(hostSchema).min(1), }) .strict(); @@ -105,6 +106,17 @@ export class ParsedCredentialEstateRegistry implements CredentialEstateRegistry return undefined; } + inventoryAuthority(estate: string): string { + const identity = this.estates.get(estate)?.inventoryAuthorityIdentity; + if (identity === undefined) { + throw new CredentialEstateRegistryError( + 'inventory-authority-missing', + `estate ${estate} has no delegated inventory authority identity`, + ); + } + return identity; + } + readOnlyControl(estate: string): string { const identity = this.estates.get(estate)?.readOnlyControlIdentity; if (identity === undefined) { diff --git a/packages/mosaic/src/credentials/file-credential-store.ts b/packages/mosaic/src/credentials/file-credential-store.ts index 569e525c..9b5f04e3 100644 --- a/packages/mosaic/src/credentials/file-credential-store.ts +++ b/packages/mosaic/src/credentials/file-credential-store.ts @@ -180,10 +180,17 @@ export class FileCredentialResolver implements CredentialResolver { }); const binding = bindingSchema.safeParse(JSON.parse(bindingSnapshot.content.toString('utf8'))); const digest = createHash('sha256').update(snapshot.content).digest('hex'); - if (!binding.success || binding.data.tokenDigest !== digest) { + if ( + !binding.success || + binding.data.tokenDigest !== digest || + binding.data.identity !== identity || + binding.data.estate !== estate || + binding.data.host !== host || + binding.data.providerLogin !== identity + ) { throw new CredentialStoreError( 'credential-generation-mismatch', - 'token and binding metadata are not one committed generation', + 'token and binding metadata are not one committed identity-bound generation', ); } } catch (error: unknown) { @@ -410,17 +417,47 @@ export class FileCredentialStore { .sort(); } - async remove(identity: string, estate: string, host: string): Promise { + async remove( + identity: string, + estate: string, + host: string, + expectedTokenDigest?: string, + ): Promise { const paths = this.paths(identity, estate, host); - await unlink(paths.token).catch((error: unknown): void => { - if (!isMissingFile(error)) throw error; - }); - await unlink(paths.binding).catch((error: unknown): void => { - if (!isMissingFile(error)) throw error; - }); - await unlink(paths.envelope).catch((error: unknown): void => { - if (!isMissingFile(error)) throw error; - }); - await syncDirectory(this.tokenDirectory); + const lockPath = join(this.tokenDirectory, `${paths.prefix}.lock`); + let lock; + try { + lock = await open(lockPath, 'wx', 0o600); + } catch { + throw new CredentialStoreError( + 'conflicting-credential-mutation', + 'another mutation owns the identity lock', + ); + } + try { + if (expectedTokenDigest !== undefined) { + const current = await this.readBinding(identity, estate, host); + if (current === undefined || current.tokenDigest !== expectedTokenDigest) { + throw new CredentialStoreError( + 'credential-generation-mismatch', + 'credential generation changed before removal', + ); + } + } + await unlink(paths.token).catch((error: unknown): void => { + if (!isMissingFile(error)) throw error; + }); + await unlink(paths.binding).catch((error: unknown): void => { + if (!isMissingFile(error)) throw error; + }); + await unlink(paths.envelope).catch((error: unknown): void => { + if (!isMissingFile(error)) throw error; + }); + await syncDirectory(this.tokenDirectory); + } finally { + await lock.close(); + await unlink(lockPath).catch((): void => undefined); + await syncDirectory(this.tokenDirectory); + } } } diff --git a/packages/mosaic/src/credentials/lifecycle.ts b/packages/mosaic/src/credentials/lifecycle.ts index 32296390..4ca8ebcb 100644 --- a/packages/mosaic/src/credentials/lifecycle.ts +++ b/packages/mosaic/src/credentials/lifecycle.ts @@ -50,6 +50,8 @@ export interface LifecycleOptions { readonly actor: string; readonly now?: () => string; readonly allowReplace?: boolean; + readonly journal?: CredentialAuditJournal; + readonly deferSuccessSeal?: boolean; } function lifecycleResult( @@ -113,7 +115,7 @@ export async function provisionCredential( teaStore: TeaLoginStore, options: LifecycleOptions, ): Promise { - const journal = await openLifecycleJournal('provision', request, options); + const journal = options.journal ?? (await openLifecycleJournal('provision', request, options)); let mutation: 'none' | 'unknown' | 'applied' = 'none'; let minted: MintedToken | undefined; let failureCode = 'mutation-state-unknown'; @@ -192,14 +194,19 @@ export async function provisionCredential( contentType: readBack.contentType, decision: 'scope-verified', }); - await journal.seal('ok', 'provision-verified'); + if (options.deferSuccessSeal !== true) { + await journal.seal('ok', 'provision-verified'); + } return lifecycleResult('provision', request, { outcome: 'ok', mutation: 'applied', - code: 'provision-verified', - message: 'Provider principal and exact token scopes were read back and stored.', + code: options.deferSuccessSeal === true ? 'replacement-staged' : 'provision-verified', + message: + options.deferSuccessSeal === true + ? 'Replacement was read back and staged under the open rotation transaction.' + : 'Provider principal and exact token scopes were read back and stored.', journalId: journal.journalId(), - auditState: 'sealed', + auditState: options.deferSuccessSeal === true ? 'open' : 'sealed', providerIdentity: identity.login, token: readBack, teaLogin: { ...teaLogin, state: 'registered' }, @@ -305,7 +312,7 @@ export async function revokeCredential( throw new Error('Tea login still exists after revocation'); } await journal.recordMutation('tea-login-removed'); - await store.remove(request.identity, request.estate, request.host); + await store.remove(request.identity, request.estate, request.host, binding.tokenDigest); await journal.seal('ok', 'revoke-verified'); return lifecycleResult('revoke', request, { outcome: 'ok',