diff --git a/packages/mosaic/framework/tools/git/detect-platform.sh b/packages/mosaic/framework/tools/git/detect-platform.sh index 3baa13f7..08ab316f 100755 --- a/packages/mosaic/framework/tools/git/detect-platform.sh +++ b/packages/mosaic/framework/tools/git/detect-platform.sh @@ -525,6 +525,11 @@ get_gitea_token() { _ident="$(git config --get mosaic.gitIdentity 2>/dev/null || true)" _ident_src="git config mosaic.gitIdentity" fi + if [[ -n "$_ident" && ! "$_ident" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]]; then + printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=invalid-identity identity= host=%s shared_path_entered=false source=%s\n' \ + "$host" "$_ident_src" >&2 + return 1 + 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 @@ -542,7 +547,7 @@ get_gitea_token() { 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 + "$HOME/.config/mosaic/secrets/gitea-tokens" "$_idcred" "$_ident" "${MOSAIC_CREDENTIAL_ESTATE:-}" "$host") || return 1 _resolution_path=identity _trace_credential_resolution credential-resolved "$_ident" "$host" "$_ident_src" printf '%s\n' "$_resolved_token" @@ -550,7 +555,8 @@ get_gitea_token() { fi if [[ -e "$_idtok" || -L "$_idtok" ]]; then local _resolved_token - _resolved_token=$(python3 "$script_dir/resolve-legacy-token.py" "$_idtok") || return 1 + _resolved_token=$(python3 "$script_dir/resolve-legacy-token.py" \ + "$HOME/.config/mosaic/secrets/gitea-tokens" "$_idtok") || return 1 _resolution_path=identity _trace_credential_resolution credential-resolved "$_ident" "$host" "$_ident_src" printf '%s\n' "$_resolved_token" diff --git a/packages/mosaic/framework/tools/git/git-credential-mosaic b/packages/mosaic/framework/tools/git/git-credential-mosaic index ec87df2b..3056f50f 100755 --- a/packages/mosaic/framework/tools/git/git-credential-mosaic +++ b/packages/mosaic/framework/tools/git/git-credential-mosaic @@ -47,6 +47,11 @@ esac ident="$MOSAIC_GIT_IDENTITY" [ -z "$ident" ] && ident=$(git config --get mosaic.gitIdentity 2>/dev/null) [ -z "$ident" ] && ident="$username_in" +if [[ -n "$ident" && ! "$ident" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]]; then + echo "quit=true" + printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=invalid-identity identity= host=%s shared_path_entered=false source=git-credential-mosaic\n' "$host" >&2 + exit 1 +fi 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' \ @@ -59,7 +64,7 @@ if [ -n "$ident" ]; then idcred="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.credential.json" if [ -e "$idcred" ] || [ -L "$idcred" ]; then token=$(python3 "$script_dir/resolve-credential-envelope.py" \ - "$idcred" "$ident" "${MOSAIC_CREDENTIAL_ESTATE:-}" "$host") || exit 1 + "$HOME/.config/mosaic/secrets/gitea-tokens" "$idcred" "$ident" "${MOSAIC_CREDENTIAL_ESTATE:-}" "$host") || exit 1 resolution_path=identity trace_resolution credential-resolved "$ident" "$host" git-credential-mosaic echo "username=${ident}" @@ -67,7 +72,8 @@ if [ -n "$ident" ]; then exit 0 fi if [ -e "$idtok" ] || [ -L "$idtok" ]; then - token=$(python3 "$script_dir/resolve-legacy-token.py" "$idtok") || exit 1 + token=$(python3 "$script_dir/resolve-legacy-token.py" \ + "$HOME/.config/mosaic/secrets/gitea-tokens" "$idtok") || exit 1 resolution_path=identity trace_resolution credential-resolved "$ident" "$host" git-credential-mosaic echo "username=${ident}" diff --git a/packages/mosaic/framework/tools/git/resolve-credential-envelope.py b/packages/mosaic/framework/tools/git/resolve-credential-envelope.py index 50a5e4c9..c3313a80 100644 --- a/packages/mosaic/framework/tools/git/resolve-credential-envelope.py +++ b/packages/mosaic/framework/tools/git/resolve-credential-envelope.py @@ -27,9 +27,11 @@ def refuse(message: str) -> None: raise SystemExit(1) -if len(sys.argv) != 5: - refuse("expected path, identity, estate, and host") -path, identity, estate, host = sys.argv[1:] +if len(sys.argv) != 6: + refuse("expected governed root, path, identity, estate, and host") +root, path, identity, estate, host = sys.argv[1:] +if os.path.abspath(os.path.dirname(path)) != os.path.abspath(root): + refuse("credential is not a direct child of the governed root") if not estate: refuse("explicit estate is required") parent = os.path.dirname(path) diff --git a/packages/mosaic/framework/tools/git/resolve-legacy-token.py b/packages/mosaic/framework/tools/git/resolve-legacy-token.py index 2f445d5a..c861950f 100644 --- a/packages/mosaic/framework/tools/git/resolve-legacy-token.py +++ b/packages/mosaic/framework/tools/git/resolve-legacy-token.py @@ -13,9 +13,11 @@ def refuse(message: str) -> None: raise SystemExit(1) -if len(sys.argv) != 2: - refuse("expected token path") -path = sys.argv[1] +if len(sys.argv) != 3: + refuse("expected governed root and token path") +root, path = sys.argv[1:] +if os.path.abspath(os.path.dirname(path)) != os.path.abspath(root): + refuse("credential is not a direct child of the governed root") parent = os.path.dirname(path) try: parent_stat = os.stat(parent, follow_symlinks=False) diff --git a/packages/mosaic/src/credentials/credential-validate-service.ts b/packages/mosaic/src/credentials/credential-validate-service.ts index b7fb62a0..ebead0e8 100644 --- a/packages/mosaic/src/credentials/credential-validate-service.ts +++ b/packages/mosaic/src/credentials/credential-validate-service.ts @@ -1,4 +1,4 @@ -import { CredentialAuditJournal } from './audit-journal.js'; +import { CredentialAuditJournal, CredentialJournalError } from './audit-journal.js'; import type { CredentialValidationDependencies, GiteaReadValidationRequestDto, @@ -66,17 +66,61 @@ async function recordAndSealValidation( }; } +function journalFailureResult( + request: GiteaReadValidationRequestDto, + journal: CredentialAuditJournal, + error: CredentialJournalError, + operation: 'validate' | 'whoami', +): CredentialValidationResultDto { + return { + schemaVersion: 1, + operation, + outcome: 'error', + exitCode: 20, + retryable: false, + subject: { + identity: request.identity, + estate: request.estate, + host: request.host, + repo: request.repo, + }, + mutation: 'none', + reason: { + code: error.code, + message: 'Validation audit persistence failed; inspect the durable open journal.', + }, + evidence: { + providerIdentity: null, + tokenCapabilities: { + state: 'not-measured', + scopes: [], + source: 'runtime-not-authorized', + }, + repositoryPermission: null, + writeDifferential: null, + }, + audit: { journalId: journal.journalId(), state: 'open' }, + }; +} + export async function runCredentialReadValidation( request: GiteaReadValidationRequestDto, dependencies: CredentialValidationDependencies, options: CredentialValidationServiceOptions, ): Promise { const journal = await openValidationJournal(request, options); - const validation = await evaluateGiteaReadValidation(request, dependencies); - return recordAndSealValidation(journal, { - ...validation, - operation: options.operation ?? 'validate', - }); + try { + const validation = await evaluateGiteaReadValidation(request, dependencies); + return await recordAndSealValidation(journal, { + ...validation, + operation: options.operation ?? 'validate', + }); + } catch (error: unknown) { + if (error instanceof CredentialJournalError) { + return journalFailureResult(request, journal, error, options.operation ?? 'validate'); + } + throw error; + } } export async function runCredentialValidation( @@ -85,6 +129,13 @@ export async function runCredentialValidation( options: CredentialValidationServiceOptions, ): Promise { const journal = await openValidationJournal(request, options); - const validation = await evaluateGiteaWriteValidation(request, dependencies); - return recordAndSealValidation(journal, validation); + try { + const validation = await evaluateGiteaWriteValidation(request, dependencies); + return await recordAndSealValidation(journal, validation); + } catch (error: unknown) { + if (error instanceof CredentialJournalError) { + return journalFailureResult(request, journal, error, 'validate'); + } + throw error; + } } diff --git a/packages/mosaic/src/credentials/lifecycle.ts b/packages/mosaic/src/credentials/lifecycle.ts index 4ca8ebcb..4314d928 100644 --- a/packages/mosaic/src/credentials/lifecycle.ts +++ b/packages/mosaic/src/credentials/lifecycle.ts @@ -292,6 +292,24 @@ export async function revokeCredential( auditState: 'sealed', }); } + const identity = await provider.readBasicIdentity(authority); + if (identity.login !== request.identity || authority.identity !== request.identity) { + await journal.seal('refused', 'provider-identity-mismatch'); + return lifecycleResult('revoke', request, { + outcome: 'refused', + mutation: 'none', + code: 'provider-identity-mismatch', + message: 'Delegated Basic authority did not bind the requested principal.', + journalId: journal.journalId(), + auditState: 'sealed', + providerIdentity: identity.login, + }); + } + await journal.recordProviderEvidence({ + endpoint: identity.endpoint, + contentType: identity.contentType, + decision: 'identity-verified', + }); mutation = 'unknown'; await provider.revokeToken(authority, request.identity, binding.tokenName); mutation = 'applied'; @@ -323,7 +341,16 @@ export async function revokeCredential( auditState: 'sealed', }); } catch (error: unknown) { - if (error instanceof CredentialJournalError) throw error; + if (error instanceof CredentialJournalError) { + return lifecycleResult('revoke', request, { + outcome: 'indeterminate', + mutation, + code: error.code, + message: 'Audit persistence failed; inspect the durable open journal before recovery.', + journalId: journal.journalId(), + auditState: 'open', + }); + } await journal.seal('indeterminate', 'mutation-state-unknown'); return lifecycleResult('revoke', request, { outcome: 'indeterminate', diff --git a/packages/mosaic/src/credentials/tea-login-store.ts b/packages/mosaic/src/credentials/tea-login-store.ts index 1b302d41..2eb4b109 100644 --- a/packages/mosaic/src/credentials/tea-login-store.ts +++ b/packages/mosaic/src/credentials/tea-login-store.ts @@ -42,6 +42,15 @@ const loginSchema = z .passthrough(); const configSchema = z.object({ logins: z.array(loginSchema).default([]) }).passthrough(); +async function syncDirectory(path: string): Promise { + const handle = await open(path, 'r'); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} + async function acquireLock(path: string): Promise>> { for (let attempt = 0; attempt < 500; attempt += 1) { try { @@ -122,6 +131,7 @@ export class TeaLoginStore { await handle.close(); } await rename(temp, this.configPath); + await syncDirectory(directory); } finally { await lock.close(); await unlink(lockPath).catch((): void => undefined); @@ -192,6 +202,7 @@ export class TeaLoginStore { await handle.close(); } await rename(temp, this.configPath); + await syncDirectory(directory); } finally { await lock.close(); await unlink(lockPath).catch((): void => undefined);