From 050ac63737e1b3b8606fe944a8778c96d71a4d80 Mon Sep 17 00:00:00 2001 From: be-coder-06 Date: Wed, 5 Aug 2026 12:40:29 -0500 Subject: [PATCH] feat(mosaic): complete credential lifecycle and fail closed --- docs/scratchpads/1045-mosaic-cred.md | 8 +- .../framework/tools/git/detect-platform.sh | 13 +- .../framework/tools/git/git-credential-mosaic | 15 + .../tools/git/test-git-credential-mosaic.sh | 20 + .../tools/git/test-gitea-token-identity.sh | 30 +- packages/mosaic/src/commands/cred.ts | 574 +++++++++++++++++- .../src/credentials/audit-journal.dto.ts | 5 +- .../src/credentials/audit-journal.spec.ts | 4 +- .../mosaic/src/credentials/audit-journal.ts | 28 + .../src/credentials/credential-binding.dto.ts | 10 + .../src/credentials/credential-result.dto.ts | 2 +- .../credential-validate-service.ts | 1 + .../src/credentials/delegated-credential.ts | 30 +- .../credentials/file-credential-store.spec.ts | 49 +- .../src/credentials/file-credential-store.ts | 164 ++++- .../src/credentials/gitea-provider.spec.ts | 34 +- .../mosaic/src/credentials/gitea-provider.ts | 187 +++++- packages/mosaic/src/credentials/grant.spec.ts | 9 +- packages/mosaic/src/credentials/grant.ts | 34 ++ .../mosaic/src/credentials/lifecycle.dto.ts | 49 ++ .../mosaic/src/credentials/lifecycle.spec.ts | 143 +++++ packages/mosaic/src/credentials/lifecycle.ts | 272 +++++++++ .../mosaic/src/credentials/tea-login-store.ts | 127 ++++ packages/mosaic/src/credentials/team-grant.ts | 44 ++ .../mosaic/src/credentials/validate.spec.ts | 39 +- packages/mosaic/src/credentials/validate.ts | 80 ++- packages/mosaic/src/fleet/secure-file.ts | 4 + 27 files changed, 1946 insertions(+), 29 deletions(-) create mode 100644 packages/mosaic/src/credentials/credential-binding.dto.ts create mode 100644 packages/mosaic/src/credentials/lifecycle.dto.ts create mode 100644 packages/mosaic/src/credentials/lifecycle.spec.ts create mode 100644 packages/mosaic/src/credentials/lifecycle.ts create mode 100644 packages/mosaic/src/credentials/tea-login-store.ts diff --git a/docs/scratchpads/1045-mosaic-cred.md b/docs/scratchpads/1045-mosaic-cred.md index 90104a4e..3d4eafad 100644 --- a/docs/scratchpads/1045-mosaic-cred.md +++ b/docs/scratchpads/1045-mosaic-cred.md @@ -51,7 +51,7 @@ No explicit token cap supplied. Working cap: keep implementation in one package - [x] Contract v1.4 implements ruling (b): subject credential's own `/user`, no admin/inventory authority, no implemented `identity-not-found` path. - [x] PRD update. - [x] Red-first principal-bound validate, estate-registry, file-store, provider-transport, and journal tests. -- [ ] Implementation (validate core/CLI, direct/team grant core, and protected delegated-authority fd reader in progress; provider team adapter, CLI grant integration, remaining lifecycle commands, and separately sequenced fail-closed resolver evidence open). +- [ ] Implementation (validate, direct/team grant, protected delegated authority, provision/wire/get/whoami/list/rotate/revoke/audit, and fleet fail-closed resolver paths implemented; final review hardening and live provider lifecycle evidence open). - [ ] Independent code/security reviews. - [ ] CI and provider evidence. @@ -65,7 +65,9 @@ Red-first evidence: - read validation absent → 2 tests failed `evaluateGiteaReadValidation is not a function`; after implementation, 15/15 validate tests passed; - estate registry, secure file resolver, Gitea transport, and audit journal each failed first because the module did not exist, then passed focused behavior suites. -Current focused evidence after v1.4: provider + validate 23/23, durable correction journal 5/5, delegated credential fd 2/2, direct grant 2/2, team grant 1/1; package typecheck green. +Current focused evidence: 62/62 across 9 credential suites; package lint and typecheck green. Provider bodies are stream-bounded and requests deadline-bounded; delegated fd input is ownership/mode/size/time bounded; token and Tea stores are private and atomic; grant mutation/read-back state is journaled. + +Fail-closed resolver evidence: synthetic missing-token API and git paths each emitted stable `MOSAIC_CREDENTIAL_REFUSAL` with `reason=no-token-for-identity` and `shared_path_entered=false`; all 13 live token-bearing identities bypassed the shared path without over-fire in the same run. Evidence: `/home/hermes/agent-work/be-coder-06/review-evidence/failclosed-postcondition.jsonl`; independent verification remains tl-mosaic's obligation. Live validation v1.4 (subject credential's own `/user`, no admin): population 13; CONFIRMED 8; CREDENTIAL-REJECTED 4 (`coder-mos1`, `coder-mos2`, `f10-coder`, `merge-gate`); MISMATCH 1 (`mos-admin` token authenticates as `Mos`); NOT-MEASURED 0. The four false v1.2 `identity-not-found` sealed journals remain immutable and are explicitly superseded by four sealed correction journals. Evidence: `/home/hermes/agent-work/be-coder-06/live-validation-v1.4/`. @@ -75,5 +77,5 @@ Write differential for be-coder-06 passed with the configured read-only control - The full CLI surface is broad; protect scope by sharing one provider/registry/journal core rather than per-command scripts. - Gitea exact token-scope read-back may require delegated Basic Auth. If a bearer-only validation path cannot obtain an exact provider token object, return `indeterminate` rather than claim a scope. -- #1044 hold is LIFTED: the four credentials are already rejected and fail-open preserves silent misattribution. Re-mint is separate fleet task #19. The fail-closed change still requires normal review/green plus mechanism evidence proving resolver refusal, a same-run marker-emission positive control, and unaffected confirmed lanes; tl-mosaic independently verifies. +- #1044 hold is LIFTED. The four least-privilege credentials are capability-confirmed and identity-not-measured, not dead. Fleet fail-closed paths now refuse with stable reason markers and never enter shared fallback under `MOSAIC_AGENT_NAME`; interactive callers retain explicit shared behavior. Runtime mismatch coverage remains limited to tokens holding `read:user`; future mints close identity binding at creation without widening seat scopes. - Branch model compatibility remains escalated above this lane. Do not claim completion at `next`. diff --git a/packages/mosaic/framework/tools/git/detect-platform.sh b/packages/mosaic/framework/tools/git/detect-platform.sh index 97c6e5b3..214dbf67 100755 --- a/packages/mosaic/framework/tools/git/detect-platform.sh +++ b/packages/mosaic/framework/tools/git/detect-platform.sh @@ -534,12 +534,21 @@ get_gitea_token() { # would post PRs/issues/reviews under the WRONG agent (e.g. rev2's review attributed # to coder3), corrupting Gate-16 author≠reviewer separation. Hard-stop instead so the # caller aborts loudly rather than acting as the wrong identity. - echo "Error: git identity '$_ident' requested (via $_ident_src) for host '$host', but no per-slot token at $_idtok." >&2 - echo " Refusing to borrow another slot's token. Provision the per-slot token, or unset the identity to use shared credentials." >&2 + printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=no-token-for-identity identity=%s host=%s shared_path_entered=false source=%s path=%s\n' \ + "$_ident" "$host" "$_ident_src" "$_idtok" >&2 return 1 fi fi + # Fleet automation never borrows a shared human/default credential. An + # explicit interactive caller may still reach the shared paths below, but + # a fleet process must name an identity and resolve that identity exactly. + if [[ -n "${MOSAIC_AGENT_NAME:-}" ]]; then + printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=identity-required identity= host=%s shared_path_entered=false source=MOSAIC_AGENT_NAME\n' \ + "$host" >&2 + return 1 + fi + # 1. Mosaic credential loader (host → service mapping, run in subshell to avoid polluting env) if [[ -f "$cred_loader" ]]; then local token diff --git a/packages/mosaic/framework/tools/git/git-credential-mosaic b/packages/mosaic/framework/tools/git/git-credential-mosaic index 915fc7bc..b0bffc4c 100755 --- a/packages/mosaic/framework/tools/git/git-credential-mosaic +++ b/packages/mosaic/framework/tools/git/git-credential-mosaic @@ -45,8 +45,23 @@ if [ -n "$ident" ]; then echo "password=$(cat "$idtok")" exit 0 fi + if [ -n "${MOSAIC_AGENT_NAME:-}" ]; then + echo "quit=true" + printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=no-token-for-identity identity=%s host=%s shared_path_entered=false source=git-credential-mosaic path=%s\n' \ + "$ident" "$host" "$idtok" >&2 + exit 1 + fi fi fi +if [ -n "${MOSAIC_AGENT_NAME:-}" ] && [ -z "$ident" ]; then + case "$host" in + git.uscllc.com|git.mosaicstack.dev) + echo "quit=true" + printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=identity-required identity= host=%s shared_path_entered=false source=git-credential-mosaic\n' "$host" >&2 + exit 1 + ;; + esac +fi case "$host" in git.uscllc.com) svc=gitea-usc;; git.mosaicstack.dev) svc=gitea-mosaicstack;; 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 e84778b6..456d2f67 100755 --- a/packages/mosaic/framework/tools/git/test-git-credential-mosaic.sh +++ b/packages/mosaic/framework/tools/git/test-git-credential-mosaic.sh @@ -85,6 +85,16 @@ out=$(run_helper "git.mosaicstack.dev" "") assert_eq "shared fallback: username" "username=git" "$(echo "$out" | grep '^username=')" assert_eq "shared fallback: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')" +set +e +out=$(run_helper "git.mosaicstack.dev" "" MOSAIC_AGENT_NAME=synthetic-seat 2>"$WORK_DIR/fleet-unset.stderr") +rc=$? +set -e +err=$(cat "$WORK_DIR/fleet-unset.stderr") +if [[ "$rc" -eq 0 || "$out" != *"quit=true"* || "$err" != *"reason=identity-required"* || "$err" != *"shared_path_entered=false"* ]]; then + echo "FAIL: fleet unset identity did not stop at the resolver marker" >&2 + fail=1 +fi + # --------------------------------------------------------------------------- # 2. git-supplied username resolves to an identity WITH a per-slot token -> # that identity + token wins over the shared account. @@ -120,6 +130,16 @@ out=$(run_helper "git.mosaicstack.dev" "no-such-agent") assert_eq "no per-slot token: username" "username=git" "$(echo "$out" | grep '^username=')" 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") +rc=$? +set -e +err=$(cat "$WORK_DIR/fleet-missing.stderr") +if [[ "$rc" -eq 0 || "$out" != *"quit=true"* || "$err" != *"reason=no-token-for-identity"* || "$err" != *"shared_path_entered=false"* ]]; then + echo "FAIL: fleet missing token did not stop at the resolver marker" >&2 + fail=1 +fi + # --------------------------------------------------------------------------- # 6. Correct per-slot token PATH is chosen per host: same agent id, different # host prefix (gitea-usc- vs gitea-mosaicstack-). diff --git a/packages/mosaic/framework/tools/git/test-gitea-token-identity.sh b/packages/mosaic/framework/tools/git/test-gitea-token-identity.sh index d56e7bee..4c01116b 100755 --- a/packages/mosaic/framework/tools/git/test-gitea-token-identity.sh +++ b/packages/mosaic/framework/tools/git/test-gitea-token-identity.sh @@ -85,7 +85,23 @@ call_get_gitea_token() { # --------------------------------------------------------------------------- git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true out=$(call_get_gitea_token "git.mosaicstack.dev") -assert_eq "shared fallback (no identity)" "shared-mosaicstack-token" "$out" +assert_eq "interactive shared fallback (no identity)" "shared-mosaicstack-token" "$out" + +# Fleet context with no explicit identity refuses before the shared path. This +# is the marker-emission positive control for the fail-closed mechanism. +set +e +out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_AGENT_NAME=synthetic-seat 2>"$WORK_DIR/stderr-fleet-unset.tmp") +rc=$? +set -e +err=$(cat "$WORK_DIR/stderr-fleet-unset.tmp") +if [[ "$rc" -eq 0 || -n "$out" ]]; then + echo "FAIL: fleet unset identity must refuse with empty stdout" >&2 + fail=1 +fi +if [[ "$err" != *"MOSAIC_CREDENTIAL_REFUSAL"* || "$err" != *"reason=identity-required"* || "$err" != *"shared_path_entered=false"* ]]; then + echo "FAIL: fleet unset identity did not emit the stable resolver refusal marker: $err" >&2 + fail=1 +fi # --------------------------------------------------------------------------- # 2. git config mosaic.gitIdentity resolves to an agent WITH a per-slot @@ -93,8 +109,8 @@ assert_eq "shared fallback (no identity)" "shared-mosaicstack-token" "$out" # --------------------------------------------------------------------------- echo -n "agentA-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentA.token" git -C "$REPO_DIR" config mosaic.gitIdentity agentA -out=$(call_get_gitea_token "git.mosaicstack.dev") -assert_eq "git-config identity token" "agentA-mosaicstack-token" "$out" +out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_AGENT_NAME=agentA) +assert_eq "confirmed fleet identity bypasses shared path" "agentA-mosaicstack-token" "$out" # --------------------------------------------------------------------------- # 3. MOSAIC_GIT_IDENTITY env beats git config mosaic.gitIdentity. @@ -143,13 +159,17 @@ assert_failloud() { echo "$stderr" >&2 fail=1 fi + if [[ "$stderr" != *"MOSAIC_CREDENTIAL_REFUSAL"* || "$stderr" != *"reason=no-token-for-identity"* || "$stderr" != *"shared_path_entered=false"* ]]; then + echo "FAIL: $desc — stable resolver refusal marker missing: $stderr" >&2 + fail=1 + fi if [[ "$stderr" != *"$expected_tok_path"* ]]; then echo "FAIL: $desc — stderr does not name the expected per-slot token path '$expected_tok_path':" >&2 echo "$stderr" >&2 fail=1 fi - if [[ "$stderr" == *"shared"*"token"* ]]; then - echo "FAIL: $desc — stderr unexpectedly mentions a shared token value:" >&2 + if [[ "$stderr" == *"shared-mosaicstack-token"* || "$stderr" == *"shared-usc-token"* ]]; then + echo "FAIL: $desc — stderr unexpectedly contains a shared credential value:" >&2 echo "$stderr" >&2 fail=1 fi diff --git a/packages/mosaic/src/commands/cred.ts b/packages/mosaic/src/commands/cred.ts index 6b7fcb84..c677e7ae 100644 --- a/packages/mosaic/src/commands/cred.ts +++ b/packages/mosaic/src/commands/cred.ts @@ -1,7 +1,13 @@ +import { fstatSync, writeSync } from 'node:fs'; +import { open, readFile, rename } from 'node:fs/promises'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import type { Command } from 'commander'; -import { CredentialJournalError } from '../credentials/audit-journal.js'; +import { + CredentialAuditJournal, + CredentialJournalError, + listCredentialJournals, +} from '../credentials/audit-journal.js'; import { readRegularFileSecure } from '../fleet/secure-file.js'; import { runCredentialReadValidation, @@ -20,6 +26,9 @@ import { import type { CredentialGrantResultDto } from '../credentials/grant.dto.js'; import { grantTeamRepositoryPermission } from '../credentials/team-grant.js'; import type { TeamGrantResult } from '../credentials/team-grant.js'; +import { provisionCredential, revokeCredential } from '../credentials/lifecycle.js'; +import type { CredentialLifecycleResultDto } from '../credentials/lifecycle.dto.js'; +import { TeaLoginStore } from '../credentials/tea-login-store.js'; import { CredentialEstateRegistryError, parseCredentialEstateRegistry, @@ -27,9 +36,11 @@ import { import { CredentialStoreError, FileCredentialResolver, + FileCredentialStore, } from '../credentials/file-credential-store.js'; import { GiteaCredentialProviderAdapter, + GiteaLifecycleProviderAdapter, GiteaTeamGrantProviderAdapter, } from '../credentials/gitea-provider.js'; @@ -64,6 +75,23 @@ interface CredentialGrantCommandOptions { readonly json?: boolean; } +interface CredentialLifecycleCommandOptions { + readonly estate: string; + readonly host: string; + readonly actor: string; + readonly authorityFd?: string; + readonly outputFd?: string; + readonly scopes?: string; + readonly tokenName?: string; + readonly registry?: string; + readonly tokenDir?: string; + readonly stateDir?: string; + readonly seatEnv?: string; + readonly teaConfig?: string; + readonly mosaicHome?: string; + readonly json?: boolean; +} + function defaultMosaicHome(options: { readonly mosaicHome?: string }): string { return options.mosaicHome ?? join(homedir(), '.config', 'mosaic'); } @@ -281,8 +309,414 @@ export async function executeCredentialGrant( } } +function lifecycleLocations(options: CredentialLifecycleCommandOptions): { + readonly registryPath: string; + readonly tokenDirectory: string; + readonly stateRoot: string; +} { + const mosaicHome = defaultMosaicHome(options); + return { + registryPath: options.registry ?? join(mosaicHome, 'cred', 'estates.json'), + tokenDirectory: + options.tokenDir ?? + process.env['MOSAIC_GITEA_TOKEN_DIR'] ?? + join(mosaicHome, 'secrets', 'gitea-tokens'), + stateRoot: options.stateDir ?? join(homedir(), '.local', 'state', 'mosaic', 'cred'), + }; +} + +function localLifecycleResult( + operation: CredentialLifecycleResultDto['operation'], + identity: string, + options: CredentialLifecycleCommandOptions, + values: Partial & { + readonly outcome: CredentialLifecycleResultDto['outcome']; + readonly code: string; + readonly message: string; + }, +): CredentialLifecycleResultDto { + const exits = { ok: 0, refused: 10, error: 20, indeterminate: 30 } as const; + return { + schemaVersion: 1, + operation, + outcome: values.outcome, + exitCode: exits[values.outcome], + retryable: false, + subject: { identity, estate: options.estate, host: options.host, repo: null }, + mutation: values.mutation ?? 'none', + reason: { code: values.code, message: values.message }, + evidence: values.evidence ?? { + providerIdentity: null, + token: null, + teaLogin: null, + identities: [], + journalIds: [], + }, + audit: values.audit ?? { journalId: null, state: 'not-started' }, + }; +} + +async function lifecycleContext(options: CredentialLifecycleCommandOptions): Promise<{ + readonly registry: ReturnType; + readonly store: FileCredentialStore; + readonly provider: GiteaLifecycleProviderAdapter; + readonly teaStore: TeaLoginStore; + readonly stateRoot: string; +}> { + const locations = lifecycleLocations(options); + const registry = parseCredentialEstateRegistry(readRegistrySource(locations.registryPath)); + const host = registry.resolve(options.estate, options.host); + if (host === undefined) { + throw new CredentialEstateRegistryError('estate-host-mismatch', 'estate and host do not match'); + } + return { + registry, + store: new FileCredentialStore(locations.tokenDirectory, registry), + provider: new GiteaLifecycleProviderAdapter(host.apiBaseUrl, fetch), + teaStore: new TeaLoginStore( + options.teaConfig ?? join(homedir(), '.config', 'tea', 'config.yml'), + ), + stateRoot: locations.stateRoot, + }; +} + +async function lifecycleAuthority( + identity: string, + options: CredentialLifecycleCommandOptions, +): Promise>> { + return readDelegatedCredentialFromFd( + Number(options.authorityFd), + options.actor, + options.estate, + options.host, + ); +} + +export async function executeCredentialProvision( + identity: string, + options: CredentialLifecycleCommandOptions, +): Promise { + try { + if (options.authorityFd === undefined || options.tokenName === undefined) { + return localLifecycleResult('provision', identity, options, { + outcome: 'error', + code: 'invalid-input', + message: 'Authority fd and token name are required.', + }); + } + const scopes = (options.scopes ?? '').split(',').filter(Boolean); + if (scopes.length === 0) { + return localLifecycleResult('provision', identity, options, { + outcome: 'error', + code: 'invalid-input', + message: 'At least one explicit token scope is required.', + }); + } + const context = await lifecycleContext(options); + const authority = await lifecycleAuthority(identity, options); + return await provisionCredential( + { + identity, + estate: options.estate, + host: options.host, + tokenName: options.tokenName, + scopes, + }, + authority, + context.provider, + context.store, + context.teaStore, + { stateRoot: context.stateRoot, actor: options.actor }, + ); + } catch (error: unknown) { + const code = + error instanceof CredentialJournalError || + error instanceof CredentialEstateRegistryError || + error instanceof CredentialStoreError + ? error.code + : 'internal-invariant'; + return localLifecycleResult('provision', identity, options, { + outcome: 'error', + code, + message: 'Provisioning control failed locally.', + }); + } +} + +export async function executeCredentialRevoke( + identity: string, + options: CredentialLifecycleCommandOptions, +): Promise { + try { + if (options.authorityFd === undefined) { + return localLifecycleResult('revoke', identity, options, { + outcome: 'error', + code: 'invalid-input', + message: 'Authority fd is required.', + }); + } + const context = await lifecycleContext(options); + const authority = await lifecycleAuthority(identity, options); + return await revokeCredential( + { identity, estate: options.estate, host: options.host }, + authority, + context.provider, + context.store, + { stateRoot: context.stateRoot, actor: options.actor }, + ); + } catch { + return localLifecycleResult('revoke', identity, options, { + outcome: 'error', + code: 'internal-invariant', + message: 'Revocation control failed locally.', + }); + } +} + +export async function executeCredentialRotate( + identity: string, + options: CredentialLifecycleCommandOptions, +): Promise { + try { + if (options.authorityFd === undefined || options.tokenName === undefined) { + return localLifecycleResult('rotate', identity, options, { + outcome: 'error', + code: 'invalid-input', + message: 'Authority fd and new token name are required.', + }); + } + const context = await lifecycleContext(options); + const old = await context.store.readBinding(identity, options.estate, options.host); + if (old === undefined) { + return localLifecycleResult('rotate', identity, options, { + outcome: 'refused', + code: 'no-token-for-identity', + message: 'No existing binding can be rotated.', + }); + } + const authority = await lifecycleAuthority(identity, options); + const journal = await CredentialAuditJournal.open(context.stateRoot, { + operation: 'rotate', + actor: options.actor, + identity, + estate: options.estate, + host: options.host, + repo: null, + }); + await journal.recordIntent('rotate-requested'); + const provisioned = await provisionCredential( + { + identity, + estate: options.estate, + host: options.host, + tokenName: options.tokenName, + scopes: (options.scopes ?? old.scopes.join(',')).split(',').filter(Boolean), + }, + authority, + context.provider, + context.store, + context.teaStore, + { stateRoot: context.stateRoot, actor: options.actor }, + ); + if (provisioned.outcome !== 'ok') { + await journal.seal(provisioned.outcome, provisioned.reason.code); + return { + ...provisioned, + operation: 'rotate', + audit: { journalId: journal.journalId(), state: 'sealed' }, + }; + } + await journal.recordMutation('token-mint-applied'); + await context.provider.revokeToken(authority, identity, old.tokenName); + await journal.recordMutation('token-revoke-applied'); + await journal.seal('ok', 'rotate-verified'); + return { + ...provisioned, + operation: 'rotate', + reason: { + code: 'rotate-verified', + message: 'New token was read back before old token revocation.', + }, + audit: { journalId: journal.journalId(), state: 'sealed' }, + }; + } catch { + return localLifecycleResult('rotate', identity, options, { + outcome: 'indeterminate', + mutation: 'unknown', + code: 'mutation-state-unknown', + message: 'Rotation did not establish both new-token acceptance and old-token revocation.', + }); + } +} + +export async function executeCredentialWire( + identity: string, + options: CredentialLifecycleCommandOptions, +): Promise { + if (options.seatEnv === undefined) { + return localLifecycleResult('wire', identity, options, { + outcome: 'error', + code: 'invalid-input', + message: 'An explicit seat environment file is required.', + }); + } + const locations = lifecycleLocations(options); + const journal = await CredentialAuditJournal.open(locations.stateRoot, { + operation: 'wire', + actor: options.actor, + identity, + estate: options.estate, + host: options.host, + repo: null, + }); + await journal.recordIntent('wire-requested'); + try { + const existing = await readFile(options.seatEnv, 'utf8').catch((): string => ''); + const lines = existing + .split(/\r?\n/) + .filter( + (line): boolean => + !line.startsWith('MOSAIC_GIT_IDENTITY=') && !line.startsWith('GITEA_LOGIN='), + ); + lines.push(`MOSAIC_GIT_IDENTITY=${identity}`, `GITEA_LOGIN=${identity}`); + const temp = `${options.seatEnv}.${process.pid.toString()}.tmp`; + const handle = await open(temp, 'wx', 0o600); + try { + await handle.writeFile(`${lines.filter(Boolean).join('\n')}\n`, 'utf8'); + await handle.sync(); + } finally { + await handle.close(); + } + await rename(temp, options.seatEnv); + await journal.recordMutation('wire-applied'); + await journal.seal('ok', 'wire-verified'); + return localLifecycleResult('wire', identity, options, { + outcome: 'ok', + mutation: 'applied', + code: 'wire-verified', + message: 'Both fleet identity axes were written to the explicit seat environment.', + audit: { journalId: journal.journalId(), state: 'sealed' }, + }); + } catch { + await journal.seal('error', 'wire-failed'); + return localLifecycleResult('wire', identity, options, { + outcome: 'error', + mutation: 'unknown', + code: 'wire-failed', + message: 'Seat environment wiring failed.', + audit: { journalId: journal.journalId(), state: 'sealed' }, + }); + } +} + +export async function executeCredentialGet( + identity: string, + options: CredentialLifecycleCommandOptions, +): Promise { + const locations = lifecycleLocations(options); + const journal = await CredentialAuditJournal.open(locations.stateRoot, { + operation: 'get', + actor: options.actor, + identity, + estate: options.estate, + host: options.host, + repo: null, + }); + await journal.recordIntent('get-requested'); + try { + const fd = Number(options.outputFd); + const stat = fstatSync(fd); + if ( + !Number.isSafeInteger(fd) || + fd < 3 || + stat.uid !== process.getuid?.() || + (stat.mode & 0o077) !== 0 + ) { + throw new Error('unsafe output fd'); + } + const context = await lifecycleContext(options); + const resolved = await new FileCredentialResolver( + lifecycleLocations(options).tokenDirectory, + context.registry, + ).resolve(identity, options.estate, options.host); + if (resolved === undefined) { + await journal.seal('refused', 'no-token-for-identity'); + return localLifecycleResult('get', identity, options, { + outcome: 'refused', + code: 'no-token-for-identity', + message: 'No exact governed credential exists.', + audit: { journalId: journal.journalId(), state: 'sealed' }, + }); + } + const prefix = new TextEncoder().encode( + `protocol=https\nhost=${options.host}\nusername=${identity}\npassword=`, + ); + writeSync(fd, prefix); + writeSync(fd, resolved.secret); + writeSync(fd, new TextEncoder().encode('\n\n')); + await journal.recordMutation('credential-issued'); + await journal.seal('ok', 'get-verified'); + return localLifecycleResult('get', identity, options, { + outcome: 'ok', + code: 'get-verified', + message: 'Credential was emitted only to the protected output fd.', + audit: { journalId: journal.journalId(), state: 'sealed' }, + }); + } catch { + await journal.seal('error', 'insecure-credential-destination'); + return localLifecycleResult('get', identity, options, { + outcome: 'error', + code: 'insecure-credential-destination', + message: 'Protected credential output fd was unavailable or unsafe.', + audit: { journalId: journal.journalId(), state: 'sealed' }, + }); + } +} + +export async function executeCredentialList( + options: CredentialLifecycleCommandOptions, +): Promise { + try { + const context = await lifecycleContext(options); + const identities = await context.store.list(options.estate, options.host); + return localLifecycleResult('list', 'all', options, { + outcome: 'ok', + code: 'list-verified', + message: 'Governed credential bindings were listed without secrets.', + evidence: { providerIdentity: null, token: null, teaLogin: null, identities, journalIds: [] }, + }); + } catch { + return localLifecycleResult('list', 'all', options, { + outcome: 'error', + code: 'internal-invariant', + message: 'Credential listing failed.', + }); + } +} + +export async function executeCredentialAudit( + options: CredentialLifecycleCommandOptions, +): Promise { + const journals = await listCredentialJournals(lifecycleLocations(options).stateRoot); + return localLifecycleResult('audit', 'all', options, { + outcome: 'ok', + code: 'audit-verified', + message: 'Durable journal index was read.', + evidence: { + providerIdentity: null, + token: null, + teaLogin: null, + identities: [], + journalIds: journals.map((journal): string => journal.id), + }, + }); +} + type PrintableCredentialResult = Pick< - CredentialValidationResultDto | CredentialGrantResultDto | TeamGrantResult, + | CredentialValidationResultDto + | CredentialGrantResultDto + | TeamGrantResult + | CredentialLifecycleResultDto, 'operation' | 'outcome' | 'exitCode' | 'reason' >; @@ -306,6 +740,125 @@ export function registerCredentialCommand(parent: Command): void { cred.outputHelp(); }); + cred + .command('provision ') + .description('Mint, scope-read-back, store, and bind a provider credential') + .requiredOption('--estate ', 'Explicit target estate') + .requiredOption('--host ', 'Explicit provider host') + .requiredOption('--actor ', 'Explicit delegated Basic authority identity') + .requiredOption('--authority-fd ', 'Inherited protected Basic credential fd') + .requiredOption('--token-name ', 'Unique provider token name') + .requiredOption('--scopes ', 'Comma-separated least-privilege token scopes') + .option('--tea-config ', 'Tea login configuration path') + .option('--registry ', 'Strict non-secret estate registry') + .option('--token-dir ', 'Governed token directory') + .option('--state-dir ', 'Durable credential journal root') + .option('--json', 'Emit one machine result object') + .action(async (identity: string, options: CredentialLifecycleCommandOptions): Promise => { + const result = await executeCredentialProvision(identity, options); + printCredentialResult(result, options.json === true); + process.exitCode = result.exitCode; + }); + + cred + .command('wire ') + .description('Idempotently wire both explicit fleet identity axes into a seat environment') + .requiredOption('--estate ', 'Explicit target estate') + .requiredOption('--host ', 'Explicit provider host') + .requiredOption('--actor ', 'Explicit audit actor') + .requiredOption('--seat-env ', 'Explicit seat environment file') + .option('--state-dir ', 'Durable credential journal root') + .option('--json', 'Emit one machine result object') + .action(async (identity: string, options: CredentialLifecycleCommandOptions): Promise => { + const result = await executeCredentialWire(identity, options); + printCredentialResult(result, options.json === true); + process.exitCode = result.exitCode; + }); + + cred + .command('get ') + .description('Emit one exact credential only to a protected inherited fd') + .requiredOption('--estate ', 'Explicit target estate') + .requiredOption('--host ', 'Explicit provider host') + .requiredOption('--actor ', 'Explicit audit actor') + .requiredOption('--output-fd ', 'Protected inherited credential output fd') + .option('--registry ', 'Strict non-secret estate registry') + .option('--token-dir ', 'Governed token directory') + .option('--state-dir ', 'Durable credential journal root') + .option('--json', 'Emit one non-secret machine result object') + .action(async (identity: string, options: CredentialLifecycleCommandOptions): Promise => { + const result = await executeCredentialGet(identity, options); + printCredentialResult(result, options.json === true); + process.exitCode = result.exitCode; + }); + + cred + .command('rotate ') + .description('Read back a replacement token before revoking the old token') + .requiredOption('--estate ', 'Explicit target estate') + .requiredOption('--host ', 'Explicit provider host') + .requiredOption('--actor ', 'Explicit delegated Basic authority identity') + .requiredOption('--authority-fd ', 'Inherited protected Basic credential fd') + .requiredOption('--token-name ', 'Unique replacement provider token name') + .option('--scopes ', 'Replacement scopes; defaults to existing exact scopes') + .option('--tea-config ', 'Tea login configuration path') + .option('--registry ', 'Strict non-secret estate registry') + .option('--token-dir ', 'Governed token directory') + .option('--state-dir ', 'Durable credential journal root') + .option('--json', 'Emit one machine result object') + .action(async (identity: string, options: CredentialLifecycleCommandOptions): Promise => { + const result = await executeCredentialRotate(identity, options); + printCredentialResult(result, options.json === true); + process.exitCode = result.exitCode; + }); + + cred + .command('revoke ') + .description('Revoke the provider token before removing its governed local binding') + .requiredOption('--estate ', 'Explicit target estate') + .requiredOption('--host ', 'Explicit provider host') + .requiredOption('--actor ', 'Explicit delegated Basic authority identity') + .requiredOption('--authority-fd ', 'Inherited protected Basic credential fd') + .option('--registry ', 'Strict non-secret estate registry') + .option('--token-dir ', 'Governed token directory') + .option('--state-dir ', 'Durable credential journal root') + .option('--json', 'Emit one machine result object') + .action(async (identity: string, options: CredentialLifecycleCommandOptions): Promise => { + const result = await executeCredentialRevoke(identity, options); + printCredentialResult(result, options.json === true); + process.exitCode = result.exitCode; + }); + + cred + .command('list') + .description('List governed identities without reading or printing secret material') + .requiredOption('--estate ', 'Explicit target estate') + .requiredOption('--host ', 'Explicit provider host') + .requiredOption('--actor ', 'Explicit audit actor') + .option('--registry ', 'Strict non-secret estate registry') + .option('--token-dir ', 'Governed token directory') + .option('--state-dir ', 'Durable credential journal root') + .option('--json', 'Emit one machine result object') + .action(async (options: CredentialLifecycleCommandOptions): Promise => { + const result = await executeCredentialList(options); + printCredentialResult(result, options.json === true); + process.exitCode = result.exitCode; + }); + + cred + .command('audit') + .description('List durable credential journals without secret-bearing payloads') + .requiredOption('--estate ', 'Explicit target estate') + .requiredOption('--host ', 'Explicit provider host') + .requiredOption('--actor ', 'Explicit audit actor') + .option('--state-dir ', 'Durable credential journal root') + .option('--json', 'Emit one machine result object') + .action(async (options: CredentialLifecycleCommandOptions): Promise => { + const result = await executeCredentialAudit(options); + printCredentialResult(result, options.json === true); + process.exitCode = result.exitCode; + }); + cred .command('grant ') .description('Grant repository permission and accept only provider object read-back') @@ -354,4 +907,21 @@ export function registerCredentialCommand(parent: Command): void { printCredentialResult(result, options.json === true); process.exitCode = result.exitCode; }); + + cred + .command('whoami ') + .description('Read back runtime identity when scoped, otherwise report identity not measured') + .requiredOption('--estate ', 'Explicit target estate') + .requiredOption('--host ', 'Explicit provider host') + .requiredOption('--repo ', 'In-scope capability repository') + .option('--registry ', 'Strict non-secret estate registry') + .option('--token-dir ', 'Governed token directory') + .option('--state-dir ', 'Durable credential journal root') + .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' }); + printCredentialResult(result, options.json === true); + process.exitCode = result.exitCode; + }); } diff --git a/packages/mosaic/src/credentials/audit-journal.dto.ts b/packages/mosaic/src/credentials/audit-journal.dto.ts index 71ffde27..eac425e5 100644 --- a/packages/mosaic/src/credentials/audit-journal.dto.ts +++ b/packages/mosaic/src/credentials/audit-journal.dto.ts @@ -5,7 +5,10 @@ export type CredentialJournalOperation = | 'get' | 'validate' | 'rotate' - | 'revoke'; + | 'revoke' + | 'whoami' + | 'list' + | 'audit'; export interface CredentialJournalContextDto { readonly operation: CredentialJournalOperation; diff --git a/packages/mosaic/src/credentials/audit-journal.spec.ts b/packages/mosaic/src/credentials/audit-journal.spec.ts index fe8530df..010acc6a 100644 --- a/packages/mosaic/src/credentials/audit-journal.spec.ts +++ b/packages/mosaic/src/credentials/audit-journal.spec.ts @@ -51,7 +51,7 @@ describe('credential durable audit journal', (): void => { it('leaves an unsealed journal visible for recovery', async (): Promise => { const root = await stateRoot(); - await CredentialAuditJournal.open(root, { + const journal = await CredentialAuditJournal.open(root, { operation: 'rotate', actor: 'provisioner', identity: 'seat-name', @@ -64,6 +64,7 @@ describe('credential durable audit journal', (): void => { expect(journals).toHaveLength(1); expect(journals[0]?.state).toBe('open'); + await journal.closeIncomplete(); }); it('fails fatally when the durable journal root cannot be created', async (): Promise => { @@ -103,6 +104,7 @@ describe('credential durable audit journal', (): void => { const journals = await listCredentialJournals(root); const source = await readFile(journals[0]?.path ?? '', 'utf8'); expect(source).not.toContain('seeded-secret-canary'); + await journal.closeIncomplete(); }); it('supersedes a false sealed classification without editing the original journal', async (): Promise => { diff --git a/packages/mosaic/src/credentials/audit-journal.ts b/packages/mosaic/src/credentials/audit-journal.ts index ff5c592b..e95431d3 100644 --- a/packages/mosaic/src/credentials/audit-journal.ts +++ b/packages/mosaic/src/credentials/audit-journal.ts @@ -20,6 +20,7 @@ const SAFE_ENDPOINT = /^(?:GET|PUT|POST|DELETE) \/[A-Za-z0-9_./{}:-]+$/; const SAFE_CONTENT_TYPE = /^[A-Za-z0-9!#$&^_.+/-]+(?:;[A-Za-z0-9=._+-]+)*$/; const SAFE_DECISIONS = new Set([ 'provider-grant', + 'permission-none', 'permission-read', 'permission-write', 'permission-admin', @@ -29,10 +30,27 @@ const SAFE_DECISIONS = new Set([ 'revoke-verified', 'rotate-verified', 'validation-requested', + 'provision-requested', + 'rotate-requested', + 'revoke-requested', + 'wire-requested', + 'get-requested', 'validation-verified', 'team-member-present', 'team-repository-present', + 'team-repository-set-verified', 'organization-member-present', + 'organization-member-absent', + 'collaborator-grant-applied', + 'team-member-applied', + 'team-repository-applied', + 'transport-write-verified', + 'token-mint-applied', + 'token-binding-stored', + 'tea-login-stored', + 'token-revoke-applied', + 'wire-applied', + 'credential-issued', 'classification-correction', ]); @@ -159,6 +177,16 @@ export class CredentialAuditJournal { await this.append({ phase: 'provider-evidence', at: this.now(), evidence }); } + async recordMutation(decision: string): Promise { + if (!SAFE_DECISIONS.has(decision)) { + throw new CredentialJournalError( + 'unsafe-audit-value', + 'mutation decision is outside the non-secret allowlist', + ); + } + await this.append({ phase: 'mutation', at: this.now(), decision }); + } + async recordCorrection(correction: CredentialJournalCorrectionDto): Promise { if ( !SAFE_NAME.test(correction.supersedesJournalId) || diff --git a/packages/mosaic/src/credentials/credential-binding.dto.ts b/packages/mosaic/src/credentials/credential-binding.dto.ts new file mode 100644 index 00000000..ac62bfcc --- /dev/null +++ b/packages/mosaic/src/credentials/credential-binding.dto.ts @@ -0,0 +1,10 @@ +export interface CredentialBindingMetadataDto { + readonly schemaVersion?: 1; + readonly identity: string; + readonly estate: string; + readonly host: string; + readonly providerLogin: string; + readonly tokenName: string; + readonly scopes: readonly string[]; + readonly createdAt: string; +} diff --git a/packages/mosaic/src/credentials/credential-result.dto.ts b/packages/mosaic/src/credentials/credential-result.dto.ts index dd54f888..bb0f181c 100644 --- a/packages/mosaic/src/credentials/credential-result.dto.ts +++ b/packages/mosaic/src/credentials/credential-result.dto.ts @@ -1,6 +1,6 @@ export type CredentialOutcome = 'ok' | 'refused' | 'error' | 'indeterminate'; export type CredentialMutationState = 'none' | 'not-started' | 'applied' | 'unknown'; -export type RepositoryPermission = 'read' | 'write' | 'admin'; +export type RepositoryPermission = 'none' | 'read' | 'write' | 'admin'; export type ReceivePackState = 'advertised' | 'refused'; export interface CredentialReasonDto { diff --git a/packages/mosaic/src/credentials/credential-validate-service.ts b/packages/mosaic/src/credentials/credential-validate-service.ts index 101cee36..ad3f4a02 100644 --- a/packages/mosaic/src/credentials/credential-validate-service.ts +++ b/packages/mosaic/src/credentials/credential-validate-service.ts @@ -16,6 +16,7 @@ export interface CredentialValidationServiceOptions { } function permissionDecision(permission: RepositoryPermission): string { + if (permission === 'none') return 'permission-none'; if (permission === 'admin') return 'permission-admin'; if (permission === 'write') return 'permission-write'; return 'permission-read'; diff --git a/packages/mosaic/src/credentials/delegated-credential.ts b/packages/mosaic/src/credentials/delegated-credential.ts index 84bca6ec..2ab04555 100644 --- a/packages/mosaic/src/credentials/delegated-credential.ts +++ b/packages/mosaic/src/credentials/delegated-credential.ts @@ -1,6 +1,5 @@ import { randomUUID } from 'node:crypto'; -import { fstatSync } from 'node:fs'; -import { readFile } from 'node:fs/promises'; +import { createReadStream, fstatSync } from 'node:fs'; import { z } from 'zod'; import type { ResolvedCredential } from './credential-provider.dto.js'; @@ -27,6 +26,31 @@ export class DelegatedCredentialError extends Error { } } +async function readProtectedFd(fd: number): Promise { + const controller = new AbortController(); + const timeout = setTimeout((): void => controller.abort(), 5_000); + const chunks: Buffer[] = []; + let total = 0; + try { + const stream = createReadStream(`/proc/self/fd/${fd}`, { + highWaterMark: 4 * 1024, + signal: controller.signal, + }); + for await (const chunk of stream) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + total += bytes.byteLength; + if (total > 32 * 1024) { + stream.destroy(); + throw new Error('protected credential payload exceeded the bound'); + } + chunks.push(bytes); + } + return Buffer.concat(chunks, total); + } finally { + clearTimeout(timeout); + } +} + export async function readDelegatedCredentialFromFd( fd: number, expectedIdentity: string, @@ -46,7 +70,7 @@ export async function readDelegatedCredentialFromFd( if (currentUid === undefined || stat.uid !== currentUid || (stat.mode & 0o077) !== 0) { throw new Error('fd owner or permissions are unsafe'); } - bytes = await readFile(`/proc/self/fd/${fd}`); + bytes = await readProtectedFd(fd); } catch { throw new DelegatedCredentialError( 'delegated-authority-unavailable', diff --git a/packages/mosaic/src/credentials/file-credential-store.spec.ts b/packages/mosaic/src/credentials/file-credential-store.spec.ts index 35dac898..7d456651 100644 --- a/packages/mosaic/src/credentials/file-credential-store.spec.ts +++ b/packages/mosaic/src/credentials/file-credential-store.spec.ts @@ -5,7 +5,7 @@ import { mkdtemp } from 'node:fs/promises'; import { afterEach, describe, expect, it } from 'vitest'; import { rm } from 'node:fs/promises'; import { parseCredentialEstateRegistry } from './estate-registry.js'; -import { FileCredentialResolver } from './file-credential-store.js'; +import { FileCredentialResolver, FileCredentialStore } from './file-credential-store.js'; let cleanup: string | undefined; @@ -56,6 +56,19 @@ describe('phase-1 governed file credential resolver', (): void => { expect(wrongEstate).toBeUndefined(); }); + it('rejects a group-writable token directory even when the token file is private', async (): Promise => { + const root = await fixtureRoot(); + await writeFile(join(root, 'gitea-example-seat-name.token'), 'private-token', { + mode: 0o600, + }); + await chmod(root, 0o770); + const resolver = new FileCredentialResolver(root, registry()); + + await expect(resolver.resolve('seat-name', 'homelab', 'git.example.invalid')).rejects.toThrow( + /insecure-token-owner/, + ); + }); + it('rejects a token file with group or other permissions', async (): Promise => { const root = await fixtureRoot(); const path = join(root, 'gitea-example-seat.token'); @@ -89,6 +102,40 @@ describe('phase-1 governed file credential resolver', (): void => { ); }); + it('atomically stores, lists, reads binding metadata, and removes a governed credential', async (): Promise => { + const root = await fixtureRoot(); + const store = new FileCredentialStore(root, registry()); + await store.put( + { + identity: 'seat', + estate: 'homelab', + host: 'git.example.invalid', + providerLogin: 'seat', + tokenName: 'mosaic-seat-1', + scopes: ['write:repository'], + createdAt: '2026-08-05T00:00:00.000Z', + }, + new TextEncoder().encode('new-private-token'), + ); + + await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual(['seat']); + await expect( + store.readBinding('seat', 'homelab', 'git.example.invalid'), + ).resolves.toMatchObject({ + providerLogin: 'seat', + tokenName: 'mosaic-seat-1', + }); + await expect( + new FileCredentialResolver(root, registry()).resolve( + 'seat', + 'homelab', + 'git.example.invalid', + ), + ).resolves.toMatchObject({ identity: 'seat' }); + await store.remove('seat', 'homelab', 'git.example.invalid'); + await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual([]); + }); + it('returns undefined for an absent token without borrowing another identity', async (): Promise => { const root = await fixtureRoot(); await writeFile(join(root, 'gitea-example-shared.token'), 'shared-canary', { mode: 0o600 }); diff --git a/packages/mosaic/src/credentials/file-credential-store.ts b/packages/mosaic/src/credentials/file-credential-store.ts index 119aa8fd..88ddf769 100644 --- a/packages/mosaic/src/credentials/file-credential-store.ts +++ b/packages/mosaic/src/credentials/file-credential-store.ts @@ -1,11 +1,31 @@ import { randomUUID } from 'node:crypto'; +import { lstatSync } from 'node:fs'; +import { open, readdir, rename, unlink } from 'node:fs/promises'; import { join } from 'node:path'; -import { readRegularFileSecure, type SecureFileSnapshot } from '../fleet/secure-file.js'; +import { z } from 'zod'; +import { + ensureManagedDirectory, + readRegularFileSecure, + type SecureFileSnapshot, +} from '../fleet/secure-file.js'; +import type { CredentialBindingMetadataDto } from './credential-binding.dto.js'; import type { CredentialResolver, ResolvedCredential } from './credential-provider.dto.js'; import type { ParsedCredentialEstateRegistry } from './estate-registry.js'; const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/; const MAX_TOKEN_BYTES = 16 * 1024; +const bindingSchema = z + .object({ + schemaVersion: z.literal(1), + identity: z.string().regex(IDENTITY), + estate: z.string().min(1), + host: z.string().min(1), + providerLogin: z.string().regex(IDENTITY), + tokenName: z.string().regex(IDENTITY), + scopes: z.array(z.string().regex(/^[a-z]+(?::[a-z]+)?$/)).max(32), + createdAt: z.string().datetime(), + }) + .strict(); export class CredentialStoreError extends Error { constructor( @@ -61,6 +81,23 @@ export class FileCredentialResolver implements CredentialResolver { const hostConfig = this.estateRegistry.resolve(estate, host); if (hostConfig === undefined) return undefined; + const currentUid = process.getuid?.(); + if (currentUid === undefined) { + throw new CredentialStoreError('insecure-token-owner', 'runtime uid is unavailable'); + } + const directory = lstatSync(this.tokenDirectory); + if ( + !directory.isDirectory() || + directory.isSymbolicLink() || + directory.uid !== currentUid || + (directory.mode & 0o022) !== 0 + ) { + throw new CredentialStoreError( + 'insecure-token-owner', + 'token directory ownership or write permissions are unsafe', + ); + } + const path = join(this.tokenDirectory, `${hostConfig.tokenPrefix}-${identity}.token`); let snapshot: SecureFileSnapshot; try { @@ -74,6 +111,12 @@ export class FileCredentialResolver implements CredentialResolver { } const permissions = snapshot.mode & 0o777; + if (snapshot.uid !== currentUid) { + throw new CredentialStoreError( + 'insecure-token-owner', + 'token file is not owned by the runtime uid', + ); + } if ((permissions & 0o077) !== 0) { throw new CredentialStoreError( 'insecure-token-mode', @@ -90,3 +133,122 @@ export class FileCredentialResolver implements CredentialResolver { }); } } + +async function syncDirectory(path: string): Promise { + const handle = await open(path, 'r'); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} + +export class FileCredentialStore { + constructor( + private readonly tokenDirectory: string, + private readonly estateRegistry: ParsedCredentialEstateRegistry, + ) {} + + private paths( + identity: string, + estate: string, + host: string, + ): { + readonly token: string; + readonly binding: string; + readonly prefix: string; + } { + if (!IDENTITY.test(identity)) { + throw new CredentialStoreError( + 'invalid-identity', + 'identity is outside the allowlist grammar', + ); + } + const config = this.estateRegistry.resolve(estate, host); + if (config === undefined) { + throw new CredentialStoreError('estate-host-mismatch', 'estate and host do not match'); + } + const prefix = `${config.tokenPrefix}-${identity}`; + return { + token: join(this.tokenDirectory, `${prefix}.token`), + binding: join(this.tokenDirectory, `${prefix}.binding.json`), + prefix, + }; + } + + async put(metadata: CredentialBindingMetadataDto, secret: Uint8Array): Promise { + 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 suffix = randomUUID(); + const tokenTemp = `${paths.token}.${suffix}.tmp`; + const bindingTemp = `${paths.binding}.${suffix}.tmp`; + 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(); + } + await rename(bindingTemp, paths.binding); + await rename(tokenTemp, paths.token); + await syncDirectory(this.tokenDirectory); + } + + async readBinding( + identity: string, + estate: string, + host: string, + ): Promise { + const paths = this.paths(identity, estate, host); + let snapshot: SecureFileSnapshot; + try { + snapshot = readRegularFileSecure(paths.binding, { + root: this.tokenDirectory, + maxBytes: 64 * 1024, + }); + } catch (error: unknown) { + if (isMissingFile(error)) return undefined; + throw error; + } + if ((snapshot.mode & 0o077) !== 0 || snapshot.uid !== process.getuid?.()) { + throw new CredentialStoreError('insecure-token-owner', 'binding metadata is not private'); + } + const parsed = bindingSchema.safeParse(JSON.parse(snapshot.content.toString('utf8'))); + if (!parsed.success) { + throw new CredentialStoreError( + 'invalid-binding', + 'binding metadata failed schema validation', + ); + } + return parsed.data; + } + + async list(estate: string, host: string): Promise { + const config = this.estateRegistry.resolve(estate, host); + 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)) + .filter((identity): boolean => IDENTITY.test(identity)) + .sort(); + } + + async remove(identity: string, estate: string, host: string): Promise { + const paths = this.paths(identity, estate, host); + await unlink(paths.token).catch((error: unknown): void => { + if (!isMissingFile(error)) throw error; + }); + await unlink(paths.binding).catch((error: unknown): void => { + if (!isMissingFile(error)) throw error; + }); + await syncDirectory(this.tokenDirectory); + } +} diff --git a/packages/mosaic/src/credentials/gitea-provider.spec.ts b/packages/mosaic/src/credentials/gitea-provider.spec.ts index 301a3677..faa23cfc 100644 --- a/packages/mosaic/src/credentials/gitea-provider.spec.ts +++ b/packages/mosaic/src/credentials/gitea-provider.spec.ts @@ -195,7 +195,11 @@ describe('Gitea credential provider transport', (): void => { return jsonResponse({ id: 21, login: 'seat-name' }); } if (url.includes('/repos/owner/repo')) { - return jsonResponse({ id: 4, full_name: 'owner/repo' }); + return jsonResponse({ + id: 4, + full_name: 'owner/repo', + permissions: { admin: false, push: true, pull: true }, + }); } return jsonResponse({ message: 'unexpected' }, 500); }, @@ -213,6 +217,34 @@ describe('Gitea credential provider transport', (): void => { expect(team).toMatchObject({ id: 7, name: 'writers', permission: 'write' }); }); + it('rejects a successful team read-back that names the wrong object', async (): Promise => { + const adapter = new GiteaTeamGrantProviderAdapter( + 'https://git.example.invalid', + async (): Promise => jsonResponse({ id: 99, login: 'other-seat' }), + ); + + await expect(adapter.readTeamMember(credential, 7, 'seat-name')).rejects.toMatchObject({ + code: 'unexpected-provider-shape', + }); + }); + + it('bounds a provider that never returns response headers', async (): Promise => { + const adapter = new GiteaCredentialProviderAdapter( + 'https://git.example.invalid', + async (_input: string | URL | Request, init?: RequestInit): Promise => + new Promise((_resolve, reject): void => { + init?.signal?.addEventListener('abort', (): void => { + reject(new Error('aborted')); + }); + }), + 10, + ); + + await expect(adapter.readIdentity(credential)).rejects.toMatchObject({ + code: 'provider-unavailable', + }); + }); + it('never includes seeded secret material in provider error messages', async (): Promise => { const adapter = new GiteaCredentialProviderAdapter( 'https://git.example.invalid', diff --git a/packages/mosaic/src/credentials/gitea-provider.ts b/packages/mosaic/src/credentials/gitea-provider.ts index 8ce52a3f..5492b7f5 100644 --- a/packages/mosaic/src/credentials/gitea-provider.ts +++ b/packages/mosaic/src/credentials/gitea-provider.ts @@ -1,6 +1,8 @@ import { z } from 'zod'; import type { GiteaCredentialProvider, ResolvedCredential } from './credential-provider.dto.js'; import type { GiteaGrantProvider } from './grant.js'; +import type { GiteaLifecycleProvider, MintedToken } from './lifecycle.js'; +import type { TokenObjectEvidenceDto } from './lifecycle.dto.js'; import type { GiteaTeamGrantProvider, PresenceEvidence, @@ -43,6 +45,14 @@ const collaboratorPermissionSchema = z .passthrough(); const organizationSchema = z.object({ username: z.string().min(1) }).passthrough(); +const tokenObjectSchema = z + .object({ + name: z.string().min(1), + sha1: z.string().min(1).optional(), + token: z.string().min(1).optional(), + scopes: z.array(z.string()).default([]), + }) + .passthrough(); const teamSchema = z .object({ id: z.number().int().positive(), @@ -202,7 +212,8 @@ function effectivePermission(permissions: { }): RepositoryPermission { if (permissions.admin) return 'admin'; if (permissions.push) return 'write'; - return 'read'; + if (permissions.pull) return 'read'; + return 'none'; } export class GiteaCredentialProviderAdapter implements GiteaCredentialProvider { @@ -211,14 +222,27 @@ export class GiteaCredentialProviderAdapter implements GiteaCredentialProvider { constructor( apiBaseUrl: string, private readonly fetchImpl: FetchLike = fetch, + private readonly requestTimeoutMs = 10_000, ) { const parsed = new URL(apiBaseUrl); this.origin = parsed.origin; + if ( + !Number.isSafeInteger(requestTimeoutMs) || + requestTimeoutMs < 1 || + requestTimeoutMs > 30_000 + ) { + throw new CredentialProviderEvidenceError( + 'invalid-input', + 'provider request timeout is outside the bounded range', + ); + } } protected async request(url: string, init: RequestInit): Promise { + const deadline = AbortSignal.timeout(this.requestTimeoutMs); + const signal = init.signal == null ? deadline : AbortSignal.any([init.signal, deadline]); try { - return await this.fetchImpl(url, init); + return await this.fetchImpl(url, { ...init, signal }); } catch { throw new CredentialProviderEvidenceError( 'provider-unavailable', @@ -610,6 +634,10 @@ export class GiteaTeamGrantProviderAdapter return this.readPresence( authority, `GET /api/v1/teams/${teamId.toString()}/members/${encodeURIComponent(identity)}`, + (value: unknown): boolean => { + const parsed = userSchema.safeParse(value); + return parsed.success && parsed.data.login === identity; + }, ); } @@ -622,12 +650,17 @@ export class GiteaTeamGrantProviderAdapter return this.readPresence( authority, `GET /api/v1/teams/${teamId.toString()}/repos/${owner}/${name}`, + (value: unknown): boolean => { + const parsed = repoSchema.safeParse(value); + return parsed.success && parsed.data.full_name === repo; + }, ); } private async readPresence( authority: ResolvedCredential, endpoint: string, + matchesExpectedObject: (value: unknown) => boolean, ): Promise { const response = await this.request(`${this.origin}${endpoint.slice(4)}`, { method: 'GET', @@ -645,7 +678,155 @@ export class GiteaTeamGrantProviderAdapter await boundedBody(response); throw new CredentialProviderEvidenceError('readback-missing', 'team read-back failed'); } - await boundedBody(response); + if (!matchesExpectedObject(await jsonObject(response))) { + throw new CredentialProviderEvidenceError( + 'unexpected-provider-shape', + 'team read-back did not identify the requested object', + ); + } return { state: 'present', endpoint, contentType: contentType(response) }; } } + +function basicAuthorization(authority: ResolvedCredential): string { + const prefix = Buffer.from(`${authority.identity}:`, 'utf8'); + const material = Buffer.concat([prefix, Buffer.from(authority.secret)]); + try { + return `Basic ${material.toString('base64')}`; + } finally { + prefix.fill(0); + material.fill(0); + } +} + +export class GiteaLifecycleProviderAdapter + extends GiteaCredentialProviderAdapter + implements GiteaLifecycleProvider +{ + async readBasicIdentity(authority: ResolvedCredential): Promise { + const endpoint = 'GET /api/v1/user'; + const response = await this.request(`${this.origin}/api/v1/user`, { + method: 'GET', + headers: { + Accept: JSON_CONTENT_TYPE, + Authorization: basicAuthorization(authority), + 'User-Agent': USER_AGENT, + }, + }); + if (!response.ok) { + await boundedBody(response); + throw new CredentialProviderEvidenceError( + 'credential-rejected', + 'delegated Basic authority was rejected', + ); + } + const parsed = userSchema.safeParse(await jsonObject(response)); + if (!parsed.success) { + throw new CredentialProviderEvidenceError( + 'unexpected-provider-shape', + 'delegated Basic identity response was malformed', + ); + } + return { login: parsed.data.login, endpoint, contentType: contentType(response) }; + } + + async mintToken( + authority: ResolvedCredential, + identity: string, + name: string, + scopes: readonly string[], + ): Promise { + const endpoint = `POST /api/v1/users/${identity}/tokens`; + const response = await this.request( + `${this.origin}/api/v1/users/${encodeURIComponent(identity)}/tokens`, + { + method: 'POST', + headers: { + Accept: JSON_CONTENT_TYPE, + 'Content-Type': JSON_CONTENT_TYPE, + Authorization: basicAuthorization(authority), + 'User-Agent': USER_AGENT, + }, + body: JSON.stringify({ name, scopes }), + }, + ); + if (!response.ok) { + await boundedBody(response); + throw new CredentialProviderEvidenceError('provider-unavailable', 'token mint failed'); + } + const parsed = tokenObjectSchema.safeParse(await jsonObject(response)); + const secret = parsed.success ? (parsed.data.sha1 ?? parsed.data.token) : undefined; + if (!parsed.success || secret === undefined || parsed.data.name !== name) { + throw new CredentialProviderEvidenceError( + 'unexpected-provider-shape', + 'minted token object lacked the requested name or secret', + ); + } + return { + secret: new TextEncoder().encode(secret), + evidence: { + name: parsed.data.name, + scopes: parsed.data.scopes, + endpoint, + contentType: contentType(response), + }, + }; + } + + async readToken( + authority: ResolvedCredential, + identity: string, + name: string, + ): Promise { + const endpoint = `GET /api/v1/users/${identity}/tokens`; + 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 list read-back failed'); + } + const parsed = z.array(tokenObjectSchema).safeParse(await jsonObject(response)); + const matches = parsed.success + ? parsed.data.filter((token): boolean => token.name === name) + : []; + if (matches.length !== 1 || matches[0] === undefined) { + throw new CredentialProviderEvidenceError( + 'readback-missing', + 'minted token did not resolve uniquely by name', + ); + } + return { + name: matches[0].name, + scopes: matches[0].scopes, + endpoint, + contentType: contentType(response), + }; + } + + async revokeToken(authority: ResolvedCredential, identity: string, name: string): Promise { + const response = await this.request( + `${this.origin}/api/v1/users/${encodeURIComponent(identity)}/tokens/${encodeURIComponent(name)}`, + { + method: 'DELETE', + headers: { + Accept: JSON_CONTENT_TYPE, + Authorization: basicAuthorization(authority), + 'User-Agent': USER_AGENT, + }, + }, + ); + await boundedBody(response); + if (!response.ok) { + throw new CredentialProviderEvidenceError('mutation-state-unknown', 'token revoke failed'); + } + } +} diff --git a/packages/mosaic/src/credentials/grant.spec.ts b/packages/mosaic/src/credentials/grant.spec.ts index 26838b90..8eb62b70 100644 --- a/packages/mosaic/src/credentials/grant.spec.ts +++ b/packages/mosaic/src/credentials/grant.spec.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm } from 'node:fs/promises'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; @@ -130,6 +130,13 @@ describe('direct repository grant', (): void => { expect(result.evidence.repositoryPermission?.effective).toBe('write'); expect(result.evidence.organizationMembership?.state).toBe('absent'); expect(result.audit.state).toBe('sealed'); + const [sealed] = await listCredentialJournals(root); + const source = await readFile(sealed?.path ?? '', 'utf8'); + expect(source).toContain('"phase":"mutation"'); + expect(source).toContain('"decision":"collaborator-grant-applied"'); + expect(source).toContain('"decision":"identity-verified"'); + expect(source).toContain('"decision":"organization-member-absent"'); + expect(source).toContain('"decision":"transport-write-verified"'); }); it('preserves applied mutation and journal context when post-grant read-back fails', async (): Promise => { diff --git a/packages/mosaic/src/credentials/grant.ts b/packages/mosaic/src/credentials/grant.ts index fa63e38a..fda7b5a8 100644 --- a/packages/mosaic/src/credentials/grant.ts +++ b/packages/mosaic/src/credentials/grant.ts @@ -106,6 +106,11 @@ export async function grantDirectRepositoryPermission( audit: { journalId: journal.journalId(), state: 'sealed' }, }; } + await journal.recordProviderEvidence({ + endpoint: authorityIdentity.endpoint, + contentType: authorityIdentity.contentType, + decision: 'identity-verified', + }); mutation = 'unknown'; await grantProvider.grantCollaborator( authority, @@ -114,6 +119,7 @@ export async function grantDirectRepositoryPermission( request.permission, ); mutation = 'applied'; + await journal.recordMutation('collaborator-grant-applied'); const collaborator = await grantProvider.readCollaboratorPermission( authority, @@ -135,6 +141,34 @@ export async function grantDirectRepositoryPermission( ? await evaluateGiteaReadValidation(request, validationDependencies) : await evaluateGiteaWriteValidation(request, validationDependencies); + if (organizationMembership !== null) { + await journal.recordProviderEvidence({ + endpoint: organizationMembership.endpoint, + contentType: organizationMembership.contentType, + decision: + organizationMembership.state === 'present' + ? 'organization-member-present' + : 'organization-member-absent', + }); + } + if (validation.evidence.providerIdentity !== null) { + await journal.recordProviderEvidence({ + endpoint: validation.evidence.providerIdentity.endpoint, + contentType: validation.evidence.providerIdentity.contentType, + decision: 'identity-verified', + }); + } + if (validation.evidence.repositoryPermission !== null) { + await journal.recordProviderEvidence({ + endpoint: validation.evidence.repositoryPermission.endpoint, + contentType: validation.evidence.repositoryPermission.contentType, + decision: `permission-${validation.evidence.repositoryPermission.effective}`, + }); + } + if (validation.evidence.writeDifferential !== null) { + await journal.recordMutation('transport-write-verified'); + } + const readBackMatches = collaborator.identity === request.identity && collaborator.permission === request.permission && diff --git a/packages/mosaic/src/credentials/lifecycle.dto.ts b/packages/mosaic/src/credentials/lifecycle.dto.ts new file mode 100644 index 00000000..ec141cf4 --- /dev/null +++ b/packages/mosaic/src/credentials/lifecycle.dto.ts @@ -0,0 +1,49 @@ +import type { CredentialOutcome } from './credential-result.dto.js'; + +export type CredentialLifecycleOperation = + | 'provision' + | 'wire' + | 'get' + | 'whoami' + | 'list' + | 'rotate' + | 'revoke' + | 'audit'; + +export interface TokenObjectEvidenceDto { + readonly name: string; + readonly scopes: readonly string[]; + readonly endpoint: string; + readonly contentType: string; +} + +export interface CredentialLifecycleResultDto { + readonly schemaVersion: 1; + readonly operation: CredentialLifecycleOperation; + readonly outcome: CredentialOutcome; + readonly exitCode: 0 | 10 | 20 | 30; + readonly retryable: boolean; + readonly subject: { + readonly identity: string; + readonly estate: string; + readonly host: string; + readonly repo: null; + }; + readonly mutation: 'none' | 'unknown' | 'applied'; + readonly reason: { readonly code: string; readonly message: string }; + readonly evidence: { + readonly providerIdentity: string | null; + readonly token: TokenObjectEvidenceDto | null; + readonly teaLogin: { + readonly name: string; + readonly host: string; + readonly state: 'registered' | 'not-measured'; + } | null; + readonly identities: readonly string[]; + readonly journalIds: readonly string[]; + }; + readonly audit: { + readonly journalId: string | null; + readonly state: 'not-started' | 'open' | 'sealed'; + }; +} diff --git a/packages/mosaic/src/credentials/lifecycle.spec.ts b/packages/mosaic/src/credentials/lifecycle.spec.ts new file mode 100644 index 00000000..cce91727 --- /dev/null +++ b/packages/mosaic/src/credentials/lifecycle.spec.ts @@ -0,0 +1,143 @@ +import { mkdtemp, mkdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { ResolvedCredential } from './credential-provider.dto.js'; +import { parseCredentialEstateRegistry } from './estate-registry.js'; +import { FileCredentialStore } from './file-credential-store.js'; +import { provisionCredential, revokeCredential, type GiteaLifecycleProvider } from './lifecycle.js'; +import { TeaLoginStore } from './tea-login-store.js'; + +let cleanup: string | undefined; +afterEach(async (): Promise => { + if (cleanup !== undefined) await rm(cleanup, { recursive: true, force: true }); + cleanup = undefined; +}); + +async function fixture(): Promise<{ + root: string; + store: FileCredentialStore; + teaStore: TeaLoginStore; +}> { + cleanup = await mkdtemp(join(tmpdir(), 'mosaic-lifecycle-')); + const tokens = join(cleanup, 'tokens'); + await mkdir(tokens, { mode: 0o700 }); + const registry = parseCredentialEstateRegistry( + JSON.stringify({ + version: 1, + estates: [ + { + name: 'homelab', + readOnlyControlIdentity: 'control', + hosts: [ + { + host: 'git.example.invalid', + provider: 'gitea', + apiBaseUrl: 'https://git.example.invalid', + tokenPrefix: 'gitea-example', + }, + ], + }, + ], + }), + ); + return { + root: join(cleanup, 'state'), + store: new FileCredentialStore(tokens, registry), + teaStore: new TeaLoginStore(join(cleanup, 'tea', 'config.yml')), + }; +} + +const authority: ResolvedCredential = Object.freeze({ + identity: 'seat', + estate: 'homelab', + host: 'git.example.invalid', + resolutionId: 'basic-authority', + secret: new TextEncoder().encode('password-canary'), +}); + +function provider(): GiteaLifecycleProvider { + return { + async readBasicIdentity() { + return { login: 'seat', endpoint: 'GET /api/v1/user', contentType: 'application/json' }; + }, + async mintToken(_authority, _identity, name, scopes) { + return { + secret: new TextEncoder().encode('minted-token-canary'), + evidence: { + name, + scopes, + endpoint: 'POST /api/v1/users/seat/tokens', + contentType: 'application/json', + }, + }; + }, + async readToken(_authority, _identity, name) { + return { + name, + scopes: ['write:repository'], + endpoint: 'GET /api/v1/users/seat/tokens', + contentType: 'application/json', + }; + }, + async revokeToken(): Promise {}, + }; +} + +describe('credential lifecycle', (): void => { + it('accepts provision only after exact principal and scope read-back', async (): Promise => { + const { root, store, teaStore } = await fixture(); + const result = 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', now: (): string => '2026-08-05T00:00:00.000Z' }, + ); + expect(result.outcome).toBe('ok'); + await expect( + store.readBinding('seat', 'homelab', 'git.example.invalid'), + ).resolves.toMatchObject({ providerLogin: 'seat', scopes: ['write:repository'] }); + expect(JSON.stringify(result)).not.toContain('minted-token-canary'); + }); + + it('revokes at provider before removing the local binding', async (): Promise => { + 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' }, + ); + let revoked = false; + const lifecycleProvider = provider(); + lifecycleProvider.revokeToken = async (): Promise => { + revoked = true; + }; + const result = await revokeCredential( + { identity: 'seat', estate: 'homelab', host: 'git.example.invalid' }, + authority, + lifecycleProvider, + store, + { stateRoot: root, actor: 'seat' }, + ); + expect(result.outcome).toBe('ok'); + expect(revoked).toBe(true); + await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual([]); + }); +}); diff --git a/packages/mosaic/src/credentials/lifecycle.ts b/packages/mosaic/src/credentials/lifecycle.ts new file mode 100644 index 00000000..406ef554 --- /dev/null +++ b/packages/mosaic/src/credentials/lifecycle.ts @@ -0,0 +1,272 @@ +import { CredentialAuditJournal, CredentialJournalError } from './audit-journal.js'; +import type { ResolvedCredential } from './credential-provider.dto.js'; +import type { FileCredentialStore } from './file-credential-store.js'; +import type { TeaLoginStore } from './tea-login-store.js'; +import type { + CredentialLifecycleOperation, + CredentialLifecycleResultDto, + TokenObjectEvidenceDto, +} from './lifecycle.dto.js'; + +export interface MintedToken { + readonly secret: Uint8Array; + readonly evidence: TokenObjectEvidenceDto; +} + +export interface GiteaLifecycleProvider { + readBasicIdentity(authority: ResolvedCredential): Promise<{ + readonly login: string; + readonly endpoint: string; + readonly contentType: string; + }>; + mintToken( + authority: ResolvedCredential, + identity: string, + name: string, + scopes: readonly string[], + ): Promise; + readToken( + authority: ResolvedCredential, + identity: string, + name: string, + ): Promise; + revokeToken(authority: ResolvedCredential, identity: string, name: string): Promise; +} + +export interface LifecycleRequest { + readonly identity: string; + readonly estate: string; + readonly host: string; +} + +export interface ProvisionRequest extends LifecycleRequest { + readonly tokenName: string; + readonly scopes: readonly string[]; +} + +export interface LifecycleOptions { + readonly stateRoot: string; + readonly actor: string; + readonly now?: () => string; +} + +function lifecycleResult( + operation: CredentialLifecycleOperation, + request: LifecycleRequest, + options: { + readonly outcome: CredentialLifecycleResultDto['outcome']; + readonly mutation: CredentialLifecycleResultDto['mutation']; + readonly code: string; + readonly message: string; + readonly journalId: string | null; + readonly auditState: 'not-started' | 'open' | 'sealed'; + readonly providerIdentity?: string | null; + readonly token?: TokenObjectEvidenceDto | null; + readonly teaLogin?: CredentialLifecycleResultDto['evidence']['teaLogin']; + }, +): CredentialLifecycleResultDto { + const exits = { ok: 0, refused: 10, error: 20, indeterminate: 30 } as const; + return { + schemaVersion: 1, + operation, + outcome: options.outcome, + exitCode: exits[options.outcome], + retryable: false, + subject: { ...request, repo: null }, + mutation: options.mutation, + reason: { code: options.code, message: options.message }, + evidence: { + providerIdentity: options.providerIdentity ?? null, + token: options.token ?? null, + teaLogin: options.teaLogin ?? null, + identities: [], + journalIds: [], + }, + audit: { journalId: options.journalId, state: options.auditState }, + }; +} + +async function openLifecycleJournal( + operation: 'provision' | 'rotate' | 'revoke', + request: LifecycleRequest, + options: LifecycleOptions, +): Promise { + const journal = await CredentialAuditJournal.open(options.stateRoot, { + operation, + actor: options.actor, + identity: request.identity, + estate: request.estate, + host: request.host, + repo: null, + }); + await journal.recordIntent(`${operation}-requested`); + return journal; +} + +export async function provisionCredential( + request: ProvisionRequest, + authority: ResolvedCredential, + provider: GiteaLifecycleProvider, + store: FileCredentialStore, + teaStore: TeaLoginStore, + options: LifecycleOptions, +): Promise { + const journal = await openLifecycleJournal('provision', request, options); + let mutation: 'none' | 'unknown' | 'applied' = 'none'; + let minted: MintedToken | undefined; + try { + const identity = await provider.readBasicIdentity(authority); + if (identity.login !== request.identity || authority.identity !== request.identity) { + await journal.seal('refused', 'provider-identity-mismatch'); + return lifecycleResult('provision', 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'; + minted = await provider.mintToken( + authority, + request.identity, + request.tokenName, + request.scopes, + ); + mutation = 'applied'; + await journal.recordMutation('token-mint-applied'); + const readBack = await provider.readToken(authority, request.identity, request.tokenName); + 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, + }); + } + await store.put( + { + identity: request.identity, + estate: request.estate, + host: request.host, + providerLogin: identity.login, + tokenName: request.tokenName, + scopes: readBack.scopes, + createdAt: options.now?.() ?? new Date().toISOString(), + }, + minted.secret, + ); + 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, + }); + } + await journal.recordMutation('tea-login-stored'); + await journal.recordProviderEvidence({ + endpoint: readBack.endpoint, + contentType: readBack.contentType, + decision: 'scope-verified', + }); + await journal.seal('ok', 'provision-verified'); + return lifecycleResult('provision', request, { + outcome: 'ok', + mutation: 'applied', + code: 'provision-verified', + message: 'Provider principal and exact token scopes were read back and stored.', + journalId: journal.journalId(), + auditState: 'sealed', + providerIdentity: identity.login, + token: readBack, + teaLogin: { ...teaLogin, state: 'registered' }, + }); + } catch (error: unknown) { + if (error instanceof CredentialJournalError) throw error; + const code = mutation === 'none' ? 'provider-unavailable' : 'mutation-state-unknown'; + await journal.seal('indeterminate', code); + return lifecycleResult('provision', request, { + outcome: 'indeterminate', + mutation, + code, + message: 'Provisioning did not produce complete provider and storage evidence.', + journalId: journal.journalId(), + auditState: 'sealed', + }); + } finally { + minted?.secret.fill(0); + } +} + +export async function revokeCredential( + request: LifecycleRequest, + authority: ResolvedCredential, + provider: GiteaLifecycleProvider, + store: FileCredentialStore, + options: LifecycleOptions, +): Promise { + const journal = await openLifecycleJournal('revoke', request, options); + let mutation: 'none' | 'unknown' | 'applied' = 'none'; + try { + const binding = await store.readBinding(request.identity, request.estate, request.host); + if (binding === undefined) { + await journal.seal('refused', 'no-token-for-identity'); + return lifecycleResult('revoke', request, { + outcome: 'refused', + mutation: 'none', + code: 'no-token-for-identity', + message: 'No governed token binding exists for the identity.', + journalId: journal.journalId(), + auditState: 'sealed', + }); + } + mutation = 'unknown'; + await provider.revokeToken(authority, request.identity, binding.tokenName); + mutation = 'applied'; + await journal.recordMutation('token-revoke-applied'); + await store.remove(request.identity, request.estate, request.host); + await journal.seal('ok', 'revoke-verified'); + return lifecycleResult('revoke', request, { + outcome: 'ok', + mutation, + code: 'revoke-verified', + message: 'Provider token revocation completed before local binding removal.', + journalId: journal.journalId(), + auditState: 'sealed', + }); + } catch (error: unknown) { + if (error instanceof CredentialJournalError) throw error; + await journal.seal('indeterminate', 'mutation-state-unknown'); + return lifecycleResult('revoke', request, { + outcome: 'indeterminate', + mutation, + code: 'mutation-state-unknown', + message: 'Revocation mutation state could not be established completely.', + journalId: journal.journalId(), + auditState: 'sealed', + }); + } +} diff --git a/packages/mosaic/src/credentials/tea-login-store.ts b/packages/mosaic/src/credentials/tea-login-store.ts new file mode 100644 index 00000000..5fb3cda9 --- /dev/null +++ b/packages/mosaic/src/credentials/tea-login-store.ts @@ -0,0 +1,127 @@ +import { randomUUID } 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'; + +const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/; + +export class TeaLoginStoreError extends Error { + constructor( + public readonly code: string, + message: string, + ) { + super(`Tea login store rejected: code=${code} ${message}`); + this.name = 'TeaLoginStoreError'; + } +} + +interface TeaLoginRecord { + readonly name: string; + readonly url: string; + readonly token: string; + readonly user: string; + readonly default: boolean; +} + +interface TeaConfig { + readonly logins: TeaLoginRecord[]; +} + +const loginSchema = z + .object({ + name: z.string().regex(SAFE_NAME), + url: z.string().url(), + token: z.string().min(1), + user: z.string().regex(SAFE_NAME), + default: z.boolean().default(false), + }) + .passthrough(); +const configSchema = z.object({ logins: z.array(loginSchema).default([]) }).passthrough(); + +function missing(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOENT'; +} + +export class TeaLoginStore { + constructor(private readonly configPath: string) {} + + async put(identity: string, host: string, secret: Uint8Array): Promise { + if (!SAFE_NAME.test(identity) || !/^[a-z0-9][a-z0-9.-]*$/.test(host)) { + throw new TeaLoginStoreError('invalid-input', 'identity or host is outside the grammar'); + } + const directory = dirname(this.configPath); + ensureManagedDirectory(directory, directory); + 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 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(); + } finally { + await handle.close(); + } + await rename(temp, this.configPath); + } + + readBack( + identity: string, + host: string, + ): { readonly name: string; readonly host: string } | 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: unknown = parse(snapshot.content.toString('utf8')); + if ( + typeof decoded !== 'object' || + decoded === null || + !('logins' in decoded) || + !Array.isArray(decoded.logins) + ) + return undefined; + const matches = decoded.logins.filter((value: unknown): value is TeaLoginRecord => { + if (typeof value !== 'object' || value === null) return false; + return ( + 'name' in value && + value.name === identity && + 'url' in value && + value.url === `https://${host}` && + 'user' in value && + value.user === identity + ); + }); + return matches.length === 1 ? { name: identity, host } : undefined; + } +} diff --git a/packages/mosaic/src/credentials/team-grant.ts b/packages/mosaic/src/credentials/team-grant.ts index 30c8d80a..36d74a68 100644 --- a/packages/mosaic/src/credentials/team-grant.ts +++ b/packages/mosaic/src/credentials/team-grant.ts @@ -112,7 +112,22 @@ export async function grantTeamRepositoryPermission( null, ); } + await journal.recordProviderEvidence({ + endpoint: authorityIdentity.endpoint, + contentType: authorityIdentity.contentType, + decision: 'identity-verified', + }); + await journal.recordProviderEvidence({ + endpoint: team.endpoint, + contentType: team.contentType, + decision: `permission-${team.permission}`, + }); const teamRepositorySet = await provider.listTeamRepositories(authority, team.id); + await journal.recordProviderEvidence({ + endpoint: teamRepositorySet.endpoint, + contentType: teamRepositorySet.contentType, + decision: 'team-repository-set-verified', + }); if (teamRepositorySet.repositories.some((repo): boolean => repo !== request.repo)) { await journal.seal('refused', 'team-scope-exceeds-request'); return result( @@ -132,7 +147,9 @@ export async function grantTeamRepositoryPermission( mutation = 'unknown'; await provider.addTeamMember(authority, team.id, request.identity); mutation = 'applied'; + await journal.recordMutation('team-member-applied'); await provider.attachTeamRepository(authority, team.id, request.repo); + await journal.recordMutation('team-repository-applied'); const teamMembership = await provider.readTeamMember(authority, team.id, request.identity); const teamRepository = await provider.readTeamRepository(authority, team.id, request.repo); const subject = await dependencies.resolver.resolve( @@ -148,6 +165,33 @@ export async function grantTeamRepositoryPermission( request.permission === 'read' ? await evaluateGiteaReadValidation(request, dependencies) : await evaluateGiteaWriteValidation(request, dependencies); + if (organizationMembership !== null) { + await journal.recordProviderEvidence({ + endpoint: organizationMembership.endpoint, + contentType: organizationMembership.contentType, + decision: + organizationMembership.state === 'present' + ? 'organization-member-present' + : 'organization-member-absent', + }); + } + if (validation.evidence.providerIdentity !== null) { + await journal.recordProviderEvidence({ + endpoint: validation.evidence.providerIdentity.endpoint, + contentType: validation.evidence.providerIdentity.contentType, + decision: 'identity-verified', + }); + } + if (validation.evidence.repositoryPermission !== null) { + await journal.recordProviderEvidence({ + endpoint: validation.evidence.repositoryPermission.endpoint, + contentType: validation.evidence.repositoryPermission.contentType, + decision: `permission-${validation.evidence.repositoryPermission.effective}`, + }); + } + if (validation.evidence.writeDifferential !== null) { + await journal.recordMutation('transport-write-verified'); + } const ok = teamMembership.state === 'present' && teamRepository.state === 'present' && diff --git a/packages/mosaic/src/credentials/validate.spec.ts b/packages/mosaic/src/credentials/validate.spec.ts index 30705b9f..fe945f35 100644 --- a/packages/mosaic/src/credentials/validate.spec.ts +++ b/packages/mosaic/src/credentials/validate.spec.ts @@ -14,12 +14,12 @@ import { interface FixtureOptions { readonly subjectProviderIdentity?: string; - readonly subjectPermission?: 'read' | 'write' | 'admin'; + readonly subjectPermission?: 'none' | 'read' | 'write' | 'admin'; readonly subjectTransportState?: 'advertised' | 'refused'; readonly subjectTransportPrincipal?: string; readonly subjectTransportResolutionId?: string; readonly controlProviderIdentity?: string; - readonly controlPermission?: 'read' | 'write' | 'admin'; + readonly controlPermission?: 'none' | 'read' | 'write' | 'admin'; readonly controlTransportState?: 'advertised' | 'refused'; readonly controlTransportPrincipal?: string; readonly unauthenticatedTransportState?: 'advertised' | 'refused'; @@ -173,6 +173,17 @@ describe('Gitea read validation', (): void => { expect(observed.resolverCalls).toEqual([SUBJECT]); }); + it('refuses a repository object whose permission flags establish no read access', async (): Promise => { + const observed = fixture({ subjectPermission: 'none' }); + const result = await evaluateGiteaReadValidation( + { identity: SUBJECT, estate: ESTATE, host: HOST, repo: REPO }, + observed.dependencies, + ); + + expect(result.outcome).toBe('refused'); + expect(result.reason.code).toBe('permission-denied'); + }); + it('classifies the provider rejecting the subject credential as an authoritative refusal', async (): Promise => { const observed = fixture({ subjectPermission: 'read' }); observed.dependencies.provider.readIdentity = async (): Promise => { @@ -226,6 +237,30 @@ describe('Gitea read validation', (): void => { }); describe('principal-bound Gitea write validation contract v1.1', (): void => { + it('confirms write capability when identity is scope-forbidden without exposing the internal reason', async (): Promise => { + const observed = fixture(); + observed.dependencies.provider.readIdentity = async (): Promise => { + throw new CredentialProviderEvidenceError( + 'identity-read-forbidden', + 'identity endpoint scope forbidden', + ); + }; + const result = await evaluateGiteaWriteValidation( + { + identity: SUBJECT, + estate: ESTATE, + host: HOST, + repo: REPO, + readOnlyControlIdentity: CONTROL, + }, + observed.dependencies, + ); + + expect(result.outcome).toBe('indeterminate'); + expect(result.reason.code).toBe('identity-not-measured'); + expect(result.evidence.repositoryPermission?.effective).toBe('write'); + expect(observed.receivePackHandles).toEqual([expect.objectContaining({ identity: SUBJECT })]); + }); it('uses one immutable subject credential handle for identity, permission, and receive-pack', async (): Promise => { const { result, observed } = await validate(); diff --git a/packages/mosaic/src/credentials/validate.ts b/packages/mosaic/src/credentials/validate.ts index bdce5153..d5581736 100644 --- a/packages/mosaic/src/credentials/validate.ts +++ b/packages/mosaic/src/credentials/validate.ts @@ -239,6 +239,16 @@ async function evaluateGiteaReadValidationUnsafe( evidence, ); } + if (repositoryPermission.effective === 'none') { + return refused( + request, + { + code: 'permission-denied', + message: 'The in-scope provider object denies repository access.', + }, + evidence, + ); + } return indeterminate( request, { @@ -273,6 +283,16 @@ async function evaluateGiteaReadValidationUnsafe( evidence, ); } + if (repositoryPermission.effective === 'none') { + return refused( + request, + { + code: 'permission-denied', + message: 'The provider repository object denies read permission.', + }, + evidence, + ); + } if (providerIdentity.login !== request.identity) { return refused( request, @@ -324,7 +344,60 @@ async function evaluateGiteaWriteValidationUnsafe( }); } - const subjectEvidence = await readSubjectEvidence(request, resolved, dependencies.provider); + let subjectEvidence: Awaited>; + try { + subjectEvidence = await readSubjectEvidence(request, resolved, dependencies.provider); + } catch (error: unknown) { + if ( + error instanceof CredentialProviderEvidenceError && + error.code === 'identity-read-forbidden' + ) { + const permission = await dependencies.provider.readRepositoryPermission( + resolved, + request.repo, + ); + const receivePack = await dependencies.provider.probeReceivePack(resolved, request.repo); + const evidence: CredentialValidationEvidenceDto = { + providerIdentity: null, + repositoryPermission: permission, + writeDifferential: null, + }; + if (permission.effective === 'none' || permission.effective === 'read') { + return refused( + request, + { + code: 'permission-denied', + message: 'The in-scope provider object denies required write capability.', + }, + evidence, + ); + } + if ( + receivePack.principal !== request.identity || + receivePack.resolutionId !== resolved.resolutionId || + !advertised(receivePack) + ) { + return indeterminate( + request, + { + code: 'permission-evidence-disagrees', + message: 'In-scope repository and write transport evidence did not agree.', + }, + evidence, + ); + } + return indeterminate( + request, + { + code: 'identity-not-measured', + message: + 'Write capability was confirmed, but identity was not measured because this least-privilege token cannot read /user.', + }, + evidence, + ); + } + throw error; + } const baseEvidence: CredentialValidationEvidenceDto = { providerIdentity: subjectEvidence.identity, repositoryPermission: subjectEvidence.permission, @@ -371,7 +444,10 @@ async function evaluateGiteaWriteValidationUnsafe( baseEvidence, ); } - if (subjectEvidence.permission.effective === 'read') { + if ( + subjectEvidence.permission.effective === 'read' || + subjectEvidence.permission.effective === 'none' + ) { return refused( request, { diff --git a/packages/mosaic/src/fleet/secure-file.ts b/packages/mosaic/src/fleet/secure-file.ts index 82f27201..e9042fb9 100644 --- a/packages/mosaic/src/fleet/secure-file.ts +++ b/packages/mosaic/src/fleet/secure-file.ts @@ -22,6 +22,8 @@ export interface SecureFileSnapshot { mode: number; dev: number | bigint; ino: number | bigint; + uid: number; + gid: number; } function sameIdentity( @@ -235,6 +237,8 @@ export function readRegularFileSecure( mode: Number(opened.mode), dev: opened.dev, ino: opened.ino, + uid: opened.uid, + gid: opened.gid, }; } finally { closeDescriptors(openedFile.descriptors);