diff --git a/packages/mosaic/framework/tools/git/detect-platform.sh b/packages/mosaic/framework/tools/git/detect-platform.sh index 0048f682..ff86ef84 100755 --- a/packages/mosaic/framework/tools/git/detect-platform.sh +++ b/packages/mosaic/framework/tools/git/detect-platform.sh @@ -539,7 +539,7 @@ get_gitea_token() { 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 + if [[ -e "$_idcred" || -L "$_idcred" ]]; then local _resolved_token _resolved_token=$(python3 "$script_dir/resolve-credential-envelope.py" \ "$_idcred" "$_ident" "${MOSAIC_CREDENTIAL_ESTATE:-}" "$host") || return 1 diff --git a/packages/mosaic/framework/tools/git/git-credential-mosaic b/packages/mosaic/framework/tools/git/git-credential-mosaic index 4ad2d975..348ff3dd 100755 --- a/packages/mosaic/framework/tools/git/git-credential-mosaic +++ b/packages/mosaic/framework/tools/git/git-credential-mosaic @@ -38,25 +38,25 @@ trace_resolution() { # survives across non-persistent shells) > git-supplied username (credential.username # / URL). When the resolved identity has a matching per-agent token, use it instead of # the shared account. Backward-compatible: nothing resolvable → shared token. +case "$host" in + git.uscllc.com) idpfx=gitea-usc;; + git.mosaicstack.dev) idpfx=gitea-mosaicstack;; + *) idpfx="";; +esac 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 +if [ -n "$idpfx" ] && [ -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;; - git.mosaicstack.dev) idpfx=gitea-mosaicstack;; - *) idpfx="";; - 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 + if [ -e "$idcred" ] || [ -L "$idcred" ]; then token=$(python3 "$script_dir/resolve-credential-envelope.py" \ "$idcred" "$ident" "${MOSAIC_CREDENTIAL_ESTATE:-}" "$host") || exit 1 trace_resolution identity credential-resolved "$ident" "$host" git-credential-mosaic 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 3410cf01..df852e24 100755 --- a/packages/mosaic/framework/tools/git/test-git-credential-mosaic.sh +++ b/packages/mosaic/framework/tools/git/test-git-credential-mosaic.sh @@ -194,6 +194,7 @@ PY chmod 600 "$envelope" out=$(run_helper "git.mosaicstack.dev" "agentE" MOSAIC_AGENT_NAME=agentE MOSAIC_CREDENTIAL_ESTATE=homelab) assert_eq "governed envelope: password" "password=agentE-envelope-token" "$(echo "$out" | grep '^password=')" +echo -n "must-not-fallback-legacy" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentE.token" chmod 640 "$envelope" set +e out=$(run_helper "git.mosaicstack.dev" "agentE" MOSAIC_AGENT_NAME=agentE MOSAIC_CREDENTIAL_ESTATE=homelab 2>"$WORK_DIR/envelope-mode.stderr") @@ -210,6 +211,8 @@ fi # --------------------------------------------------------------------------- out=$(run_helper "github.com" "agentA") assert_eq "unknown host: no output" "" "$out" +out=$(run_helper "github.com" "github-user" MOSAIC_AGENT_NAME=agentA) +assert_eq "unknown host in fleet context: no output" "" "$out" # --------------------------------------------------------------------------- # 9. Non-"get" verb (store/erase) -> exit 0, no output (git-credential diff --git a/packages/mosaic/src/commands/cred.ts b/packages/mosaic/src/commands/cred.ts index 3c332d58..c7c135a1 100644 --- a/packages/mosaic/src/commands/cred.ts +++ b/packages/mosaic/src/commands/cred.ts @@ -56,6 +56,7 @@ interface CredentialValidateCommandOptions { readonly mosaicHome?: string; readonly actor?: string; readonly json?: boolean; + readonly operation?: 'validate' | 'whoami'; } interface CredentialGrantCommandOptions { @@ -111,10 +112,11 @@ function readRegistrySource(path: string): string { function errorResult( request: GiteaWriteValidationRequestDto, code: string, + operation: 'validate' | 'whoami' = 'validate', ): CredentialValidationResultDto { return { schemaVersion: 1, - operation: 'validate', + operation, outcome: 'error', exitCode: 20, retryable: false, @@ -166,13 +168,13 @@ export async function executeCredentialValidate( try { if (!['read', 'write', 'admin'].includes(options.require)) { - return errorResult(request, 'invalid-input'); + return errorResult(request, 'invalid-input', options.operation); } const registry = parseCredentialEstateRegistry(readRegistrySource(registryPath)); const hostConfig = registry.resolve(options.estate, options.host); if (hostConfig === undefined) { return { - ...errorResult(request, 'estate-host-mismatch'), + ...errorResult(request, 'estate-host-mismatch', options.operation), outcome: 'refused', exitCode: 10, reason: { @@ -188,19 +190,23 @@ export async function executeCredentialValidate( const resolver = new FileCredentialResolver(tokenDirectory, registry); const provider = new GiteaCredentialProviderAdapter(hostConfig.apiBaseUrl, fetch); const dependencies = { resolver, provider, estateRegistry: registry }; - const serviceOptions = { stateRoot, actor: options.actor ?? identity }; + const serviceOptions = { + stateRoot, + actor: options.actor ?? identity, + operation: options.operation ?? 'validate', + }; if (options.require === 'read') { return await runCredentialReadValidation(request, dependencies, serviceOptions); } return await runCredentialValidation(request, dependencies, serviceOptions); } catch (error: unknown) { if (error instanceof CredentialJournalError) { - return errorResult(request, error.code); + return errorResult(request, error.code, options.operation); } if (error instanceof CredentialEstateRegistryError || error instanceof CredentialStoreError) { - return errorResult(request, error.code); + return errorResult(request, error.code, options.operation); } - return errorResult(request, 'internal-invariant'); + return errorResult(request, 'internal-invariant', options.operation); } } @@ -505,6 +511,14 @@ export async function executeCredentialRotate( message: 'No existing binding can be rotated.', }); } + if (options.tokenName === old.binding.tokenName) { + old.secret.fill(0); + return localLifecycleResult('rotate', identity, options, { + outcome: 'refused', + code: 'replacement-token-name-conflict', + message: 'Replacement token name must differ from the active generation.', + }); + } const authority = await lifecycleAuthority(identity, options); const journal = await CredentialAuditJournal.open(context.stateRoot, { operation: 'rotate', @@ -646,7 +660,7 @@ export async function executeCredentialWire( lines.push( `MOSAIC_GIT_IDENTITY=${identity}`, `MOSAIC_CREDENTIAL_ESTATE=${options.estate}`, - `GITEA_LOGIN=${identity}`, + `GITEA_LOGIN=${identity}--${options.host}`, ); const temp = `${options.seatEnv}.${process.pid.toString()}.tmp`; const handle = await open(temp, 'wx', 0o600); @@ -1051,7 +1065,11 @@ export function registerCredentialCommand(parent: Command): void { .option('--actor ', 'Explicit audit actor') .option('--json', 'Emit one machine result object') .action(async (identity: string, options: CredentialValidateCommandOptions): Promise => { - const result = await executeCredentialValidate(identity, { ...options, require: 'read' }); + const result = await executeCredentialValidate(identity, { + ...options, + require: 'read', + operation: 'whoami', + }); printCredentialResult(result, options.json === true); process.exitCode = result.exitCode; }); diff --git a/packages/mosaic/src/credentials/audit-journal.spec.ts b/packages/mosaic/src/credentials/audit-journal.spec.ts index 010acc6a..e0805ee0 100644 --- a/packages/mosaic/src/credentials/audit-journal.spec.ts +++ b/packages/mosaic/src/credentials/audit-journal.spec.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; @@ -107,6 +107,22 @@ describe('credential durable audit journal', (): void => { await journal.closeIncomplete(); }); + it('refuses a group-writable journal root before opening evidence', async (): Promise => { + const root = await stateRoot(); + await mkdir(root, { mode: 0o700 }); + await chmod(root, 0o770); + await expect( + CredentialAuditJournal.open(root, { + operation: 'grant', + actor: 'provisioner', + identity: 'seat-name', + estate: 'homelab', + host: 'git.example.invalid', + repo: 'owner/repo', + }), + ).rejects.toThrow(/journal-unavailable/); + }); + it('supersedes a false sealed classification without editing the original journal', async (): Promise => { const root = await stateRoot(); const journal = await CredentialAuditJournal.open( diff --git a/packages/mosaic/src/credentials/audit-journal.ts b/packages/mosaic/src/credentials/audit-journal.ts index c7b3d579..f9f039d0 100644 --- a/packages/mosaic/src/credentials/audit-journal.ts +++ b/packages/mosaic/src/credentials/audit-journal.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto'; +import { lstatSync } from 'node:fs'; import { open, readdir, rename } from 'node:fs/promises'; import type { FileHandle } from 'node:fs/promises'; import { join } from 'node:path'; @@ -31,6 +32,7 @@ const SAFE_DECISIONS = new Set([ 'revoke-verified', 'rotate-verified', 'validation-requested', + 'whoami-requested', 'provision-requested', 'rotate-requested', 'revoke-requested', @@ -69,6 +71,21 @@ export class CredentialJournalError extends Error { } } +function assertPrivateDirectory(path: string): void { + const stat = lstatSync(path); + if ( + !stat.isDirectory() || + stat.isSymbolicLink() || + stat.uid !== process.getuid?.() || + (stat.mode & 0o022) !== 0 + ) { + throw new CredentialJournalError( + 'journal-unavailable', + 'journal directory owner or write permissions are unsafe', + ); + } +} + function assertContext(context: CredentialJournalContextDto): void { if ( !SAFE_NAME.test(context.actor) || @@ -132,6 +149,8 @@ export class CredentialAuditJournal { let handle: FileHandle | undefined; try { ensureManagedDirectory(stateRoot, journalsDirectory); + assertPrivateDirectory(stateRoot); + assertPrivateDirectory(journalsDirectory); const openPath = join(journalsDirectory, `${id}.open.jsonl`); handle = await open(openPath, 'wx', 0o600); const journal = new CredentialAuditJournal(handle, openPath, journalsDirectory, id, now); @@ -270,6 +289,8 @@ export async function listCredentialJournals( const journalsDirectory = join(stateRoot, 'journals'); let names: string[]; try { + assertPrivateDirectory(stateRoot); + assertPrivateDirectory(journalsDirectory); names = await readdir(journalsDirectory); } catch (error: unknown) { if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return []; diff --git a/packages/mosaic/src/credentials/credential-result.dto.ts b/packages/mosaic/src/credentials/credential-result.dto.ts index 3511e4a6..c9765a5c 100644 --- a/packages/mosaic/src/credentials/credential-result.dto.ts +++ b/packages/mosaic/src/credentials/credential-result.dto.ts @@ -72,7 +72,7 @@ export interface CredentialAuditResultDto { export interface CredentialValidationResultDto { readonly schemaVersion: 1; - readonly operation: 'validate'; + readonly operation: 'validate' | 'whoami'; readonly outcome: CredentialOutcome; readonly exitCode: 0 | 10 | 20 | 30; readonly retryable: boolean; diff --git a/packages/mosaic/src/credentials/credential-validate-service.ts b/packages/mosaic/src/credentials/credential-validate-service.ts index ad3f4a02..b7fb62a0 100644 --- a/packages/mosaic/src/credentials/credential-validate-service.ts +++ b/packages/mosaic/src/credentials/credential-validate-service.ts @@ -13,6 +13,7 @@ import { evaluateGiteaReadValidation, evaluateGiteaWriteValidation } from './val export interface CredentialValidationServiceOptions { readonly stateRoot: string; readonly actor: string; + readonly operation?: 'validate' | 'whoami'; } function permissionDecision(permission: RepositoryPermission): string { @@ -27,14 +28,16 @@ async function openValidationJournal( options: CredentialValidationServiceOptions, ): Promise { const journal = await CredentialAuditJournal.open(options.stateRoot, { - operation: 'validate', + operation: options.operation ?? 'validate', actor: options.actor, identity: request.identity, estate: request.estate, host: request.host, repo: request.repo, }); - await journal.recordIntent('validation-requested'); + await journal.recordIntent( + options.operation === 'whoami' ? 'whoami-requested' : 'validation-requested', + ); return journal; } @@ -70,7 +73,10 @@ export async function runCredentialReadValidation( ): Promise { const journal = await openValidationJournal(request, options); const validation = await evaluateGiteaReadValidation(request, dependencies); - return recordAndSealValidation(journal, validation); + return recordAndSealValidation(journal, { + ...validation, + operation: options.operation ?? 'validate', + }); } export async function runCredentialValidation( diff --git a/packages/mosaic/src/credentials/estate-registry.spec.ts b/packages/mosaic/src/credentials/estate-registry.spec.ts index a15b8602..4a0d78f6 100644 --- a/packages/mosaic/src/credentials/estate-registry.spec.ts +++ b/packages/mosaic/src/credentials/estate-registry.spec.ts @@ -26,6 +26,11 @@ describe('credential estate registry', (): void => { expect(registry.matches('homelab', 'git.example.invalid')).toBe(true); expect(registry.matches('usc', 'git.example.invalid')).toBe(false); expect(registry.matches('homelab', 'other.example.invalid')).toBe(false); + expect(registry.resolveByHost('git.example.invalid')).toMatchObject({ + estate: 'homelab', + host: { host: 'git.example.invalid', provider: 'gitea' }, + }); + expect(registry.resolveByHost('other.example.invalid')).toBeUndefined(); }); it('rejects a provider URL whose host differs from the declared host', (): void => { diff --git a/packages/mosaic/src/credentials/estate-registry.ts b/packages/mosaic/src/credentials/estate-registry.ts index 093397a3..f1349624 100644 --- a/packages/mosaic/src/credentials/estate-registry.ts +++ b/packages/mosaic/src/credentials/estate-registry.ts @@ -93,6 +93,18 @@ export class ParsedCredentialEstateRegistry implements CredentialEstateRegistry ?.hosts.find((candidate: CredentialHostConfigDto): boolean => candidate.host === host); } + resolveByHost( + host: string, + ): { readonly estate: string; readonly host: CredentialHostConfigDto } | undefined { + for (const [estate, config] of this.estates) { + const match = config.hosts.find( + (candidate: CredentialHostConfigDto): boolean => candidate.host === host, + ); + if (match !== undefined) return { estate, host: match }; + } + return undefined; + } + readOnlyControl(estate: string): string { const identity = this.estates.get(estate)?.readOnlyControlIdentity; if (identity === undefined) { diff --git a/packages/mosaic/src/credentials/lifecycle.ts b/packages/mosaic/src/credentials/lifecycle.ts index 762efd48..32296390 100644 --- a/packages/mosaic/src/credentials/lifecycle.ts +++ b/packages/mosaic/src/credentials/lifecycle.ts @@ -205,8 +205,9 @@ export async function provisionCredential( teaLogin: { ...teaLogin, state: 'registered' }, }); } catch (error: unknown) { - if (error instanceof CredentialJournalError) throw error; + const journalFailure = error instanceof CredentialJournalError; if (minted === undefined) { + if (journalFailure) throw error; await journal.seal('indeterminate', 'provider-unavailable'); return lifecycleResult('provision', request, { outcome: 'indeterminate', @@ -231,13 +232,19 @@ export async function provisionCredential( 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); } + if (journalFailure) { + if (rollbackComplete) { + await journal.recordMutation('provision-rollback-verified').catch((): void => undefined); + } + throw error; + } const code = rollbackComplete ? failureCode : 'rollback-incomplete'; + if (rollbackComplete) await journal.recordMutation('provision-rollback-verified'); await journal.seal(rollbackComplete ? 'error' : 'indeterminate', code); return lifecycleResult('provision', request, { outcome: rollbackComplete ? 'error' : 'indeterminate', diff --git a/packages/mosaic/src/credentials/tea-login-store.spec.ts b/packages/mosaic/src/credentials/tea-login-store.spec.ts index efa9be6b..512dabe5 100644 --- a/packages/mosaic/src/credentials/tea-login-store.spec.ts +++ b/packages/mosaic/src/credentials/tea-login-store.spec.ts @@ -28,7 +28,7 @@ describe('host-bound Tea login store', (): void => { 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', + name: 'seat--git.two.invalid', 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 99cf4a7b..1b302d41 100644 --- a/packages/mosaic/src/credentials/tea-login-store.ts +++ b/packages/mosaic/src/credentials/tea-login-store.ts @@ -59,6 +59,10 @@ async function acquireLock(path: string): Promise !(login.name === identity && login.url === `https://${host}`), + (login): boolean => + !(login.name === loginName(identity, host) && login.url === `https://${host}`), ); logins.push(record); const temp = `${this.configPath}.${randomUUID()}.tmp`; @@ -140,7 +145,9 @@ export class TeaLoginStore { if (!decoded.success) return undefined; const matches = decoded.data.logins.filter( (login): boolean => - login.name === identity && login.url === `https://${host}` && login.user === identity, + login.name === loginName(identity, host) && + login.url === `https://${host}` && + login.user === identity, ); if (matches.length !== 1 || matches[0] === undefined) return undefined; return Object.freeze({ @@ -173,7 +180,8 @@ export class TeaLoginStore { 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}`), + (login): boolean => + !(login.name === loginName(identity, host) && login.url === `https://${host}`), ); const temp = `${this.configPath}.${randomUUID()}.tmp`; const handle = await open(temp, 'wx', 0o600); @@ -215,13 +223,13 @@ export class TeaLoginStore { if (typeof value !== 'object' || value === null) return false; return ( 'name' in value && - value.name === identity && + value.name === loginName(identity, host) && 'url' in value && value.url === `https://${host}` && 'user' in value && value.user === identity ); }); - return matches.length === 1 ? { name: identity, host } : undefined; + return matches.length === 1 ? { name: loginName(identity, host), host } : undefined; } }