fix(mosaic): make credential lifecycle transactional

This commit is contained in:
2026-08-05 18:01:41 -05:00
parent fa301afb8c
commit 791c57157a
13 changed files with 389 additions and 43 deletions
@@ -516,6 +516,11 @@ get_gitea_token() {
_ident="$(git config --get mosaic.gitIdentity 2>/dev/null || true)"
_ident_src="git config mosaic.gitIdentity"
fi
if [[ -n "${MOSAIC_AGENT_NAME:-}" && -n "$_ident" && "$_ident" != "$MOSAIC_AGENT_NAME" ]]; then
printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=provider-identity-mismatch identity=%s fleet_identity=%s host=%s shared_path_entered=false source=%s\n' \
"$_ident" "$MOSAIC_AGENT_NAME" "$host" "$_ident_src" >&2
return 1
fi
if [[ -n "$_ident" ]]; then
local _idpfx=""
case "$host" in
@@ -32,6 +32,12 @@ done
ident="$MOSAIC_GIT_IDENTITY"
[ -z "$ident" ] && ident=$(git config --get mosaic.gitIdentity 2>/dev/null)
[ -z "$ident" ] && ident="$username_in"
if [ -n "${MOSAIC_AGENT_NAME:-}" ] && [ -n "$ident" ] && [ "$ident" != "$MOSAIC_AGENT_NAME" ]; then
echo "quit=true"
printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=provider-identity-mismatch identity=%s fleet_identity=%s host=%s shared_path_entered=false source=git-credential-mosaic\n' \
"$ident" "$MOSAIC_AGENT_NAME" "$host" >&2
exit 1
fi
if [ -n "$ident" ]; then
case "$host" in
git.uscllc.com) idpfx=gitea-usc;;
@@ -103,6 +103,15 @@ echo -n "agentA-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-to
out=$(run_helper "git.mosaicstack.dev" "agentA")
assert_eq "username-resolved identity: username" "username=agentA" "$(echo "$out" | grep '^username=')"
assert_eq "username-resolved identity: password" "password=agentA-mosaicstack-token" "$(echo "$out" | grep '^password=')"
set +e
out=$(run_helper "git.mosaicstack.dev" "agentA" MOSAIC_AGENT_NAME=agentB 2>"$WORK_DIR/fleet-mismatch.stderr")
rc=$?
set -e
err=$(cat "$WORK_DIR/fleet-mismatch.stderr")
if [[ "$rc" -eq 0 || "$out" != *"quit=true"* || "$err" != *"reason=provider-identity-mismatch"* || "$err" != *"shared_path_entered=false"* ]]; then
echo "FAIL: fleet identity override was not refused before token resolution" >&2
fail=1
fi
# ---------------------------------------------------------------------------
# 3. git config mosaic.gitIdentity (per-worktree) beats git-supplied username.
@@ -131,7 +140,7 @@ assert_eq "no per-slot token: username" "username=git" "$(echo "$out" | grep '^u
assert_eq "no per-slot token: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
set +e
out=$(run_helper "git.mosaicstack.dev" "no-such-agent" MOSAIC_AGENT_NAME=synthetic-seat 2>"$WORK_DIR/fleet-missing.stderr")
out=$(run_helper "git.mosaicstack.dev" "no-such-agent" MOSAIC_AGENT_NAME=no-such-agent 2>"$WORK_DIR/fleet-missing.stderr")
rc=$?
set -e
err=$(cat "$WORK_DIR/fleet-missing.stderr")
@@ -111,6 +111,15 @@ echo -n "agentA-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-to
git -C "$REPO_DIR" config mosaic.gitIdentity agentA
out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_AGENT_NAME=agentA)
assert_eq "confirmed fleet identity bypasses shared path" "agentA-mosaicstack-token" "$out"
set +e
out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_AGENT_NAME=agentA MOSAIC_GIT_IDENTITY=agentB 2>"$WORK_DIR/fleet-mismatch.stderr")
rc=$?
set -e
err=$(cat "$WORK_DIR/fleet-mismatch.stderr")
if [[ "$rc" -eq 0 || -n "$out" || "$err" != *"reason=provider-identity-mismatch"* || "$err" != *"shared_path_entered=false"* ]]; then
echo "FAIL: fleet identity override was not refused before token resolution" >&2
fail=1
fi
# ---------------------------------------------------------------------------
# 3. MOSAIC_GIT_IDENTITY env beats git config mosaic.gitIdentity.
+43 -7
View File
@@ -1,5 +1,5 @@
import { fstatSync, writeSync } from 'node:fs';
import { open, readFile, rename } from 'node:fs/promises';
import { open, rename } from 'node:fs/promises';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
import type { Command } from 'commander';
@@ -486,7 +486,7 @@ export async function executeCredentialRotate(
});
}
const context = await lifecycleContext(options);
const old = await context.store.readBinding(identity, options.estate, options.host);
const old = await context.store.snapshot(identity, options.estate, options.host);
if (old === undefined) {
return localLifecycleResult('rotate', identity, options, {
outcome: 'refused',
@@ -510,16 +510,17 @@ export async function executeCredentialRotate(
estate: options.estate,
host: options.host,
tokenName: options.tokenName,
scopes: (options.scopes ?? old.scopes.join(',')).split(',').filter(Boolean),
scopes: (options.scopes ?? old.binding.scopes.join(',')).split(',').filter(Boolean),
},
authority,
context.provider,
context.store,
context.teaStore,
{ stateRoot: context.stateRoot, actor: options.actor },
{ stateRoot: context.stateRoot, actor: options.actor, allowReplace: true },
);
if (provisioned.outcome !== 'ok') {
await journal.seal(provisioned.outcome, provisioned.reason.code);
old.secret.fill(0);
return {
...provisioned,
operation: 'rotate',
@@ -527,9 +528,32 @@ export async function executeCredentialRotate(
};
}
await journal.recordMutation('token-mint-applied');
await context.provider.revokeToken(authority, identity, old.tokenName);
await journal.recordMutation('token-revoke-applied');
try {
await context.provider.revokeToken(authority, identity, old.binding.tokenName);
if (await context.provider.tokenExists(authority, identity, old.binding.tokenName)) {
throw new Error('old token still exists');
}
await journal.recordMutation('token-revoke-applied');
} catch {
await context.provider.revokeToken(authority, identity, options.tokenName);
if (await context.provider.tokenExists(authority, identity, options.tokenName)) {
throw new Error('replacement rollback could not be verified');
}
await context.store.put(old.binding, old.secret);
await context.teaStore.put(identity, options.host, old.secret);
await journal.recordMutation('rotate-rollback-verified');
await journal.seal('indeterminate', 'old-credential-preserved');
old.secret.fill(0);
return localLifecycleResult('rotate', identity, options, {
outcome: 'indeterminate',
mutation: 'none',
code: 'old-credential-preserved',
message: 'Replacement failed; the previous credential remains the canonical binding.',
audit: { journalId: journal.journalId(), state: 'sealed' },
});
}
await journal.seal('ok', 'rotate-verified');
old.secret.fill(0);
return {
...provisioned,
operation: 'rotate',
@@ -571,7 +595,19 @@ export async function executeCredentialWire(
});
await journal.recordIntent('wire-requested');
try {
const existing = await readFile(options.seatEnv, 'utf8').catch((): string => '');
let existing = '';
try {
const snapshot = readRegularFileSecure(options.seatEnv, {
root: dirname(options.seatEnv),
maxBytes: 1024 * 1024,
});
if ((snapshot.mode & 0o022) !== 0 || snapshot.uid !== process.getuid?.()) {
throw new Error('seat environment ownership or mode is unsafe');
}
existing = snapshot.content.toString('utf8');
} catch (error: unknown) {
if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error;
}
const lines = existing
.split(/\r?\n/)
.filter(
@@ -48,6 +48,8 @@ const SAFE_DECISIONS = new Set<string>([
'token-mint-applied',
'token-binding-stored',
'tea-login-stored',
'provision-rollback-verified',
'rotate-rollback-verified',
'token-revoke-applied',
'wire-applied',
'credential-issued',
@@ -7,4 +7,5 @@ export interface CredentialBindingMetadataDto {
readonly tokenName: string;
readonly scopes: readonly string[];
readonly createdAt: string;
readonly tokenDigest?: string;
}
@@ -1,4 +1,4 @@
import { randomUUID } from 'node:crypto';
import { createHash, randomUUID } from 'node:crypto';
import { lstatSync } from 'node:fs';
import { open, readdir, rename, unlink } from 'node:fs/promises';
import { join } from 'node:path';
@@ -24,6 +24,10 @@ const bindingSchema = z
tokenName: z.string().regex(IDENTITY),
scopes: z.array(z.string().regex(/^[a-z]+(?::[a-z]+)?$/)).max(32),
createdAt: z.string().datetime(),
tokenDigest: z
.string()
.regex(/^[a-f0-9]{64}$/)
.optional(),
})
.strict();
@@ -110,6 +114,28 @@ export class FileCredentialResolver implements CredentialResolver {
throw error;
}
const bindingPath = join(
this.tokenDirectory,
`${hostConfig.tokenPrefix}-${identity}.binding.json`,
);
try {
const bindingSnapshot = readRegularFileSecure(bindingPath, {
root: this.tokenDirectory,
maxBytes: 64 * 1024,
});
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) {
throw new CredentialStoreError(
'credential-generation-mismatch',
'token and binding metadata are not one committed generation',
);
}
} catch (error: unknown) {
if (!isMissingFile(error)) throw error;
// Legacy token files predate binding metadata and remain readable until rotated.
}
const permissions = snapshot.mode & 0o777;
if (snapshot.uid !== currentUid) {
throw new CredentialStoreError(
@@ -180,10 +206,24 @@ export class FileCredentialStore {
const paths = this.paths(metadata.identity, metadata.estate, metadata.host);
ensureManagedDirectory(this.tokenDirectory, this.tokenDirectory);
const token = validateSecret(Buffer.from(secret));
const binding = bindingSchema.parse({ ...metadata, schemaVersion: 1 });
const binding = bindingSchema.parse({
...metadata,
schemaVersion: 1,
tokenDigest: createHash('sha256').update(token).digest('hex'),
});
const suffix = randomUUID();
const tokenTemp = `${paths.token}.${suffix}.tmp`;
const bindingTemp = `${paths.binding}.${suffix}.tmp`;
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',
);
}
const tokenHandle = await open(tokenTemp, 'wx', 0o600);
const bindingHandle = await open(bindingTemp, 'wx', 0o600);
try {
@@ -195,9 +235,41 @@ export class FileCredentialStore {
await tokenHandle.close();
await bindingHandle.close();
}
await rename(bindingTemp, paths.binding);
await rename(tokenTemp, paths.token);
await syncDirectory(this.tokenDirectory);
try {
await rename(bindingTemp, paths.binding);
await rename(tokenTemp, paths.token);
await syncDirectory(this.tokenDirectory);
} finally {
await lock.close();
await unlink(lockPath).catch((): void => undefined);
await syncDirectory(this.tokenDirectory);
}
}
async snapshot(
identity: string,
estate: string,
host: string,
): Promise<
| {
readonly binding: CredentialBindingMetadataDto;
readonly secret: Uint8Array;
}
| undefined
> {
const binding = await this.readBinding(identity, estate, host);
if (binding === undefined) return undefined;
const resolved = await new FileCredentialResolver(
this.tokenDirectory,
this.estateRegistry,
).resolve(identity, estate, host);
if (resolved === undefined) {
throw new CredentialStoreError(
'invalid-binding',
'binding metadata exists without its token generation',
);
}
return { binding, secret: new Uint8Array(resolved.secret) };
}
async readBinding(
@@ -812,6 +812,39 @@ export class GiteaLifecycleProviderAdapter
};
}
async tokenExists(
authority: ResolvedCredential,
identity: string,
name: string,
): Promise<boolean> {
const response = await this.request(
`${this.origin}/api/v1/users/${encodeURIComponent(identity)}/tokens`,
{
method: 'GET',
headers: {
Accept: JSON_CONTENT_TYPE,
Authorization: basicAuthorization(authority),
'User-Agent': USER_AGENT,
},
},
);
if (!response.ok) {
await boundedBody(response);
throw new CredentialProviderEvidenceError(
'readback-missing',
'token absence read-back failed',
);
}
const parsed = z.array(tokenObjectSchema).safeParse(await jsonObject(response));
if (!parsed.success) {
throw new CredentialProviderEvidenceError(
'unexpected-provider-shape',
'token absence read-back was malformed',
);
}
return parsed.data.some((token): boolean => token.name === name);
}
async revokeToken(authority: ResolvedCredential, identity: string, name: string): Promise<void> {
const response = await this.request(
`${this.origin}/api/v1/users/${encodeURIComponent(identity)}/tokens/${encodeURIComponent(name)}`,
@@ -81,6 +81,9 @@ function provider(): GiteaLifecycleProvider {
};
},
async revokeToken(): Promise<void> {},
async tokenExists(): Promise<boolean> {
return false;
},
};
}
@@ -108,6 +111,42 @@ describe('credential lifecycle', (): void => {
expect(JSON.stringify(result)).not.toContain('minted-token-canary');
});
it('rolls back a minted token when exact scope read-back disagrees', async (): Promise<void> => {
const { root, store, teaStore } = await fixture();
let revoked = false;
const lifecycleProvider = provider();
lifecycleProvider.readToken = async (_authority, _identity, name) => ({
name,
scopes: ['admin'],
endpoint: 'GET /api/v1/users/seat/tokens',
contentType: 'application/json',
});
lifecycleProvider.revokeToken = async (): Promise<void> => {
revoked = true;
};
lifecycleProvider.tokenExists = async (): Promise<boolean> => false;
const result = await provisionCredential(
{
identity: 'seat',
estate: 'homelab',
host: 'git.example.invalid',
tokenName: 'mosaic-seat-bad',
scopes: ['write:repository'],
},
authority,
lifecycleProvider,
store,
teaStore,
{ stateRoot: root, actor: 'seat' },
);
expect(result.outcome).toBe('error');
expect(result.mutation).toBe('none');
expect(revoked).toBe(true);
await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual([]);
});
it('revokes at provider before removing the local binding', async (): Promise<void> => {
const { root, store, teaStore } = await fixture();
await provisionCredential(
@@ -140,4 +179,34 @@ describe('credential lifecycle', (): void => {
expect(revoked).toBe(true);
await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual([]);
});
it('preserves the local recovery binding when provider revocation read-back still finds the token', async (): Promise<void> => {
const { root, store, teaStore } = await fixture();
await provisionCredential(
{
identity: 'seat',
estate: 'homelab',
host: 'git.example.invalid',
tokenName: 'mosaic-seat-1',
scopes: ['write:repository'],
},
authority,
provider(),
store,
teaStore,
{ stateRoot: root, actor: 'seat' },
);
const lifecycleProvider = provider();
lifecycleProvider.tokenExists = async (): Promise<boolean> => true;
const result = await revokeCredential(
{ identity: 'seat', estate: 'homelab', host: 'git.example.invalid' },
authority,
lifecycleProvider,
store,
{ stateRoot: root, actor: 'seat' },
);
expect(result.outcome).toBe('indeterminate');
await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual(['seat']);
});
});
+73 -28
View File
@@ -31,6 +31,7 @@ export interface GiteaLifecycleProvider {
name: string,
): Promise<TokenObjectEvidenceDto>;
revokeToken(authority: ResolvedCredential, identity: string, name: string): Promise<void>;
tokenExists(authority: ResolvedCredential, identity: string, name: string): Promise<boolean>;
}
export interface LifecycleRequest {
@@ -48,6 +49,7 @@ export interface LifecycleOptions {
readonly stateRoot: string;
readonly actor: string;
readonly now?: () => string;
readonly allowReplace?: boolean;
}
function lifecycleResult(
@@ -114,6 +116,19 @@ export async function provisionCredential(
const journal = await openLifecycleJournal('provision', request, options);
let mutation: 'none' | 'unknown' | 'applied' = 'none';
let minted: MintedToken | undefined;
let failureCode = 'mutation-state-unknown';
const prior = await store.snapshot(request.identity, request.estate, request.host);
if (prior !== undefined && options.allowReplace !== true) {
await journal.seal('refused', 'credential-already-exists');
return lifecycleResult('provision', request, {
outcome: 'refused',
mutation: 'none',
code: 'credential-already-exists',
message: 'A governed credential already exists; use rotate.',
journalId: journal.journalId(),
auditState: 'sealed',
});
}
try {
const identity = await provider.readBasicIdentity(authority);
if (identity.login !== request.identity || authority.identity !== request.identity) {
@@ -146,17 +161,8 @@ export async function provisionCredential(
const expected = [...request.scopes].sort();
const actual = [...readBack.scopes].sort();
if (JSON.stringify(expected) !== JSON.stringify(actual)) {
await journal.seal('indeterminate', 'scope-not-evaluable');
return lifecycleResult('provision', request, {
outcome: 'indeterminate',
mutation,
code: 'scope-not-evaluable',
message: 'Minted token scope read-back disagreed with the request.',
journalId: journal.journalId(),
auditState: 'sealed',
providerIdentity: identity.login,
token: readBack,
});
failureCode = 'scope-not-evaluable';
throw new Error('scope read-back disagreed');
}
await store.put(
{
@@ -173,18 +179,12 @@ export async function provisionCredential(
await journal.recordMutation('token-binding-stored');
await teaStore.put(request.identity, request.host, minted.secret);
const teaLogin = teaStore.readBack(request.identity, request.host);
if (teaLogin === undefined) {
await journal.seal('indeterminate', 'tea-login-missing');
return lifecycleResult('provision', request, {
outcome: 'indeterminate',
mutation: 'applied',
code: 'tea-login-missing',
message: 'Token was stored but the host-bound Tea login did not read back.',
journalId: journal.journalId(),
auditState: 'sealed',
providerIdentity: identity.login,
token: readBack,
});
if (
teaLogin === undefined ||
!teaStore.matchesSecret(request.identity, request.host, minted.secret)
) {
failureCode = 'tea-login-missing';
throw new Error('Tea login did not resolve exactly');
}
await journal.recordMutation('tea-login-stored');
await journal.recordProviderEvidence({
@@ -206,18 +206,52 @@ export async function provisionCredential(
});
} catch (error: unknown) {
if (error instanceof CredentialJournalError) throw error;
const code = mutation === 'none' ? 'provider-unavailable' : 'mutation-state-unknown';
await journal.seal('indeterminate', code);
if (minted === undefined) {
await journal.seal('indeterminate', 'provider-unavailable');
return lifecycleResult('provision', request, {
outcome: 'indeterminate',
mutation,
code: 'provider-unavailable',
message: 'Provider token mint did not complete.',
journalId: journal.journalId(),
auditState: 'sealed',
});
}
let rollbackComplete = false;
try {
await provider.revokeToken(authority, request.identity, request.tokenName);
if (await provider.tokenExists(authority, request.identity, request.tokenName)) {
throw new Error('minted token still exists after rollback');
}
if (prior === undefined) {
await store.remove(request.identity, request.estate, request.host);
await teaStore.remove(request.identity, request.host).catch((): void => undefined);
} else {
await store.put(prior.binding, prior.secret);
await teaStore.put(request.identity, request.host, prior.secret);
}
rollbackComplete = true;
await journal.recordMutation('provision-rollback-verified');
} catch {
rollbackComplete = false;
} finally {
prior?.secret.fill(0);
}
const code = rollbackComplete ? failureCode : 'rollback-incomplete';
await journal.seal(rollbackComplete ? 'error' : 'indeterminate', code);
return lifecycleResult('provision', request, {
outcome: 'indeterminate',
mutation,
outcome: rollbackComplete ? 'error' : 'indeterminate',
mutation: rollbackComplete ? 'none' : mutation,
code,
message: 'Provisioning did not produce complete provider and storage evidence.',
message: rollbackComplete
? 'Provisioning failed and every completed mutation was rolled back.'
: 'Provisioning failed and rollback could not be proven complete.',
journalId: journal.journalId(),
auditState: 'sealed',
});
} finally {
minted?.secret.fill(0);
prior?.secret.fill(0);
}
}
@@ -247,6 +281,17 @@ export async function revokeCredential(
await provider.revokeToken(authority, request.identity, binding.tokenName);
mutation = 'applied';
await journal.recordMutation('token-revoke-applied');
if (await provider.tokenExists(authority, request.identity, binding.tokenName)) {
await journal.seal('indeterminate', 'revoke-readback-missing');
return lifecycleResult('revoke', request, {
outcome: 'indeterminate',
mutation,
code: 'revoke-readback-missing',
message: 'Provider still returned the token after revocation acknowledgement.',
journalId: journal.journalId(),
auditState: 'sealed',
});
}
await store.remove(request.identity, request.estate, request.host);
await journal.seal('ok', 'revoke-verified');
return lifecycleResult('revoke', request, {
@@ -1,9 +1,10 @@
import { randomUUID } from 'node:crypto';
import { randomUUID, timingSafeEqual } from 'node:crypto';
import { open, rename } from 'node:fs/promises';
import { dirname } from 'node:path';
import { parse, stringify } from 'yaml';
import { z } from 'zod';
import { ensureManagedDirectory, readRegularFileSecure } from '../fleet/secure-file.js';
import type { ResolvedCredential } from './credential-provider.dto.js';
const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
@@ -91,6 +92,64 @@ export class TeaLoginStore {
await rename(temp, this.configPath);
}
resolve(identity: string, estate: string, host: string): ResolvedCredential | undefined {
const directory = dirname(this.configPath);
let snapshot;
try {
snapshot = readRegularFileSecure(this.configPath, {
root: directory,
maxBytes: 1024 * 1024,
});
} catch (error: unknown) {
if (missing(error)) return undefined;
throw error;
}
const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8')));
if (!decoded.success) return undefined;
const matches = decoded.data.logins.filter(
(login): boolean =>
login.name === identity && login.url === `https://${host}` && login.user === identity,
);
if (matches.length !== 1 || matches[0] === undefined) return undefined;
return Object.freeze({
identity,
estate,
host,
resolutionId: randomUUID(),
secret: new TextEncoder().encode(matches[0].token),
});
}
matchesSecret(identity: string, host: string, secret: Uint8Array): boolean {
const resolved = this.resolve(identity, 'binding-check', host);
if (resolved === undefined || resolved.secret.byteLength !== secret.byteLength) return false;
return timingSafeEqual(Buffer.from(resolved.secret), Buffer.from(secret));
}
async remove(identity: string, host: string): Promise<void> {
const directory = dirname(this.configPath);
const snapshot = readRegularFileSecure(this.configPath, {
root: directory,
maxBytes: 1024 * 1024,
});
const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8')));
if (!decoded.success) {
throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation');
}
const logins = decoded.data.logins.filter(
(login): boolean => !(login.name === identity && login.url === `https://${host}`),
);
const temp = `${this.configPath}.${randomUUID()}.tmp`;
const handle = await open(temp, 'wx', 0o600);
try {
await handle.writeFile(stringify({ logins }), 'utf8');
await handle.sync();
} finally {
await handle.close();
}
await rename(temp, this.configPath);
}
readBack(
identity: string,
host: string,