From d18e49e8f421da8997c225ab1302b432455aeb6a Mon Sep 17 00:00:00 2001 From: be-coder-06 Date: Wed, 5 Aug 2026 12:57:19 -0500 Subject: [PATCH] fix(mosaic): bind lifecycle across storage and runtime --- .../framework/tools/git/detect-platform.sh | 22 +++ .../framework/tools/git/git-credential-mosaic | 22 +++ packages/mosaic/src/commands/cred.ts | 15 ++ .../mosaic/src/credentials/audit-journal.ts | 2 + .../src/credentials/file-credential-store.ts | 119 +++++++++++++--- .../mosaic/src/credentials/lifecycle.spec.ts | 2 + packages/mosaic/src/credentials/lifecycle.ts | 6 + .../src/credentials/tea-login-store.spec.ts | 35 +++++ .../mosaic/src/credentials/tea-login-store.ts | 131 +++++++++++------- 9 files changed, 286 insertions(+), 68 deletions(-) create mode 100644 packages/mosaic/src/credentials/tea-login-store.spec.ts diff --git a/packages/mosaic/framework/tools/git/detect-platform.sh b/packages/mosaic/framework/tools/git/detect-platform.sh index 01b6f0c6..32b2cca4 100755 --- a/packages/mosaic/framework/tools/git/detect-platform.sh +++ b/packages/mosaic/framework/tools/git/detect-platform.sh @@ -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 diff --git a/packages/mosaic/framework/tools/git/git-credential-mosaic b/packages/mosaic/framework/tools/git/git-credential-mosaic index a1a1048c..95e031c9 100755 --- a/packages/mosaic/framework/tools/git/git-credential-mosaic +++ b/packages/mosaic/framework/tools/git/git-credential-mosaic @@ -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 diff --git a/packages/mosaic/src/commands/cred.ts b/packages/mosaic/src/commands/cred.ts index 210eab98..318dacc5 100644 --- a/packages/mosaic/src/commands/cred.ts +++ b/packages/mosaic/src/commands/cred.ts @@ -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')); diff --git a/packages/mosaic/src/credentials/audit-journal.ts b/packages/mosaic/src/credentials/audit-journal.ts index 71f75da1..ca85f923 100644 --- a/packages/mosaic/src/credentials/audit-journal.ts +++ b/packages/mosaic/src/credentials/audit-journal.ts @@ -48,10 +48,12 @@ const SAFE_DECISIONS = new Set([ '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', ]); diff --git a/packages/mosaic/src/credentials/file-credential-store.ts b/packages/mosaic/src/credentials/file-credential-store.ts index 9f0cb93c..fda7d725 100644 --- a/packages/mosaic/src/credentials/file-credential-store.ts +++ b/packages/mosaic/src/credentials/file-credential-store.ts @@ -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 { 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); } } diff --git a/packages/mosaic/src/credentials/lifecycle.spec.ts b/packages/mosaic/src/credentials/lifecycle.spec.ts index a695cf17..858b5c40 100644 --- a/packages/mosaic/src/credentials/lifecycle.spec.ts +++ b/packages/mosaic/src/credentials/lifecycle.spec.ts @@ -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' }, ); diff --git a/packages/mosaic/src/credentials/lifecycle.ts b/packages/mosaic/src/credentials/lifecycle.ts index 3148e51b..762efd48 100644 --- a/packages/mosaic/src/credentials/lifecycle.ts +++ b/packages/mosaic/src/credentials/lifecycle.ts @@ -260,6 +260,7 @@ export async function revokeCredential( authority: ResolvedCredential, provider: GiteaLifecycleProvider, store: FileCredentialStore, + teaStore: TeaLoginStore, options: LifecycleOptions, ): Promise { 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, { diff --git a/packages/mosaic/src/credentials/tea-login-store.spec.ts b/packages/mosaic/src/credentials/tea-login-store.spec.ts new file mode 100644 index 00000000..6ab225cd --- /dev/null +++ b/packages/mosaic/src/credentials/tea-login-store.spec.ts @@ -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 => { + 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 => { + 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', + }); + }); +}); diff --git a/packages/mosaic/src/credentials/tea-login-store.ts b/packages/mosaic/src/credentials/tea-login-store.ts index 67cb3ee4..88173184 100644 --- a/packages/mosaic/src/credentials/tea-login-store.ts +++ b/packages/mosaic/src/credentials/tea-login-store.ts @@ -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>> { + 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((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 { 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(