fix(mosaic): serialize credential lifecycle mutations

This commit is contained in:
2026-08-05 13:32:57 -05:00
parent b309896067
commit 1391daae4c
10 changed files with 181 additions and 27 deletions
@@ -548,10 +548,12 @@ get_gitea_token() {
printf '%s\n' "$_resolved_token" printf '%s\n' "$_resolved_token"
return 0 return 0
fi 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 _resolution_path=identity
_trace_credential_resolution credential-resolved "$_ident" "$host" "$_ident_src" _trace_credential_resolution credential-resolved "$_ident" "$host" "$_ident_src"
cat "$_idtok" printf '%s\n' "$_resolved_token"
return 0 return 0
fi fi
# FAIL LOUD: an explicit git identity was requested for a recognized Gitea host, # FAIL LOUD: an explicit git identity was requested for a recognized Gitea host,
@@ -66,11 +66,12 @@ if [ -n "$ident" ]; then
echo "password=${token}" echo "password=${token}"
exit 0 exit 0
fi 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 resolution_path=identity
trace_resolution credential-resolved "$ident" "$host" git-credential-mosaic trace_resolution credential-resolved "$ident" "$host" git-credential-mosaic
echo "username=${ident}" echo "username=${ident}"
echo "password=$(cat "$idtok")" echo "password=${token}"
exit 0 exit 0
fi fi
if [ -n "${MOSAIC_AGENT_NAME:-}" ]; then if [ -n "${MOSAIC_AGENT_NAME:-}" ]; then
@@ -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")
@@ -16,6 +16,7 @@
# NEVER reads real secrets or touches the real ~/.config/mosaic/secrets. # NEVER reads real secrets or touches the real ~/.config/mosaic/secrets.
set -euo pipefail set -euo pipefail
umask 077
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/git-credential-mosaic}" 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/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-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" chmod +x "$HELPER"
git -C "$REPO_DIR" init -q git -C "$REPO_DIR" init -q
@@ -28,6 +28,7 @@
# HOME. NEVER reads real secrets or touches the real ~/.config/mosaic/secrets. # HOME. NEVER reads real secrets or touches the real ~/.config/mosaic/secrets.
set -euo pipefail set -euo pipefail
umask 077
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/gitea-token-identity}" WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/gitea-token-identity}"
+46 -4
View File
@@ -1,5 +1,5 @@
import { timingSafeEqual } from 'node:crypto'; 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 { open, rename } from 'node:fs/promises';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
@@ -9,7 +9,7 @@ import {
CredentialJournalError, CredentialJournalError,
listCredentialJournals, listCredentialJournals,
} from '../credentials/audit-journal.js'; } from '../credentials/audit-journal.js';
import { readRegularFileSecure } from '../fleet/secure-file.js'; import { ensureManagedDirectory, readRegularFileSecure } from '../fleet/secure-file.js';
import { import {
runCredentialReadValidation, runCredentialReadValidation,
runCredentialValidation, runCredentialValidation,
@@ -542,10 +542,15 @@ export async function executeCredentialRotate(
context.provider, context.provider,
context.store, context.store,
context.teaStore, 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') { if (provisioned.outcome !== 'ok') {
await journal.seal(provisioned.outcome, provisioned.reason.code);
old.secret.fill(0); old.secret.fill(0);
return { return {
...provisioned, ...provisioned,
@@ -616,7 +621,25 @@ export async function executeCredentialWire(
}); });
} }
const locations = lifecycleLocations(options); 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 { 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); const context = await lifecycleContext(options);
if (context.registry.resolve(options.estate, options.host) === undefined) { if (context.registry.resolve(options.estate, options.host) === undefined) {
return localLifecycleResult('wire', identity, options, { return localLifecycleResult('wire', identity, options, {
@@ -668,6 +691,7 @@ export async function executeCredentialWire(
`MOSAIC_CREDENTIAL_ESTATE=${options.estate}`, `MOSAIC_CREDENTIAL_ESTATE=${options.estate}`,
`GITEA_LOGIN=${identity}--${options.host}`, `GITEA_LOGIN=${identity}--${options.host}`,
); );
const parentBefore = lstatSync(seatEnvironmentRoot);
const temp = `${options.seatEnv}.${process.pid.toString()}.tmp`; const temp = `${options.seatEnv}.${process.pid.toString()}.tmp`;
const handle = await open(temp, 'wx', 0o600); const handle = await open(temp, 'wx', 0o600);
try { try {
@@ -676,6 +700,15 @@ export async function executeCredentialWire(
} finally { } finally {
await handle.close(); 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 rename(temp, options.seatEnv);
await journal.recordMutation('wire-applied'); await journal.recordMutation('wire-applied');
await journal.seal('ok', 'wire-verified'); await journal.seal('ok', 'wire-verified');
@@ -813,6 +846,15 @@ async function executeAuthorizedInventoryRead(
}); });
} }
const context = await lifecycleContext(options); 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 authority = await lifecycleAuthority(options.actor, options);
const providerIdentity = await context.provider.readIdentity(authority); const providerIdentity = await context.provider.readIdentity(authority);
if (providerIdentity.login !== options.actor) { if (providerIdentity.login !== options.actor) {
@@ -10,5 +10,6 @@ export interface CredentialHostConfigDto {
export interface CredentialEstateConfigDto { export interface CredentialEstateConfigDto {
readonly name: string; readonly name: string;
readonly readOnlyControlIdentity?: string; readonly readOnlyControlIdentity?: string;
readonly inventoryAuthorityIdentity?: string;
readonly hosts: readonly CredentialHostConfigDto[]; readonly hosts: readonly CredentialHostConfigDto[];
} }
@@ -19,6 +19,7 @@ const estateSchema = z
.object({ .object({
name: z.string().regex(NAME), name: z.string().regex(NAME),
readOnlyControlIdentity: z.string().regex(IDENTITY).optional(), readOnlyControlIdentity: z.string().regex(IDENTITY).optional(),
inventoryAuthorityIdentity: z.string().regex(IDENTITY).optional(),
hosts: z.array(hostSchema).min(1), hosts: z.array(hostSchema).min(1),
}) })
.strict(); .strict();
@@ -105,6 +106,17 @@ export class ParsedCredentialEstateRegistry implements CredentialEstateRegistry
return undefined; 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 { readOnlyControl(estate: string): string {
const identity = this.estates.get(estate)?.readOnlyControlIdentity; const identity = this.estates.get(estate)?.readOnlyControlIdentity;
if (identity === undefined) { if (identity === undefined) {
@@ -180,10 +180,17 @@ export class FileCredentialResolver implements CredentialResolver {
}); });
const binding = bindingSchema.safeParse(JSON.parse(bindingSnapshot.content.toString('utf8'))); const binding = bindingSchema.safeParse(JSON.parse(bindingSnapshot.content.toString('utf8')));
const digest = createHash('sha256').update(snapshot.content).digest('hex'); 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( throw new CredentialStoreError(
'credential-generation-mismatch', '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) { } catch (error: unknown) {
@@ -410,17 +417,47 @@ export class FileCredentialStore {
.sort(); .sort();
} }
async remove(identity: string, estate: string, host: string): Promise<void> { async remove(
identity: string,
estate: string,
host: string,
expectedTokenDigest?: string,
): Promise<void> {
const paths = this.paths(identity, estate, host); const paths = this.paths(identity, estate, host);
await unlink(paths.token).catch((error: unknown): void => { const lockPath = join(this.tokenDirectory, `${paths.prefix}.lock`);
if (!isMissingFile(error)) throw error; let lock;
}); try {
await unlink(paths.binding).catch((error: unknown): void => { lock = await open(lockPath, 'wx', 0o600);
if (!isMissingFile(error)) throw error; } catch {
}); throw new CredentialStoreError(
await unlink(paths.envelope).catch((error: unknown): void => { 'conflicting-credential-mutation',
if (!isMissingFile(error)) throw error; 'another mutation owns the identity lock',
}); );
await syncDirectory(this.tokenDirectory); }
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);
}
} }
} }
+13 -6
View File
@@ -50,6 +50,8 @@ export interface LifecycleOptions {
readonly actor: string; readonly actor: string;
readonly now?: () => string; readonly now?: () => string;
readonly allowReplace?: boolean; readonly allowReplace?: boolean;
readonly journal?: CredentialAuditJournal;
readonly deferSuccessSeal?: boolean;
} }
function lifecycleResult( function lifecycleResult(
@@ -113,7 +115,7 @@ export async function provisionCredential(
teaStore: TeaLoginStore, teaStore: TeaLoginStore,
options: LifecycleOptions, options: LifecycleOptions,
): Promise<CredentialLifecycleResultDto> { ): Promise<CredentialLifecycleResultDto> {
const journal = await openLifecycleJournal('provision', request, options); const journal = options.journal ?? (await openLifecycleJournal('provision', request, options));
let mutation: 'none' | 'unknown' | 'applied' = 'none'; let mutation: 'none' | 'unknown' | 'applied' = 'none';
let minted: MintedToken | undefined; let minted: MintedToken | undefined;
let failureCode = 'mutation-state-unknown'; let failureCode = 'mutation-state-unknown';
@@ -192,14 +194,19 @@ export async function provisionCredential(
contentType: readBack.contentType, contentType: readBack.contentType,
decision: 'scope-verified', decision: 'scope-verified',
}); });
await journal.seal('ok', 'provision-verified'); if (options.deferSuccessSeal !== true) {
await journal.seal('ok', 'provision-verified');
}
return lifecycleResult('provision', request, { return lifecycleResult('provision', request, {
outcome: 'ok', outcome: 'ok',
mutation: 'applied', mutation: 'applied',
code: 'provision-verified', code: options.deferSuccessSeal === true ? 'replacement-staged' : 'provision-verified',
message: 'Provider principal and exact token scopes were read back and stored.', 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(), journalId: journal.journalId(),
auditState: 'sealed', auditState: options.deferSuccessSeal === true ? 'open' : 'sealed',
providerIdentity: identity.login, providerIdentity: identity.login,
token: readBack, token: readBack,
teaLogin: { ...teaLogin, state: 'registered' }, teaLogin: { ...teaLogin, state: 'registered' },
@@ -305,7 +312,7 @@ export async function revokeCredential(
throw new Error('Tea login still exists after revocation'); throw new Error('Tea login still exists after revocation');
} }
await journal.recordMutation('tea-login-removed'); 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'); await journal.seal('ok', 'revoke-verified');
return lifecycleResult('revoke', request, { return lifecycleResult('revoke', request, {
outcome: 'ok', outcome: 'ok',