fix(mosaic): bind lifecycle across storage and runtime

This commit is contained in:
2026-08-05 13:11:04 -05:00
committed by Hermes Agent
parent f705800353
commit 59cde3d199
9 changed files with 286 additions and 68 deletions
@@ -529,7 +529,29 @@ get_gitea_token() {
esac
if [[ -n "$_idpfx" ]]; then
local _idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.token"
local _idcred="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.credential.json"
if [[ -r "$_idcred" ]]; then
local _resolved_token
_resolved_token=$(python3 - "$_idcred" <<'PY'
import json, sys
value = json.load(open(sys.argv[1], encoding="utf-8")).get("token")
if not isinstance(value, str) or not value or any(ch.isspace() for ch in value):
raise SystemExit(1)
print(value)
PY
) || return 1
if [[ "${MOSAIC_CREDENTIAL_TRACE:-}" == 1 ]]; then
printf 'MOSAIC_CREDENTIAL_RESOLUTION outcome=ok reason=credential-resolved identity=%s host=%s shared_path_entered=false source=%s\n' \
"$_ident" "$host" "$_ident_src" >&2
fi
printf '%s\n' "$_resolved_token"
return 0
fi
if [[ -r "$_idtok" ]]; then
if [[ "${MOSAIC_CREDENTIAL_TRACE:-}" == 1 ]]; then
printf 'MOSAIC_CREDENTIAL_RESOLUTION outcome=ok reason=credential-resolved identity=%s host=%s shared_path_entered=false source=%s\n' \
"$_ident" "$host" "$_ident_src" >&2
fi
cat "$_idtok"
return 0
fi
@@ -46,7 +46,29 @@ if [ -n "$ident" ]; then
esac
if [ -n "$idpfx" ]; then
idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.token"
idcred="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.credential.json"
if [ -r "$idcred" ]; then
token=$(python3 - "$idcred" <<'PY'
import json, sys
value = json.load(open(sys.argv[1], encoding="utf-8")).get("token")
if not isinstance(value, str) or not value or any(ch.isspace() for ch in value):
raise SystemExit(1)
print(value)
PY
) || exit 1
if [ "${MOSAIC_CREDENTIAL_TRACE:-}" = 1 ]; then
printf 'MOSAIC_CREDENTIAL_RESOLUTION outcome=ok reason=credential-resolved identity=%s host=%s shared_path_entered=false source=git-credential-mosaic\n' \
"$ident" "$host" >&2
fi
echo "username=${ident}"
echo "password=${token}"
exit 0
fi
if [ -r "$idtok" ]; then
if [ "${MOSAIC_CREDENTIAL_TRACE:-}" = 1 ]; then
printf 'MOSAIC_CREDENTIAL_RESOLUTION outcome=ok reason=credential-resolved identity=%s host=%s shared_path_entered=false source=git-credential-mosaic\n' \
"$ident" "$host" >&2
fi
echo "username=${ident}"
echo "password=$(cat "$idtok")"
exit 0
+15
View File
@@ -462,6 +462,7 @@ export async function executeCredentialRevoke(
authority,
context.provider,
context.store,
context.teaStore,
{ stateRoot: context.stateRoot, actor: options.actor },
);
} catch {
@@ -660,6 +661,19 @@ export async function executeCredentialGet(
});
await journal.recordIntent('get-requested');
try {
if (
process.env['MOSAIC_AGENT_NAME'] === undefined ||
process.env['MOSAIC_AGENT_NAME'] !== identity ||
options.actor !== identity
) {
await journal.seal('refused', 'provider-identity-mismatch');
return localLifecycleResult('get', identity, options, {
outcome: 'refused',
code: 'provider-identity-mismatch',
message: 'Runtime fleet identity, explicit actor, and requested identity must match.',
audit: { journalId: journal.journalId(), state: 'sealed' },
});
}
const fd = Number(options.outputFd);
const stat = fstatSync(fd);
if (
@@ -687,6 +701,7 @@ export async function executeCredentialGet(
const prefix = new TextEncoder().encode(
`protocol=https\nhost=${options.host}\nusername=${identity}\npassword=`,
);
await journal.recordMutation('credential-issuance-authorized');
writeSync(fd, prefix);
writeSync(fd, resolved.secret);
writeSync(fd, new TextEncoder().encode('\n\n'));
@@ -48,10 +48,12 @@ const SAFE_DECISIONS = new Set<string>([
'token-mint-applied',
'token-binding-stored',
'tea-login-stored',
'tea-login-removed',
'provision-rollback-verified',
'rotate-rollback-verified',
'token-revoke-applied',
'wire-applied',
'credential-issuance-authorized',
'credential-issued',
'classification-correction',
]);
@@ -30,6 +30,9 @@ const bindingSchema = z
.optional(),
})
.strict();
const credentialEnvelopeSchema = bindingSchema.extend({
token: z.string().min(1).max(MAX_TOKEN_BYTES).regex(/^\S+$/),
});
export class CredentialStoreError extends Error {
constructor(
@@ -102,6 +105,47 @@ export class FileCredentialResolver implements CredentialResolver {
);
}
const envelopePath = join(
this.tokenDirectory,
`${hostConfig.tokenPrefix}-${identity}.credential.json`,
);
try {
const envelopeSnapshot = readRegularFileSecure(envelopePath, {
root: this.tokenDirectory,
maxBytes: 64 * 1024,
});
const envelope = credentialEnvelopeSchema.safeParse(
JSON.parse(envelopeSnapshot.content.toString('utf8')),
);
if (
!envelope.success ||
envelopeSnapshot.uid !== process.getuid?.() ||
(envelopeSnapshot.mode & 0o077) !== 0
) {
throw new CredentialStoreError(
'invalid-binding',
'credential envelope failed schema, owner, or mode validation',
);
}
const secret = validateSecret(Buffer.from(envelope.data.token, 'utf8'));
const digest = createHash('sha256').update(secret).digest('hex');
if (envelope.data.tokenDigest !== digest) {
throw new CredentialStoreError(
'credential-generation-mismatch',
'credential envelope digest does not match its token',
);
}
return Object.freeze({
identity,
estate,
host,
resolutionId: randomUUID(),
secret,
});
} catch (error: unknown) {
if (!isMissingFile(error)) throw error;
}
const path = join(this.tokenDirectory, `${hostConfig.tokenPrefix}-${identity}.token`);
let snapshot: SecureFileSnapshot;
try {
@@ -182,6 +226,7 @@ export class FileCredentialStore {
): {
readonly token: string;
readonly binding: string;
readonly envelope: string;
readonly prefix: string;
} {
if (!IDENTITY.test(identity)) {
@@ -198,6 +243,7 @@ export class FileCredentialStore {
return {
token: join(this.tokenDirectory, `${prefix}.token`),
binding: join(this.tokenDirectory, `${prefix}.binding.json`),
envelope: join(this.tokenDirectory, `${prefix}.credential.json`),
prefix,
};
}
@@ -206,14 +252,14 @@ 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({
const envelope = credentialEnvelopeSchema.parse({
...metadata,
schemaVersion: 1,
tokenDigest: createHash('sha256').update(token).digest('hex'),
token: Buffer.from(token).toString('utf8'),
});
const suffix = randomUUID();
const tokenTemp = `${paths.token}.${suffix}.tmp`;
const bindingTemp = `${paths.binding}.${suffix}.tmp`;
const envelopeTemp = `${paths.envelope}.${suffix}.tmp`;
const lockPath = join(this.tokenDirectory, `${paths.prefix}.lock`);
let lock;
try {
@@ -224,23 +270,19 @@ export class FileCredentialStore {
'another mutation owns the identity lock',
);
}
const tokenHandle = await open(tokenTemp, 'wx', 0o600);
const bindingHandle = await open(bindingTemp, 'wx', 0o600);
try {
await tokenHandle.writeFile(token);
await tokenHandle.sync();
await bindingHandle.writeFile(`${JSON.stringify(binding)}\n`, 'utf8');
await bindingHandle.sync();
} finally {
await tokenHandle.close();
await bindingHandle.close();
}
try {
await rename(bindingTemp, paths.binding);
await rename(tokenTemp, paths.token);
const handle = await open(envelopeTemp, 'wx', 0o600);
try {
await handle.writeFile(`${JSON.stringify(envelope)}\n`, 'utf8');
await handle.sync();
} finally {
await handle.close();
}
await rename(envelopeTemp, paths.envelope);
await syncDirectory(this.tokenDirectory);
} finally {
await lock.close();
await unlink(envelopeTemp).catch((): void => undefined);
await unlink(lockPath).catch((): void => undefined);
await syncDirectory(this.tokenDirectory);
}
@@ -279,6 +321,31 @@ export class FileCredentialStore {
): Promise<CredentialBindingMetadataDto | undefined> {
const paths = this.paths(identity, estate, host);
let snapshot: SecureFileSnapshot;
try {
const envelope = readRegularFileSecure(paths.envelope, {
root: this.tokenDirectory,
maxBytes: 64 * 1024,
});
const parsed = credentialEnvelopeSchema.safeParse(
JSON.parse(envelope.content.toString('utf8')),
);
if (!parsed.success) {
throw new CredentialStoreError('invalid-binding', 'credential envelope is malformed');
}
return {
schemaVersion: 1,
identity: parsed.data.identity,
estate: parsed.data.estate,
host: parsed.data.host,
providerLogin: parsed.data.providerLogin,
tokenName: parsed.data.tokenName,
scopes: parsed.data.scopes,
createdAt: parsed.data.createdAt,
tokenDigest: parsed.data.tokenDigest,
};
} catch (error: unknown) {
if (!isMissingFile(error)) throw error;
}
try {
snapshot = readRegularFileSecure(paths.binding, {
root: this.tokenDirectory,
@@ -306,9 +373,20 @@ export class FileCredentialStore {
if (config === undefined) return [];
const names = await readdir(this.tokenDirectory);
const prefix = `${config.tokenPrefix}-`;
return names
.filter((name): boolean => name.startsWith(prefix) && name.endsWith('.token'))
.map((name): string => name.slice(prefix.length, -'.token'.length))
return [
...new Set(
names.flatMap((name): string[] => {
if (!name.startsWith(prefix)) return [];
if (name.endsWith('.token')) {
return [name.slice(prefix.length, -'.token'.length)];
}
if (name.endsWith('.credential.json')) {
return [name.slice(prefix.length, -'.credential.json'.length)];
}
return [];
}),
),
]
.filter((identity): boolean => IDENTITY.test(identity))
.sort();
}
@@ -321,6 +399,9 @@ export class FileCredentialStore {
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);
}
}
@@ -173,6 +173,7 @@ describe('credential lifecycle', (): void => {
authority,
lifecycleProvider,
store,
teaStore,
{ stateRoot: root, actor: 'seat' },
);
expect(result.outcome).toBe('ok');
@@ -203,6 +204,7 @@ describe('credential lifecycle', (): void => {
authority,
lifecycleProvider,
store,
teaStore,
{ stateRoot: root, actor: 'seat' },
);
@@ -260,6 +260,7 @@ export async function revokeCredential(
authority: ResolvedCredential,
provider: GiteaLifecycleProvider,
store: FileCredentialStore,
teaStore: TeaLoginStore,
options: LifecycleOptions,
): Promise<CredentialLifecycleResultDto> {
const journal = await openLifecycleJournal('revoke', request, options);
@@ -292,6 +293,11 @@ export async function revokeCredential(
auditState: 'sealed',
});
}
await teaStore.remove(request.identity, request.host);
if (teaStore.readBack(request.identity, request.host) !== undefined) {
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 journal.seal('ok', 'revoke-verified');
return lifecycleResult('revoke', request, {
@@ -0,0 +1,35 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { TeaLoginStore } from './tea-login-store.js';
let root: string | undefined;
afterEach(async (): Promise<void> => {
if (root !== undefined) await rm(root, { recursive: true, force: true });
root = undefined;
});
describe('host-bound Tea login store', (): void => {
it('serializes concurrent updates and preserves the same identity on two hosts', async (): Promise<void> => {
root = await mkdtemp(join(tmpdir(), 'mosaic-tea-store-'));
const store = new TeaLoginStore(join(root, 'tea', 'config.yml'));
await Promise.all([
store.put('seat', 'git.one.invalid', new TextEncoder().encode('token-one')),
store.put('seat', 'git.two.invalid', new TextEncoder().encode('token-two')),
]);
expect(
store.matchesSecret('seat', 'git.one.invalid', new TextEncoder().encode('token-one')),
).toBe(true);
expect(
store.matchesSecret('seat', 'git.two.invalid', new TextEncoder().encode('token-two')),
).toBe(true);
await store.remove('seat', 'git.one.invalid');
expect(store.readBack('seat', 'git.one.invalid')).toBeUndefined();
expect(store.readBack('seat', 'git.two.invalid')).toEqual({
name: 'seat',
host: 'git.two.invalid',
});
});
});
@@ -1,5 +1,5 @@
import { randomUUID, timingSafeEqual } from 'node:crypto';
import { open, rename } from 'node:fs/promises';
import { open, rename, unlink } from 'node:fs/promises';
import { dirname } from 'node:path';
import { parse, stringify } from 'yaml';
import { z } from 'zod';
@@ -41,6 +41,23 @@ const loginSchema = z
.passthrough();
const configSchema = z.object({ logins: z.array(loginSchema).default([]) }).passthrough();
async function acquireLock(path: string): Promise<Awaited<ReturnType<typeof open>>> {
for (let attempt = 0; attempt < 500; attempt += 1) {
try {
return await open(path, 'wx', 0o600);
} catch (error: unknown) {
if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) throw error;
await new Promise<void>((resolve): void => {
setTimeout(resolve, 10);
});
}
}
throw new TeaLoginStoreError(
'conflicting-credential-mutation',
'Tea configuration lock did not become available',
);
}
function missing(error: unknown): boolean {
return error instanceof Error && 'code' in error && error.code === 'ENOENT';
}
@@ -54,42 +71,51 @@ export class TeaLoginStore {
}
const directory = dirname(this.configPath);
ensureManagedDirectory(directory, directory);
let current: TeaConfig = { logins: [] };
const lockPath = `${this.configPath}.lock`;
const lock = await acquireLock(lockPath);
try {
const snapshot = readRegularFileSecure(this.configPath, {
root: directory,
maxBytes: 1024 * 1024,
});
if ((snapshot.mode & 0o077) !== 0 || snapshot.uid !== process.getuid?.()) {
throw new TeaLoginStoreError('tea-config-insecure', 'Tea config is not private');
let current: TeaConfig = { logins: [] };
try {
const snapshot = readRegularFileSecure(this.configPath, {
root: directory,
maxBytes: 1024 * 1024,
});
if ((snapshot.mode & 0o077) !== 0 || snapshot.uid !== process.getuid?.()) {
throw new TeaLoginStoreError('tea-config-insecure', 'Tea config is not private');
}
const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8')));
if (!decoded.success) {
throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation');
}
current = { logins: decoded.data.logins };
} catch (error: unknown) {
if (!missing(error)) throw error;
}
const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8')));
if (!decoded.success) {
throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation');
const token = Buffer.from(secret).toString('utf8');
const record: TeaLoginRecord = {
name: identity,
url: `https://${host}`,
token,
user: identity,
default: false,
};
const logins = current.logins.filter(
(login): boolean => !(login.name === identity && login.url === `https://${host}`),
);
logins.push(record);
const temp = `${this.configPath}.${randomUUID()}.tmp`;
const handle = await open(temp, 'wx', 0o600);
try {
await handle.writeFile(stringify({ ...current, logins }), 'utf8');
await handle.sync();
} finally {
await handle.close();
}
current = { logins: decoded.data.logins };
} catch (error: unknown) {
if (!missing(error)) throw error;
}
const token = Buffer.from(secret).toString('utf8');
const record: TeaLoginRecord = {
name: identity,
url: `https://${host}`,
token,
user: identity,
default: false,
};
const logins = current.logins.filter((login): boolean => login.name !== identity);
logins.push(record);
const temp = `${this.configPath}.${randomUUID()}.tmp`;
const handle = await open(temp, 'wx', 0o600);
try {
await handle.writeFile(stringify({ ...current, logins }), 'utf8');
await handle.sync();
await rename(temp, this.configPath);
} finally {
await handle.close();
await lock.close();
await unlink(lockPath).catch((): void => undefined);
}
await rename(temp, this.configPath);
}
resolve(identity: string, estate: string, host: string): ResolvedCredential | undefined {
@@ -128,26 +154,33 @@ export class TeaLoginStore {
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);
const lockPath = `${this.configPath}.lock`;
const lock = await acquireLock(lockPath);
try {
await handle.writeFile(stringify({ logins }), 'utf8');
await handle.sync();
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);
} finally {
await handle.close();
await lock.close();
await unlink(lockPath).catch((): void => undefined);
}
await rename(temp, this.configPath);
}
readBack(