fix(mosaic): close credential evidence gaps
This commit is contained in:
+2
-2
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 '<interactive-shared>' "$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 '<interactive-shared>' "$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 '<interactive-shared>' "$host" git-credentials
|
||||
echo "$token"
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -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 '<interactive-shared>' "$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),
|
||||
|
||||
@@ -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")
|
||||
@@ -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 <<EOF
|
||||
|
||||
@@ -131,6 +131,11 @@ function errorResult(
|
||||
},
|
||||
evidence: {
|
||||
providerIdentity: null,
|
||||
tokenCapabilities: {
|
||||
state: 'not-measured',
|
||||
scopes: [],
|
||||
source: 'runtime-not-authorized',
|
||||
},
|
||||
repositoryPermission: null,
|
||||
writeDifferential: null,
|
||||
},
|
||||
@@ -220,6 +225,11 @@ function grantErrorResult(
|
||||
},
|
||||
evidence: {
|
||||
providerIdentity: null,
|
||||
tokenCapabilities: {
|
||||
state: 'not-measured',
|
||||
scopes: [],
|
||||
source: 'runtime-not-authorized',
|
||||
},
|
||||
repositoryPermission: null,
|
||||
writeDifferential: null,
|
||||
collaboratorPermission: null,
|
||||
@@ -586,6 +596,22 @@ export async function executeCredentialWire(
|
||||
});
|
||||
}
|
||||
const locations = lifecycleLocations(options);
|
||||
try {
|
||||
const context = await lifecycleContext(options);
|
||||
if (context.registry.resolve(options.estate, options.host) === undefined) {
|
||||
return localLifecycleResult('wire', identity, options, {
|
||||
outcome: 'refused',
|
||||
code: 'estate-host-mismatch',
|
||||
message: 'Declared estate does not map exactly to the declared host.',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
return localLifecycleResult('wire', identity, options, {
|
||||
outcome: 'refused',
|
||||
code: 'estate-host-mismatch',
|
||||
message: 'Declared estate and host could not be resolved before mutation.',
|
||||
});
|
||||
}
|
||||
const journal = await CredentialAuditJournal.open(locations.stateRoot, {
|
||||
operation: 'wire',
|
||||
actor: options.actor,
|
||||
@@ -613,9 +639,15 @@ export async function executeCredentialWire(
|
||||
.split(/\r?\n/)
|
||||
.filter(
|
||||
(line): boolean =>
|
||||
!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<CredentialLifecycleResultDto> {
|
||||
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<CredentialLifecycleResultDto> {
|
||||
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<CredentialLifecycleResultDto> {
|
||||
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 <estate>', 'Explicit target estate')
|
||||
.requiredOption('--host <host>', 'Explicit provider host')
|
||||
.requiredOption('--actor <identity>', 'Explicit audit actor')
|
||||
.requiredOption('--actor <identity>', 'Explicit delegated inventory authority identity')
|
||||
.requiredOption('--authority-fd <fd>', 'Inherited protected Basic credential fd')
|
||||
.option('--registry <path>', 'Strict non-secret estate registry')
|
||||
.option('--token-dir <path>', 'Governed token directory')
|
||||
.option('--state-dir <path>', '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 <estate>', 'Explicit target estate')
|
||||
.requiredOption('--host <host>', 'Explicit provider host')
|
||||
.requiredOption('--actor <identity>', 'Explicit audit actor')
|
||||
.requiredOption('--actor <identity>', 'Explicit delegated inventory authority identity')
|
||||
.requiredOption('--authority-fd <fd>', 'Inherited protected Basic credential fd')
|
||||
.option('--registry <path>', 'Strict non-secret estate registry')
|
||||
.option('--state-dir <path>', 'Durable credential journal root')
|
||||
.option('--json', 'Emit one machine result object')
|
||||
.action(async (options: CredentialLifecycleCommandOptions): Promise<void> => {
|
||||
|
||||
@@ -25,6 +25,7 @@ const SAFE_DECISIONS = new Set<string>([
|
||||
'permission-write',
|
||||
'permission-admin',
|
||||
'identity-verified',
|
||||
'inventory-authority-verified',
|
||||
'scope-verified',
|
||||
'grant-verified',
|
||||
'revoke-verified',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void> => {
|
||||
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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Awaited<ReturnType<typeof open
|
||||
);
|
||||
}
|
||||
|
||||
function assertPrivate(snapshot: { readonly mode: number; readonly uid: number }): void {
|
||||
if ((snapshot.mode & 0o077) !== 0 || snapshot.uid !== process.getuid?.()) {
|
||||
throw new TeaLoginStoreError('tea-config-insecure', 'Tea config is not private');
|
||||
}
|
||||
}
|
||||
|
||||
function missing(error: unknown): boolean {
|
||||
return error instanceof Error && 'code' in error && error.code === 'ENOENT';
|
||||
}
|
||||
@@ -80,14 +87,12 @@ export class TeaLoginStore {
|
||||
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');
|
||||
}
|
||||
assertPrivate(snapshot);
|
||||
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 };
|
||||
current = decoded.data;
|
||||
} catch (error: unknown) {
|
||||
if (!missing(error)) throw error;
|
||||
}
|
||||
@@ -130,6 +135,7 @@ export class TeaLoginStore {
|
||||
if (missing(error)) return undefined;
|
||||
throw error;
|
||||
}
|
||||
assertPrivate(snapshot);
|
||||
const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8')));
|
||||
if (!decoded.success) return undefined;
|
||||
const matches = decoded.data.logins.filter(
|
||||
@@ -161,6 +167,7 @@ export class TeaLoginStore {
|
||||
root: directory,
|
||||
maxBytes: 1024 * 1024,
|
||||
});
|
||||
assertPrivate(snapshot);
|
||||
const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8')));
|
||||
if (!decoded.success) {
|
||||
throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation');
|
||||
@@ -171,7 +178,7 @@ export class TeaLoginStore {
|
||||
const temp = `${this.configPath}.${randomUUID()}.tmp`;
|
||||
const handle = await open(temp, 'wx', 0o600);
|
||||
try {
|
||||
await handle.writeFile(stringify({ logins }), 'utf8');
|
||||
await handle.writeFile(stringify({ ...decoded.data, logins }), 'utf8');
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
@@ -195,6 +202,7 @@ export class TeaLoginStore {
|
||||
if (missing(error)) return undefined;
|
||||
throw error;
|
||||
}
|
||||
assertPrivate(snapshot);
|
||||
const decoded: unknown = parse(snapshot.content.toString('utf8'));
|
||||
if (
|
||||
typeof decoded !== 'object' ||
|
||||
|
||||
@@ -289,6 +289,11 @@ function result(
|
||||
},
|
||||
evidence: {
|
||||
providerIdentity: validation?.evidence.providerIdentity ?? null,
|
||||
tokenCapabilities: validation?.evidence.tokenCapabilities ?? {
|
||||
state: 'not-measured',
|
||||
scopes: [],
|
||||
source: 'runtime-not-authorized',
|
||||
},
|
||||
repositoryPermission: validation?.evidence.repositoryPermission ?? null,
|
||||
writeDifferential: validation?.evidence.writeDifferential ?? null,
|
||||
collaboratorPermission: null,
|
||||
|
||||
@@ -32,6 +32,11 @@ export type {
|
||||
} from './credential-result.dto.js';
|
||||
|
||||
const JSON_CONTENT_TYPE = 'application/json';
|
||||
const RUNTIME_SCOPE_NOT_MEASURED = {
|
||||
state: 'not-measured' as const,
|
||||
scopes: [] as readonly string[],
|
||||
source: 'runtime-not-authorized' as const,
|
||||
};
|
||||
const RECEIVE_PACK_CONTENT_TYPE = 'application/x-git-receive-pack-advertisement';
|
||||
|
||||
interface ResultOptions {
|
||||
@@ -72,6 +77,7 @@ function result(
|
||||
reason: { code: options.code, message: options.message },
|
||||
evidence: options.evidence ?? {
|
||||
providerIdentity: null,
|
||||
tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED,
|
||||
repositoryPermission: null,
|
||||
writeDifferential: null,
|
||||
},
|
||||
@@ -186,6 +192,7 @@ function successfulEvidence(
|
||||
};
|
||||
return {
|
||||
providerIdentity: subjectIdentity,
|
||||
tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED,
|
||||
repositoryPermission: subjectPermission,
|
||||
writeDifferential,
|
||||
};
|
||||
@@ -226,6 +233,7 @@ async function evaluateGiteaReadValidationUnsafe(
|
||||
);
|
||||
const evidence: CredentialValidationEvidenceDto = {
|
||||
providerIdentity: null,
|
||||
tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED,
|
||||
repositoryPermission,
|
||||
writeDifferential: null,
|
||||
};
|
||||
@@ -267,6 +275,7 @@ async function evaluateGiteaReadValidationUnsafe(
|
||||
);
|
||||
const evidence: CredentialValidationEvidenceDto = {
|
||||
providerIdentity,
|
||||
tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED,
|
||||
repositoryPermission,
|
||||
writeDifferential: null,
|
||||
};
|
||||
@@ -359,6 +368,7 @@ async function evaluateGiteaWriteValidationUnsafe(
|
||||
const receivePack = await dependencies.provider.probeReceivePack(resolved, request.repo);
|
||||
const evidence: CredentialValidationEvidenceDto = {
|
||||
providerIdentity: null,
|
||||
tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED,
|
||||
repositoryPermission: permission,
|
||||
writeDifferential: null,
|
||||
};
|
||||
@@ -400,6 +410,7 @@ async function evaluateGiteaWriteValidationUnsafe(
|
||||
}
|
||||
const baseEvidence: CredentialValidationEvidenceDto = {
|
||||
providerIdentity: subjectEvidence.identity,
|
||||
tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED,
|
||||
repositoryPermission: subjectEvidence.permission,
|
||||
writeDifferential: null,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user