diff --git a/docs/PRD.md b/docs/PRD.md index e6981f75..981f70b7 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -470,8 +470,8 @@ Phase 1 governs the existing per-identity Gitea token store and Tea login regist 1. `AC-CRED-01`: Red-first tests prove unset identity, missing token, wrong estate, wrong host, wrong Tea login, and out-of-estate identity produce the same structured refusal class/reason on git and API resolution, with no shared credential read and no provider mutation. 2. `AC-CRED-02`: Provisioning against a provider fixture proves Basic Auth is required, bearer-only token minting is refused, both identity axes register atomically, exact token scopes are read back from the provider token object, and rollback removes partial local registration. -3. `AC-CRED-03`: Direct and team grant tests read all applicable permission layers back from provider objects. Deliberately divergent token scope, repo grant, org membership, and team membership cases cannot return `ok`. -4. `AC-CRED-04`: Validate proves provider identity and the write differential on the intended repository through one credential handle. The subject is accepted, a separately resolved provider-confirmed read-only principal is refused, and an unauthenticated caller is refused in the same invocation. A shared/wrong-principal fallback, independent subject lookups, invalid read-only control, evidence disagreement, unexpected content type/shape, provider outage, or unavailable exact scope returns `indeterminate`, never success or policy refusal. +3. `AC-CRED-03`: Direct and team grant tests read all applicable permission layers back from provider objects. Deliberately divergent token scope and repo grant cases cannot return `ok`; organization/team membership and team-repository attachment are additionally acceptance-bearing for team grants. A direct collaborator grant reports organization membership but does not require it, because direct collaborator permission and organization membership are intentionally independent provider layers. +4. `AC-CRED-04`: Validate proves provider identity and the write differential on the intended repository through one credential handle. The subject is accepted, a separately resolved provider-confirmed read-only principal is refused, and an unauthenticated caller is refused in the same invocation. A shared/wrong-principal fallback, independent subject lookups, invalid read-only control, evidence disagreement, unexpected content type/shape, or provider outage returns `indeterminate`, never success or policy refusal. Runtime exact scope is reported independently as `not-measured` when the current seat credential is not authorized to read its provider token object; NOT-MEASURED is neither pass nor failure and does not erase confirmed repository capability. Exact scope is acceptance-bearing at provision/rotate time, where delegated mint authority can read the token object. 5. `AC-CRED-05`: Audit/journal fault injection before and after each mutation proves write failure is fatal, open journals remain visible/recoverable, and no operation can claim success without a sealed journal and provider read-back. 6. `AC-CRED-06`: Adversarial output/argv tests seed distinct secret values through success, refusal, provider-error, parser-error, rollback, rotate, and revoke paths and find zero secret/partial/fingerprint occurrences in stdout, stderr, logs, audit, and child argv. 7. `AC-CRED-07`: Storage tests reject symlinked roots/files, non-regular files, permissive modes, traversal, conflicting concurrent mutation, and production-store leakage into fixture tests. Existing canonical per-seat token consumers continue through the governed adapter. diff --git a/docs/credentials/GRANT-VALIDATE-CONTRACT.md b/docs/credentials/GRANT-VALIDATE-CONTRACT.md index 10133059..51a28cb7 100644 --- a/docs/credentials/GRANT-VALIDATE-CONTRACT.md +++ b/docs/credentials/GRANT-VALIDATE-CONTRACT.md @@ -69,7 +69,11 @@ Rules: "endpoint": "GET /api/v1/user", "contentType": "application/json" }, - "tokenCapabilities": [], + "tokenCapabilities": { + "state": "not-measured", + "scopes": [], + "source": "runtime-not-authorized" + }, "repositoryPermission": { "requested": "write", "effective": "write", @@ -109,12 +113,12 @@ Fields may be `null` only when their enclosing evidence state explains why. Miss ## Terminal classes -| Outcome | Exit | Meaning | Mutation guarantee | Caller action | -| --------------- | ---: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | -| `ok` | `0` | Requested property was established from provider objects and all required layers agree. | `validate`: `none`; `grant`: `applied` and read back. | Continue. | -| `refused` | `10` | A complete, authoritative policy/access decision denied the request. Examples: estate-host mismatch, missing explicit identity, provider identity mismatch, explicit permission denial, or cross-estate subject. | `none`; refusal occurs before mutation. | Treat as a stable denial. Do not retry without changing authority/configuration. | -| `error` | `20` | The command contract or local control failed before an access verdict. Examples: invalid arguments, malformed estate registry, insecure credential path, journal cannot be opened/fsynced, or internal invariant failure. | `none` unless `mutation` explicitly says `unknown`; `unknown` is never success. | Repair the tool/configuration. Do not reinterpret as access denial. | -| `indeterminate` | `30` | The requested security property could not be evaluated completely or evidence disagreed. Examples: provider unavailable, wrong content type/shape, stale or absent scope read-back, permission and receive-pack disagreement, missing post-grant read-back, or unknown mutation acknowledgement. | `none`, `applied`, or `unknown`, stated explicitly. Never infer. | Fail closed at the calling gate. Investigate/re-evaluate; do not label the subject refused. | +| Outcome | Exit | Meaning | Mutation guarantee | Caller action | +| --------------- | ---: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| `ok` | `0` | Requested property was established from provider objects and all required layers agree. | `validate`: `none`; `grant`: `applied` and read back. | Continue. | +| `refused` | `10` | A complete, authoritative policy/access decision denied the request. Examples: estate-host mismatch, missing explicit identity, provider identity mismatch, explicit permission denial, or cross-estate subject. | `none`; refusal occurs before mutation. | Treat as a stable denial. Do not retry without changing authority/configuration. | +| `error` | `20` | The command contract or local control failed before an access verdict. Examples: invalid arguments, malformed estate registry, insecure credential path, journal cannot be opened/fsynced, or internal invariant failure. | `none` unless `mutation` explicitly says `unknown`; `unknown` is never success. | Repair the tool/configuration. Do not reinterpret as access denial. | +| `indeterminate` | `30` | The requested security property could not be evaluated completely or evidence disagreed. Examples: provider unavailable, wrong content type/shape, permission and receive-pack disagreement, missing post-grant read-back, or unknown mutation acknowledgement. Runtime scope `not-measured` remains a separately reported axis and is neither pass nor failure. | `none`, `applied`, or `unknown`, stated explicitly. Never infer. | Fail closed at the calling gate. Investigate/re-evaluate; do not label the subject refused. | Parsing/usage errors emitted by Commander remain exit `2` and do not produce a broker verdict. Callers should treat them as integration defects, not access decisions. @@ -142,7 +146,11 @@ A refusal is intentionally recognizable without prose: }, "evidence": { "providerIdentity": null, - "tokenCapabilities": [], + "tokenCapabilities": { + "state": "not-measured", + "scopes": [], + "source": "runtime-not-authorized" + }, "repositoryPermission": null, "organizationMembership": null, "teamMembership": null, diff --git a/packages/mosaic/framework/tools/git/detect-platform.sh b/packages/mosaic/framework/tools/git/detect-platform.sh index 32b2cca4..0048f682 100755 --- a/packages/mosaic/framework/tools/git/detect-platform.sh +++ b/packages/mosaic/framework/tools/git/detect-platform.sh @@ -499,6 +499,15 @@ get_gitea_url_for_host() { # Resolve a Gitea API token for the given host. # Priority: Mosaic credential loader → GITEA_TOKEN env → ~/.git-credentials +_trace_credential_resolution() { + [[ "${MOSAIC_CREDENTIAL_TRACE:-}" == 1 ]] || return 0 + local resolution_path="$1" reason="$2" identity="$3" host="$4" source="$5" + local shared_path_entered=false + [[ "$resolution_path" == shared ]] && shared_path_entered=true + printf 'MOSAIC_CREDENTIAL_RESOLUTION outcome=ok reason=%s identity=%s host=%s resolution_path=%s shared_path_entered=%s source=%s\n' \ + "$reason" "$identity" "$host" "$resolution_path" "$shared_path_entered" "$source" >&2 +} + get_gitea_token() { local host="$1" local script_dir @@ -532,26 +541,14 @@ get_gitea_token() { local _idcred="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.credential.json" if [[ -r "$_idcred" ]]; then local _resolved_token - _resolved_token=$(python3 - "$_idcred" <<'PY' -import json, sys -value = json.load(open(sys.argv[1], encoding="utf-8")).get("token") -if not isinstance(value, str) or not value or any(ch.isspace() for ch in value): - raise SystemExit(1) -print(value) -PY -) || return 1 - if [[ "${MOSAIC_CREDENTIAL_TRACE:-}" == 1 ]]; then - printf 'MOSAIC_CREDENTIAL_RESOLUTION outcome=ok reason=credential-resolved identity=%s host=%s shared_path_entered=false source=%s\n' \ - "$_ident" "$host" "$_ident_src" >&2 - fi + _resolved_token=$(python3 "$script_dir/resolve-credential-envelope.py" \ + "$_idcred" "$_ident" "${MOSAIC_CREDENTIAL_ESTATE:-}" "$host") || return 1 + _trace_credential_resolution identity credential-resolved "$_ident" "$host" "$_ident_src" printf '%s\n' "$_resolved_token" return 0 fi if [[ -r "$_idtok" ]]; then - if [[ "${MOSAIC_CREDENTIAL_TRACE:-}" == 1 ]]; then - printf 'MOSAIC_CREDENTIAL_RESOLUTION outcome=ok reason=credential-resolved identity=%s host=%s shared_path_entered=false source=%s\n' \ - "$_ident" "$host" "$_ident_src" >&2 - fi + _trace_credential_resolution identity credential-resolved "$_ident" "$host" "$_ident_src" cat "$_idtok" return 0 fi @@ -607,6 +604,7 @@ PY echo "${GITEA_TOKEN:-}" ) if [[ -n "$token" ]]; then + _trace_credential_resolution shared shared-credential-resolved '' "$host" credentials-loader echo "$token" return 0 fi @@ -615,6 +613,7 @@ PY # 2. GITEA_TOKEN env var (only when GITEA_URL, if present, matches the remote host) if [[ -n "${GITEA_TOKEN:-}" ]]; then if [[ -z "${GITEA_URL:-}" ]] || gitea_url_matches_host "$GITEA_URL" "$host"; then + _trace_credential_resolution shared shared-credential-resolved '' "$host" environment echo "$GITEA_TOKEN" return 0 fi @@ -626,6 +625,7 @@ PY local token token=$(grep -F "$host" "$creds" 2>/dev/null | sed -n 's#https\?://[^@]*:\([^@/]*\)@.*#\1#p' | head -n 1) if [[ -n "$token" ]]; then + _trace_credential_resolution shared shared-credential-resolved '' "$host" git-credentials echo "$token" return 0 fi diff --git a/packages/mosaic/framework/tools/git/git-credential-mosaic b/packages/mosaic/framework/tools/git/git-credential-mosaic index 95e031c9..4ad2d975 100755 --- a/packages/mosaic/framework/tools/git/git-credential-mosaic +++ b/packages/mosaic/framework/tools/git/git-credential-mosaic @@ -24,6 +24,15 @@ while IFS= read -r line; do username=*) username_in=${line#username=};; esac done +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +trace_resolution() { + [ "${MOSAIC_CREDENTIAL_TRACE:-}" = 1 ] || return 0 + resolution_path="$1" reason="$2" trace_identity="$3" trace_host="$4" source="$5" + shared_path_entered=false + [ "$resolution_path" = shared ] && shared_path_entered=true + printf 'MOSAIC_CREDENTIAL_RESOLUTION outcome=ok reason=%s identity=%s host=%s resolution_path=%s shared_path_entered=%s source=%s\n' \ + "$reason" "$trace_identity" "$trace_host" "$resolution_path" "$shared_path_entered" "$source" >&2 +} # Per-agent identity resolution (Gate-16 author≠reviewer separation). # Priority: MOSAIC_GIT_IDENTITY env > git config mosaic.gitIdentity (per-worktree, # survives across non-persistent shells) > git-supplied username (credential.username @@ -48,27 +57,15 @@ if [ -n "$ident" ]; then idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.token" idcred="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.credential.json" if [ -r "$idcred" ]; then - token=$(python3 - "$idcred" <<'PY' -import json, sys -value = json.load(open(sys.argv[1], encoding="utf-8")).get("token") -if not isinstance(value, str) or not value or any(ch.isspace() for ch in value): - raise SystemExit(1) -print(value) -PY -) || exit 1 - if [ "${MOSAIC_CREDENTIAL_TRACE:-}" = 1 ]; then - printf 'MOSAIC_CREDENTIAL_RESOLUTION outcome=ok reason=credential-resolved identity=%s host=%s shared_path_entered=false source=git-credential-mosaic\n' \ - "$ident" "$host" >&2 - fi + token=$(python3 "$script_dir/resolve-credential-envelope.py" \ + "$idcred" "$ident" "${MOSAIC_CREDENTIAL_ESTATE:-}" "$host") || exit 1 + trace_resolution identity credential-resolved "$ident" "$host" git-credential-mosaic echo "username=${ident}" echo "password=${token}" exit 0 fi if [ -r "$idtok" ]; then - if [ "${MOSAIC_CREDENTIAL_TRACE:-}" = 1 ]; then - printf 'MOSAIC_CREDENTIAL_RESOLUTION outcome=ok reason=credential-resolved identity=%s host=%s shared_path_entered=false source=git-credential-mosaic\n' \ - "$ident" "$host" >&2 - fi + trace_resolution identity credential-resolved "$ident" "$host" git-credential-mosaic echo "username=${ident}" echo "password=$(cat "$idtok")" exit 0 @@ -98,10 +95,10 @@ esac # Script-relative (not $HOME-absolute) so this resolves correctly regardless # of where the framework installer places tools/ under $HOME — mirrors # detect-platform.sh's own cred_loader resolution in this same directory. -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=../_lib/credentials.sh source "$script_dir/../_lib/credentials.sh" load_credentials "$svc" >/dev/null 2>&1 || exit 0 +trace_resolution shared shared-credential-resolved '' "$host" credentials-loader # GITEA_USER is not populated by load_credentials (it only exports # GITEA_URL/GITEA_TOKEN for gitea-*), so this fallback is normally taken. Gitea's # git-over-HTTP auth authenticates from the token itself (the password field), diff --git a/packages/mosaic/framework/tools/git/resolve-credential-envelope.py b/packages/mosaic/framework/tools/git/resolve-credential-envelope.py new file mode 100644 index 00000000..50a5e4c9 --- /dev/null +++ b/packages/mosaic/framework/tools/git/resolve-credential-envelope.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Fail-closed reader for one governed Mosaic credential envelope.""" + +import hashlib +import json +import os +import stat +import sys + +MAX_BYTES = 64 * 1024 +EXPECTED_KEYS = { + "schemaVersion", + "identity", + "estate", + "host", + "providerLogin", + "tokenName", + "scopes", + "createdAt", + "tokenDigest", + "token", +} + + +def refuse(message: str) -> None: + print(f"credential envelope refused: {message}", file=sys.stderr) + raise SystemExit(1) + + +if len(sys.argv) != 5: + refuse("expected path, identity, estate, and host") +path, identity, estate, host = sys.argv[1:] +if not estate: + refuse("explicit estate is required") +parent = os.path.dirname(path) +try: + parent_stat = os.stat(parent, follow_symlinks=False) +except OSError: + refuse("credential directory unavailable") +if not stat.S_ISDIR(parent_stat.st_mode) or stat.S_ISLNK(parent_stat.st_mode): + refuse("credential directory is not a real directory") +if parent_stat.st_uid != os.getuid() or parent_stat.st_mode & 0o022: + refuse("credential directory owner or mode is unsafe") +try: + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC) +except OSError: + refuse("credential file unavailable or symbolic") +try: + file_stat = os.fstat(fd) + if not stat.S_ISREG(file_stat.st_mode): + refuse("credential is not a regular file") + if file_stat.st_uid != os.getuid() or file_stat.st_mode & 0o077: + refuse("credential owner or mode is unsafe") + content = os.read(fd, MAX_BYTES + 1) + if len(content) > MAX_BYTES: + refuse("credential exceeds size limit") +finally: + os.close(fd) +try: + value = json.loads(content) +except (UnicodeDecodeError, json.JSONDecodeError): + refuse("credential is not valid JSON") +if not isinstance(value, dict) or set(value) != EXPECTED_KEYS: + refuse("credential schema is not exact") +if ( + value.get("schemaVersion") != 1 + or value.get("identity") != identity + or value.get("estate") != estate + or value.get("host") != host + or value.get("providerLogin") != identity +): + refuse("credential binding does not match requested identity, estate, host, and principal") +token = value.get("token") +if not isinstance(token, str) or not token or any(ch.isspace() for ch in token): + refuse("credential token is invalid") +if value.get("tokenDigest") != hashlib.sha256(token.encode()).hexdigest(): + refuse("credential digest does not match token") +sys.stdout.write(token + "\n") 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 eb99cb86..3410cf01 100755 --- a/packages/mosaic/framework/tools/git/test-git-credential-mosaic.sh +++ b/packages/mosaic/framework/tools/git/test-git-credential-mosaic.sh @@ -34,6 +34,7 @@ mkdir -p "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens" \ "$REPO_DIR" cp "$SCRIPT_DIR/git-credential-mosaic" "$HELPER" +cp "$SCRIPT_DIR/resolve-credential-envelope.py" "$FAKE_HOME/.config/mosaic/tools/git/resolve-credential-envelope.py" chmod +x "$HELPER" git -C "$REPO_DIR" init -q @@ -84,6 +85,12 @@ git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true 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=')" +out=$(run_helper "git.mosaicstack.dev" "" MOSAIC_CREDENTIAL_TRACE=1 2>"$WORK_DIR/shared-trace.stderr") +err=$(cat "$WORK_DIR/shared-trace.stderr") +if [[ "$err" != *"resolution_path=shared"* || "$err" != *"shared_path_entered=true"* ]]; then + echo "FAIL: shared credential materialization did not emit its computed path" >&2 + fail=1 +fi set +e out=$(run_helper "git.mosaicstack.dev" "" MOSAIC_AGENT_NAME=synthetic-seat 2>"$WORK_DIR/fleet-unset.stderr") @@ -103,6 +110,12 @@ echo -n "agentA-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-to out=$(run_helper "git.mosaicstack.dev" "agentA") assert_eq "username-resolved identity: username" "username=agentA" "$(echo "$out" | grep '^username=')" assert_eq "username-resolved identity: password" "password=agentA-mosaicstack-token" "$(echo "$out" | grep '^password=')" +out=$(run_helper "git.mosaicstack.dev" "agentA" MOSAIC_AGENT_NAME=agentA MOSAIC_CREDENTIAL_TRACE=1 2>"$WORK_DIR/identity-trace.stderr") +err=$(cat "$WORK_DIR/identity-trace.stderr") +if [[ "$err" != *"resolution_path=identity"* || "$err" != *"shared_path_entered=false"* ]]; then + echo "FAIL: identity credential did not emit its computed path" >&2 + fail=1 +fi set +e out=$(run_helper "git.mosaicstack.dev" "agentA" MOSAIC_AGENT_NAME=agentB 2>"$WORK_DIR/fleet-mismatch.stderr") rc=$? @@ -164,14 +177,42 @@ assert_eq "host-scoped token path (cross-host must not leak): username" "usernam assert_eq "host-scoped token path (cross-host must not leak): password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')" # --------------------------------------------------------------------------- -# 7. Unrelated/unknown host -> exit 0, no output (passthrough for non-Gitea +# 7. Governed envelopes use the same binding, owner, mode, and digest checks. +# --------------------------------------------------------------------------- +envelope="$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentE.credential.json" +python3 - "$envelope" <<'PY' +import hashlib, json, sys +secret = "agentE-envelope-token" +json.dump({ + "schemaVersion": 1, "identity": "agentE", "estate": "homelab", + "host": "git.mosaicstack.dev", "providerLogin": "agentE", + "tokenName": "mosaic-agentE-1", "scopes": ["write:repository"], + "createdAt": "2026-08-05T00:00:00.000Z", + "tokenDigest": hashlib.sha256(secret.encode()).hexdigest(), "token": secret, +}, open(sys.argv[1], "w", encoding="utf-8")) +PY +chmod 600 "$envelope" +out=$(run_helper "git.mosaicstack.dev" "agentE" MOSAIC_AGENT_NAME=agentE MOSAIC_CREDENTIAL_ESTATE=homelab) +assert_eq "governed envelope: password" "password=agentE-envelope-token" "$(echo "$out" | grep '^password=')" +chmod 640 "$envelope" +set +e +out=$(run_helper "git.mosaicstack.dev" "agentE" MOSAIC_AGENT_NAME=agentE MOSAIC_CREDENTIAL_ESTATE=homelab 2>"$WORK_DIR/envelope-mode.stderr") +rc=$? +set -e +if [[ "$rc" -eq 0 || "$out" == *"password="* ]]; then + echo "FAIL: permissive envelope was consumed" >&2 + fail=1 +fi + +# --------------------------------------------------------------------------- +# 8. Unrelated/unknown host -> exit 0, no output (passthrough for non-Gitea # remotes, e.g. github.com via a different credential helper). # --------------------------------------------------------------------------- out=$(run_helper "github.com" "agentA") assert_eq "unknown host: no output" "" "$out" # --------------------------------------------------------------------------- -# 8. Non-"get" verb (store/erase) -> exit 0, no output (git-credential +# 9. Non-"get" verb (store/erase) -> exit 0, no output (git-credential # protocol: this helper only implements get). # --------------------------------------------------------------------------- store_out=$(cd "$REPO_DIR" && env -i HOME="$FAKE_HOME" PATH="$PATH" bash "$HELPER" store < - !line.startsWith('MOSAIC_GIT_IDENTITY=') && !line.startsWith('GITEA_LOGIN='), + !line.startsWith('MOSAIC_GIT_IDENTITY=') && + !line.startsWith('MOSAIC_CREDENTIAL_ESTATE=') && + !line.startsWith('GITEA_LOGIN='), ); - lines.push(`MOSAIC_GIT_IDENTITY=${identity}`, `GITEA_LOGIN=${identity}`); + lines.push( + `MOSAIC_GIT_IDENTITY=${identity}`, + `MOSAIC_CREDENTIAL_ESTATE=${options.estate}`, + `GITEA_LOGIN=${identity}`, + ); const temp = `${options.seatEnv}.${process.pid.toString()}.tmp`; const handle = await open(temp, 'wx', 0o600); try { @@ -724,43 +756,88 @@ export async function executeCredentialGet( } } +async function executeAuthorizedInventoryRead( + operation: 'list' | 'audit', + options: CredentialLifecycleCommandOptions, +): Promise { + const locations = lifecycleLocations(options); + const journal = await CredentialAuditJournal.open(locations.stateRoot, { + operation, + actor: options.actor, + identity: options.actor, + estate: options.estate, + host: options.host, + repo: null, + }); + await journal.recordIntent(`${operation}-requested`); + try { + if (options.authorityFd === undefined) { + await journal.seal('refused', 'authority-required'); + return localLifecycleResult(operation, 'all', options, { + outcome: 'refused', + code: 'authority-required', + message: 'A protected delegated inventory authority is required.', + audit: { journalId: journal.journalId(), state: 'sealed' }, + }); + } + const context = await lifecycleContext(options); + const authority = await lifecycleAuthority(options.actor, options); + const providerIdentity = await context.provider.readIdentity(authority); + if (providerIdentity.login !== options.actor) { + await journal.seal('refused', 'provider-identity-mismatch'); + return localLifecycleResult(operation, 'all', options, { + outcome: 'refused', + code: 'provider-identity-mismatch', + message: 'Delegated inventory authority did not match the explicit actor.', + audit: { journalId: journal.journalId(), state: 'sealed' }, + }); + } + const identities = + operation === 'list' ? await context.store.list(options.estate, options.host) : []; + const journals = operation === 'audit' ? await listCredentialJournals(locations.stateRoot) : []; + await journal.recordProviderEvidence({ + endpoint: providerIdentity.endpoint, + contentType: providerIdentity.contentType, + decision: 'inventory-authority-verified', + }); + await journal.seal('ok', `${operation}-verified`); + return localLifecycleResult(operation, 'all', options, { + outcome: 'ok', + code: `${operation}-verified`, + message: + operation === 'list' + ? 'Governed credential bindings were listed under delegated authority.' + : 'Durable credential journal identifiers were read under delegated authority.', + evidence: { + providerIdentity: providerIdentity.login, + token: null, + teaLogin: null, + identities, + journalIds: journals.map((entry): string => entry.id), + }, + audit: { journalId: journal.journalId(), state: 'sealed' }, + }); + } catch { + await journal.seal('error', 'inventory-read-failed'); + return localLifecycleResult(operation, 'all', options, { + outcome: 'error', + code: 'inventory-read-failed', + message: 'Authorized credential inventory read failed.', + 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.', - }); - } + return executeAuthorizedInventoryRead('list', options); } 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), - }, - }); + return executeAuthorizedInventoryRead('audit', options); } type PrintableCredentialResult = Pick< @@ -885,7 +962,8 @@ export function registerCredentialCommand(parent: Command): void { .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') + .requiredOption('--actor ', 'Explicit delegated inventory 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') @@ -901,7 +979,9 @@ export function registerCredentialCommand(parent: Command): void { .description('List durable credential journals without secret-bearing payloads') .requiredOption('--estate ', 'Explicit target estate') .requiredOption('--host ', 'Explicit provider host') - .requiredOption('--actor ', 'Explicit audit actor') + .requiredOption('--actor ', 'Explicit delegated inventory authority identity') + .requiredOption('--authority-fd ', 'Inherited protected Basic credential fd') + .option('--registry ', 'Strict non-secret estate registry') .option('--state-dir ', 'Durable credential journal root') .option('--json', 'Emit one machine result object') .action(async (options: CredentialLifecycleCommandOptions): Promise => { diff --git a/packages/mosaic/src/credentials/audit-journal.ts b/packages/mosaic/src/credentials/audit-journal.ts index ca85f923..c7b3d579 100644 --- a/packages/mosaic/src/credentials/audit-journal.ts +++ b/packages/mosaic/src/credentials/audit-journal.ts @@ -25,6 +25,7 @@ const SAFE_DECISIONS = new Set([ 'permission-write', 'permission-admin', 'identity-verified', + 'inventory-authority-verified', 'scope-verified', 'grant-verified', 'revoke-verified', diff --git a/packages/mosaic/src/credentials/credential-result.dto.ts b/packages/mosaic/src/credentials/credential-result.dto.ts index bb0f181c..3511e4a6 100644 --- a/packages/mosaic/src/credentials/credential-result.dto.ts +++ b/packages/mosaic/src/credentials/credential-result.dto.ts @@ -52,8 +52,15 @@ export interface WriteDifferentialEvidenceDto { readonly doesNotProve: string; } +export interface TokenCapabilitiesEvidenceDto { + readonly state: 'measured' | 'not-measured'; + readonly scopes: readonly string[]; + readonly source: 'provider-token-object' | 'runtime-not-authorized'; +} + export interface CredentialValidationEvidenceDto { readonly providerIdentity: ProviderIdentityEvidenceDto | null; + readonly tokenCapabilities: TokenCapabilitiesEvidenceDto; readonly repositoryPermission: RepositoryPermissionEvidenceDto | null; readonly writeDifferential: WriteDifferentialEvidenceDto | null; } diff --git a/packages/mosaic/src/credentials/file-credential-store.spec.ts b/packages/mosaic/src/credentials/file-credential-store.spec.ts index 7d456651..7f50b4a5 100644 --- a/packages/mosaic/src/credentials/file-credential-store.spec.ts +++ b/packages/mosaic/src/credentials/file-credential-store.spec.ts @@ -1,4 +1,4 @@ -import { chmod, mkdir, symlink, writeFile } from 'node:fs/promises'; +import { chmod, copyFile, mkdir, symlink, unlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { mkdtemp } from 'node:fs/promises'; @@ -132,6 +132,18 @@ describe('phase-1 governed file credential resolver', (): void => { 'git.example.invalid', ), ).resolves.toMatchObject({ identity: 'seat' }); + await copyFile( + join(root, 'gitea-example-seat.credential.json'), + join(root, 'gitea-example-other.credential.json'), + ); + await expect( + new FileCredentialResolver(root, registry()).resolve( + 'other', + 'homelab', + 'git.example.invalid', + ), + ).rejects.toThrow(/credential-binding-mismatch/); + await unlink(join(root, 'gitea-example-other.credential.json')); await store.remove('seat', 'homelab', 'git.example.invalid'); await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual([]); }); diff --git a/packages/mosaic/src/credentials/file-credential-store.ts b/packages/mosaic/src/credentials/file-credential-store.ts index fda7d725..cbb9d7fe 100644 --- a/packages/mosaic/src/credentials/file-credential-store.ts +++ b/packages/mosaic/src/credentials/file-credential-store.ts @@ -127,6 +127,17 @@ export class FileCredentialResolver implements CredentialResolver { 'credential envelope failed schema, owner, or mode validation', ); } + if ( + envelope.data.identity !== identity || + envelope.data.estate !== estate || + envelope.data.host !== host || + envelope.data.providerLogin !== identity + ) { + throw new CredentialStoreError( + 'credential-binding-mismatch', + 'credential envelope does not match the requested identity, estate, host, and principal', + ); + } const secret = validateSecret(Buffer.from(envelope.data.token, 'utf8')); const digest = createHash('sha256').update(secret).digest('hex'); if (envelope.data.tokenDigest !== digest) { diff --git a/packages/mosaic/src/credentials/grant.ts b/packages/mosaic/src/credentials/grant.ts index fda7b5a8..195e3052 100644 --- a/packages/mosaic/src/credentials/grant.ts +++ b/packages/mosaic/src/credentials/grant.ts @@ -98,6 +98,11 @@ export async function grantDirectRepositoryPermission( }, evidence: { providerIdentity: authorityIdentity, + tokenCapabilities: { + state: 'not-measured', + scopes: [], + source: 'runtime-not-authorized', + }, repositoryPermission: null, writeDifferential: null, collaboratorPermission: null, @@ -241,6 +246,11 @@ export async function grantDirectRepositoryPermission( }, evidence: { providerIdentity: null, + tokenCapabilities: { + state: 'not-measured', + scopes: [], + source: 'runtime-not-authorized', + }, repositoryPermission: null, writeDifferential: null, collaboratorPermission: null, diff --git a/packages/mosaic/src/credentials/tea-login-store.spec.ts b/packages/mosaic/src/credentials/tea-login-store.spec.ts index 6ab225cd..efa9be6b 100644 --- a/packages/mosaic/src/credentials/tea-login-store.spec.ts +++ b/packages/mosaic/src/credentials/tea-login-store.spec.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm } from 'node:fs/promises'; +import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; @@ -32,4 +32,23 @@ describe('host-bound Tea login store', (): void => { host: 'git.two.invalid', }); }); + + it('preserves unrelated Tea configuration and rejects permissive secret reads', async (): Promise => { + root = await mkdtemp(join(tmpdir(), 'mosaic-tea-store-')); + const configPath = join(root, 'tea', 'config.yml'); + const store = new TeaLoginStore(configPath); + await store.put('seat', 'git.one.invalid', new TextEncoder().encode('token-one')); + const original = await readFile(configPath, 'utf8'); + await writeFile(configPath, `preferences:\n color: true\n${original}`, { mode: 0o600 }); + + await store.put('seat', 'git.two.invalid', new TextEncoder().encode('token-two')); + await store.remove('seat', 'git.one.invalid'); + expect(await readFile(configPath, 'utf8')).toContain('color: true'); + + await chmod(configPath, 0o644); + expect(() => store.resolve('seat', 'homelab', 'git.two.invalid')).toThrow( + /tea-config-insecure/, + ); + expect(() => store.readBack('seat', 'git.two.invalid')).toThrow(/tea-config-insecure/); + }); }); diff --git a/packages/mosaic/src/credentials/tea-login-store.ts b/packages/mosaic/src/credentials/tea-login-store.ts index 88173184..99cf4a7b 100644 --- a/packages/mosaic/src/credentials/tea-login-store.ts +++ b/packages/mosaic/src/credentials/tea-login-store.ts @@ -28,6 +28,7 @@ interface TeaLoginRecord { interface TeaConfig { readonly logins: TeaLoginRecord[]; + readonly [key: string]: unknown; } const loginSchema = z @@ -58,6 +59,12 @@ async function acquireLock(path: string): Promise