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.
|
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.
|
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`.
|
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, provider outage, or unavailable exact scope returns `indeterminate`, never success or policy refusal.
|
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.
|
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.
|
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.
|
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",
|
"endpoint": "GET /api/v1/user",
|
||||||
"contentType": "application/json"
|
"contentType": "application/json"
|
||||||
},
|
},
|
||||||
"tokenCapabilities": [],
|
"tokenCapabilities": {
|
||||||
|
"state": "not-measured",
|
||||||
|
"scopes": [],
|
||||||
|
"source": "runtime-not-authorized"
|
||||||
|
},
|
||||||
"repositoryPermission": {
|
"repositoryPermission": {
|
||||||
"requested": "write",
|
"requested": "write",
|
||||||
"effective": "write",
|
"effective": "write",
|
||||||
@@ -109,12 +113,12 @@ Fields may be `null` only when their enclosing evidence state explains why. Miss
|
|||||||
|
|
||||||
## Terminal classes
|
## Terminal classes
|
||||||
|
|
||||||
| Outcome | Exit | Meaning | Mutation guarantee | Caller action |
|
| 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. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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.
|
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": {
|
"evidence": {
|
||||||
"providerIdentity": null,
|
"providerIdentity": null,
|
||||||
"tokenCapabilities": [],
|
"tokenCapabilities": {
|
||||||
|
"state": "not-measured",
|
||||||
|
"scopes": [],
|
||||||
|
"source": "runtime-not-authorized"
|
||||||
|
},
|
||||||
"repositoryPermission": null,
|
"repositoryPermission": null,
|
||||||
"organizationMembership": null,
|
"organizationMembership": null,
|
||||||
"teamMembership": null,
|
"teamMembership": null,
|
||||||
|
|||||||
@@ -499,6 +499,15 @@ get_gitea_url_for_host() {
|
|||||||
|
|
||||||
# Resolve a Gitea API token for the given host.
|
# Resolve a Gitea API token for the given host.
|
||||||
# Priority: Mosaic credential loader → GITEA_TOKEN env → ~/.git-credentials
|
# 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() {
|
get_gitea_token() {
|
||||||
local host="$1"
|
local host="$1"
|
||||||
local script_dir
|
local script_dir
|
||||||
@@ -532,26 +541,14 @@ get_gitea_token() {
|
|||||||
local _idcred="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.credential.json"
|
local _idcred="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.credential.json"
|
||||||
if [[ -r "$_idcred" ]]; then
|
if [[ -r "$_idcred" ]]; then
|
||||||
local _resolved_token
|
local _resolved_token
|
||||||
_resolved_token=$(python3 - "$_idcred" <<'PY'
|
_resolved_token=$(python3 "$script_dir/resolve-credential-envelope.py" \
|
||||||
import json, sys
|
"$_idcred" "$_ident" "${MOSAIC_CREDENTIAL_ESTATE:-}" "$host") || return 1
|
||||||
value = json.load(open(sys.argv[1], encoding="utf-8")).get("token")
|
_trace_credential_resolution identity credential-resolved "$_ident" "$host" "$_ident_src"
|
||||||
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
|
|
||||||
printf '%s\n' "$_resolved_token"
|
printf '%s\n' "$_resolved_token"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
if [[ -r "$_idtok" ]]; then
|
if [[ -r "$_idtok" ]]; then
|
||||||
if [[ "${MOSAIC_CREDENTIAL_TRACE:-}" == 1 ]]; then
|
_trace_credential_resolution identity credential-resolved "$_ident" "$host" "$_ident_src"
|
||||||
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
|
|
||||||
cat "$_idtok"
|
cat "$_idtok"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
@@ -607,6 +604,7 @@ PY
|
|||||||
echo "${GITEA_TOKEN:-}"
|
echo "${GITEA_TOKEN:-}"
|
||||||
)
|
)
|
||||||
if [[ -n "$token" ]]; then
|
if [[ -n "$token" ]]; then
|
||||||
|
_trace_credential_resolution shared shared-credential-resolved '<interactive-shared>' "$host" credentials-loader
|
||||||
echo "$token"
|
echo "$token"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
@@ -615,6 +613,7 @@ PY
|
|||||||
# 2. GITEA_TOKEN env var (only when GITEA_URL, if present, matches the remote host)
|
# 2. GITEA_TOKEN env var (only when GITEA_URL, if present, matches the remote host)
|
||||||
if [[ -n "${GITEA_TOKEN:-}" ]]; then
|
if [[ -n "${GITEA_TOKEN:-}" ]]; then
|
||||||
if [[ -z "${GITEA_URL:-}" ]] || gitea_url_matches_host "$GITEA_URL" "$host"; 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"
|
echo "$GITEA_TOKEN"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
@@ -626,6 +625,7 @@ PY
|
|||||||
local token
|
local token
|
||||||
token=$(grep -F "$host" "$creds" 2>/dev/null | sed -n 's#https\?://[^@]*:\([^@/]*\)@.*#\1#p' | head -n 1)
|
token=$(grep -F "$host" "$creds" 2>/dev/null | sed -n 's#https\?://[^@]*:\([^@/]*\)@.*#\1#p' | head -n 1)
|
||||||
if [[ -n "$token" ]]; then
|
if [[ -n "$token" ]]; then
|
||||||
|
_trace_credential_resolution shared shared-credential-resolved '<interactive-shared>' "$host" git-credentials
|
||||||
echo "$token"
|
echo "$token"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -24,6 +24,15 @@ while IFS= read -r line; do
|
|||||||
username=*) username_in=${line#username=};;
|
username=*) username_in=${line#username=};;
|
||||||
esac
|
esac
|
||||||
done
|
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).
|
# Per-agent identity resolution (Gate-16 author≠reviewer separation).
|
||||||
# Priority: MOSAIC_GIT_IDENTITY env > git config mosaic.gitIdentity (per-worktree,
|
# Priority: MOSAIC_GIT_IDENTITY env > git config mosaic.gitIdentity (per-worktree,
|
||||||
# survives across non-persistent shells) > git-supplied username (credential.username
|
# 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"
|
idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.token"
|
||||||
idcred="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.credential.json"
|
idcred="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.credential.json"
|
||||||
if [ -r "$idcred" ]; then
|
if [ -r "$idcred" ]; then
|
||||||
token=$(python3 - "$idcred" <<'PY'
|
token=$(python3 "$script_dir/resolve-credential-envelope.py" \
|
||||||
import json, sys
|
"$idcred" "$ident" "${MOSAIC_CREDENTIAL_ESTATE:-}" "$host") || exit 1
|
||||||
value = json.load(open(sys.argv[1], encoding="utf-8")).get("token")
|
trace_resolution identity credential-resolved "$ident" "$host" git-credential-mosaic
|
||||||
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
|
|
||||||
echo "username=${ident}"
|
echo "username=${ident}"
|
||||||
echo "password=${token}"
|
echo "password=${token}"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
if [ -r "$idtok" ]; then
|
if [ -r "$idtok" ]; then
|
||||||
if [ "${MOSAIC_CREDENTIAL_TRACE:-}" = 1 ]; then
|
trace_resolution identity credential-resolved "$ident" "$host" git-credential-mosaic
|
||||||
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
|
|
||||||
echo "username=${ident}"
|
echo "username=${ident}"
|
||||||
echo "password=$(cat "$idtok")"
|
echo "password=$(cat "$idtok")"
|
||||||
exit 0
|
exit 0
|
||||||
@@ -98,10 +95,10 @@ esac
|
|||||||
# Script-relative (not $HOME-absolute) so this resolves correctly regardless
|
# Script-relative (not $HOME-absolute) so this resolves correctly regardless
|
||||||
# of where the framework installer places tools/ under $HOME — mirrors
|
# of where the framework installer places tools/ under $HOME — mirrors
|
||||||
# detect-platform.sh's own cred_loader resolution in this same directory.
|
# 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
|
# shellcheck source=../_lib/credentials.sh
|
||||||
source "$script_dir/../_lib/credentials.sh"
|
source "$script_dir/../_lib/credentials.sh"
|
||||||
load_credentials "$svc" >/dev/null 2>&1 || exit 0
|
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_USER is not populated by load_credentials (it only exports
|
||||||
# GITEA_URL/GITEA_TOKEN for gitea-*), so this fallback is normally taken. Gitea's
|
# 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),
|
# 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"
|
"$REPO_DIR"
|
||||||
|
|
||||||
cp "$SCRIPT_DIR/git-credential-mosaic" "$HELPER"
|
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"
|
chmod +x "$HELPER"
|
||||||
|
|
||||||
git -C "$REPO_DIR" init -q
|
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" "")
|
out=$(run_helper "git.mosaicstack.dev" "")
|
||||||
assert_eq "shared fallback: username" "username=git" "$(echo "$out" | grep '^username=')"
|
assert_eq "shared fallback: username" "username=git" "$(echo "$out" | grep '^username=')"
|
||||||
assert_eq "shared fallback: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
|
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
|
set +e
|
||||||
out=$(run_helper "git.mosaicstack.dev" "" MOSAIC_AGENT_NAME=synthetic-seat 2>"$WORK_DIR/fleet-unset.stderr")
|
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")
|
out=$(run_helper "git.mosaicstack.dev" "agentA")
|
||||||
assert_eq "username-resolved identity: username" "username=agentA" "$(echo "$out" | grep '^username=')"
|
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=')"
|
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
|
set +e
|
||||||
out=$(run_helper "git.mosaicstack.dev" "agentA" MOSAIC_AGENT_NAME=agentB 2>"$WORK_DIR/fleet-mismatch.stderr")
|
out=$(run_helper "git.mosaicstack.dev" "agentA" MOSAIC_AGENT_NAME=agentB 2>"$WORK_DIR/fleet-mismatch.stderr")
|
||||||
rc=$?
|
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=')"
|
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).
|
# remotes, e.g. github.com via a different credential helper).
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
out=$(run_helper "github.com" "agentA")
|
out=$(run_helper "github.com" "agentA")
|
||||||
assert_eq "unknown host: no output" "" "$out"
|
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).
|
# protocol: this helper only implements get).
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
store_out=$(cd "$REPO_DIR" && env -i HOME="$FAKE_HOME" PATH="$PATH" bash "$HELPER" store <<EOF
|
store_out=$(cd "$REPO_DIR" && env -i HOME="$FAKE_HOME" PATH="$PATH" bash "$HELPER" store <<EOF
|
||||||
|
|||||||
@@ -131,6 +131,11 @@ function errorResult(
|
|||||||
},
|
},
|
||||||
evidence: {
|
evidence: {
|
||||||
providerIdentity: null,
|
providerIdentity: null,
|
||||||
|
tokenCapabilities: {
|
||||||
|
state: 'not-measured',
|
||||||
|
scopes: [],
|
||||||
|
source: 'runtime-not-authorized',
|
||||||
|
},
|
||||||
repositoryPermission: null,
|
repositoryPermission: null,
|
||||||
writeDifferential: null,
|
writeDifferential: null,
|
||||||
},
|
},
|
||||||
@@ -220,6 +225,11 @@ function grantErrorResult(
|
|||||||
},
|
},
|
||||||
evidence: {
|
evidence: {
|
||||||
providerIdentity: null,
|
providerIdentity: null,
|
||||||
|
tokenCapabilities: {
|
||||||
|
state: 'not-measured',
|
||||||
|
scopes: [],
|
||||||
|
source: 'runtime-not-authorized',
|
||||||
|
},
|
||||||
repositoryPermission: null,
|
repositoryPermission: null,
|
||||||
writeDifferential: null,
|
writeDifferential: null,
|
||||||
collaboratorPermission: null,
|
collaboratorPermission: null,
|
||||||
@@ -586,6 +596,22 @@ export async function executeCredentialWire(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const locations = lifecycleLocations(options);
|
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, {
|
const journal = await CredentialAuditJournal.open(locations.stateRoot, {
|
||||||
operation: 'wire',
|
operation: 'wire',
|
||||||
actor: options.actor,
|
actor: options.actor,
|
||||||
@@ -613,9 +639,15 @@ export async function executeCredentialWire(
|
|||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
.filter(
|
.filter(
|
||||||
(line): boolean =>
|
(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 temp = `${options.seatEnv}.${process.pid.toString()}.tmp`;
|
||||||
const handle = await open(temp, 'wx', 0o600);
|
const handle = await open(temp, 'wx', 0o600);
|
||||||
try {
|
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(
|
export async function executeCredentialList(
|
||||||
options: CredentialLifecycleCommandOptions,
|
options: CredentialLifecycleCommandOptions,
|
||||||
): Promise<CredentialLifecycleResultDto> {
|
): Promise<CredentialLifecycleResultDto> {
|
||||||
try {
|
return executeAuthorizedInventoryRead('list', options);
|
||||||
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(
|
export async function executeCredentialAudit(
|
||||||
options: CredentialLifecycleCommandOptions,
|
options: CredentialLifecycleCommandOptions,
|
||||||
): Promise<CredentialLifecycleResultDto> {
|
): Promise<CredentialLifecycleResultDto> {
|
||||||
const journals = await listCredentialJournals(lifecycleLocations(options).stateRoot);
|
return executeAuthorizedInventoryRead('audit', options);
|
||||||
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<
|
type PrintableCredentialResult = Pick<
|
||||||
@@ -885,7 +962,8 @@ export function registerCredentialCommand(parent: Command): void {
|
|||||||
.description('List governed identities without reading or printing secret material')
|
.description('List governed identities without reading or printing secret material')
|
||||||
.requiredOption('--estate <estate>', 'Explicit target estate')
|
.requiredOption('--estate <estate>', 'Explicit target estate')
|
||||||
.requiredOption('--host <host>', 'Explicit provider host')
|
.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('--registry <path>', 'Strict non-secret estate registry')
|
||||||
.option('--token-dir <path>', 'Governed token directory')
|
.option('--token-dir <path>', 'Governed token directory')
|
||||||
.option('--state-dir <path>', 'Durable credential journal root')
|
.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')
|
.description('List durable credential journals without secret-bearing payloads')
|
||||||
.requiredOption('--estate <estate>', 'Explicit target estate')
|
.requiredOption('--estate <estate>', 'Explicit target estate')
|
||||||
.requiredOption('--host <host>', 'Explicit provider host')
|
.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('--state-dir <path>', 'Durable credential journal root')
|
||||||
.option('--json', 'Emit one machine result object')
|
.option('--json', 'Emit one machine result object')
|
||||||
.action(async (options: CredentialLifecycleCommandOptions): Promise<void> => {
|
.action(async (options: CredentialLifecycleCommandOptions): Promise<void> => {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const SAFE_DECISIONS = new Set<string>([
|
|||||||
'permission-write',
|
'permission-write',
|
||||||
'permission-admin',
|
'permission-admin',
|
||||||
'identity-verified',
|
'identity-verified',
|
||||||
|
'inventory-authority-verified',
|
||||||
'scope-verified',
|
'scope-verified',
|
||||||
'grant-verified',
|
'grant-verified',
|
||||||
'revoke-verified',
|
'revoke-verified',
|
||||||
|
|||||||
@@ -52,8 +52,15 @@ export interface WriteDifferentialEvidenceDto {
|
|||||||
readonly doesNotProve: string;
|
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 {
|
export interface CredentialValidationEvidenceDto {
|
||||||
readonly providerIdentity: ProviderIdentityEvidenceDto | null;
|
readonly providerIdentity: ProviderIdentityEvidenceDto | null;
|
||||||
|
readonly tokenCapabilities: TokenCapabilitiesEvidenceDto;
|
||||||
readonly repositoryPermission: RepositoryPermissionEvidenceDto | null;
|
readonly repositoryPermission: RepositoryPermissionEvidenceDto | null;
|
||||||
readonly writeDifferential: WriteDifferentialEvidenceDto | 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 { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { mkdtemp } from 'node:fs/promises';
|
import { mkdtemp } from 'node:fs/promises';
|
||||||
@@ -132,6 +132,18 @@ describe('phase-1 governed file credential resolver', (): void => {
|
|||||||
'git.example.invalid',
|
'git.example.invalid',
|
||||||
),
|
),
|
||||||
).resolves.toMatchObject({ identity: 'seat' });
|
).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 store.remove('seat', 'homelab', 'git.example.invalid');
|
||||||
await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual([]);
|
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',
|
'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 secret = validateSecret(Buffer.from(envelope.data.token, 'utf8'));
|
||||||
const digest = createHash('sha256').update(secret).digest('hex');
|
const digest = createHash('sha256').update(secret).digest('hex');
|
||||||
if (envelope.data.tokenDigest !== digest) {
|
if (envelope.data.tokenDigest !== digest) {
|
||||||
|
|||||||
@@ -98,6 +98,11 @@ export async function grantDirectRepositoryPermission(
|
|||||||
},
|
},
|
||||||
evidence: {
|
evidence: {
|
||||||
providerIdentity: authorityIdentity,
|
providerIdentity: authorityIdentity,
|
||||||
|
tokenCapabilities: {
|
||||||
|
state: 'not-measured',
|
||||||
|
scopes: [],
|
||||||
|
source: 'runtime-not-authorized',
|
||||||
|
},
|
||||||
repositoryPermission: null,
|
repositoryPermission: null,
|
||||||
writeDifferential: null,
|
writeDifferential: null,
|
||||||
collaboratorPermission: null,
|
collaboratorPermission: null,
|
||||||
@@ -241,6 +246,11 @@ export async function grantDirectRepositoryPermission(
|
|||||||
},
|
},
|
||||||
evidence: {
|
evidence: {
|
||||||
providerIdentity: null,
|
providerIdentity: null,
|
||||||
|
tokenCapabilities: {
|
||||||
|
state: 'not-measured',
|
||||||
|
scopes: [],
|
||||||
|
source: 'runtime-not-authorized',
|
||||||
|
},
|
||||||
repositoryPermission: null,
|
repositoryPermission: null,
|
||||||
writeDifferential: null,
|
writeDifferential: null,
|
||||||
collaboratorPermission: 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 { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { afterEach, describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
@@ -32,4 +32,23 @@ describe('host-bound Tea login store', (): void => {
|
|||||||
host: 'git.two.invalid',
|
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 {
|
interface TeaConfig {
|
||||||
readonly logins: TeaLoginRecord[];
|
readonly logins: TeaLoginRecord[];
|
||||||
|
readonly [key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loginSchema = z
|
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 {
|
function missing(error: unknown): boolean {
|
||||||
return error instanceof Error && 'code' in error && error.code === 'ENOENT';
|
return error instanceof Error && 'code' in error && error.code === 'ENOENT';
|
||||||
}
|
}
|
||||||
@@ -80,14 +87,12 @@ export class TeaLoginStore {
|
|||||||
root: directory,
|
root: directory,
|
||||||
maxBytes: 1024 * 1024,
|
maxBytes: 1024 * 1024,
|
||||||
});
|
});
|
||||||
if ((snapshot.mode & 0o077) !== 0 || snapshot.uid !== process.getuid?.()) {
|
assertPrivate(snapshot);
|
||||||
throw new TeaLoginStoreError('tea-config-insecure', 'Tea config is not private');
|
|
||||||
}
|
|
||||||
const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8')));
|
const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8')));
|
||||||
if (!decoded.success) {
|
if (!decoded.success) {
|
||||||
throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation');
|
throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation');
|
||||||
}
|
}
|
||||||
current = { logins: decoded.data.logins };
|
current = decoded.data;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (!missing(error)) throw error;
|
if (!missing(error)) throw error;
|
||||||
}
|
}
|
||||||
@@ -130,6 +135,7 @@ export class TeaLoginStore {
|
|||||||
if (missing(error)) return undefined;
|
if (missing(error)) return undefined;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
assertPrivate(snapshot);
|
||||||
const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8')));
|
const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8')));
|
||||||
if (!decoded.success) return undefined;
|
if (!decoded.success) return undefined;
|
||||||
const matches = decoded.data.logins.filter(
|
const matches = decoded.data.logins.filter(
|
||||||
@@ -161,6 +167,7 @@ export class TeaLoginStore {
|
|||||||
root: directory,
|
root: directory,
|
||||||
maxBytes: 1024 * 1024,
|
maxBytes: 1024 * 1024,
|
||||||
});
|
});
|
||||||
|
assertPrivate(snapshot);
|
||||||
const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8')));
|
const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8')));
|
||||||
if (!decoded.success) {
|
if (!decoded.success) {
|
||||||
throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation');
|
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 temp = `${this.configPath}.${randomUUID()}.tmp`;
|
||||||
const handle = await open(temp, 'wx', 0o600);
|
const handle = await open(temp, 'wx', 0o600);
|
||||||
try {
|
try {
|
||||||
await handle.writeFile(stringify({ logins }), 'utf8');
|
await handle.writeFile(stringify({ ...decoded.data, logins }), 'utf8');
|
||||||
await handle.sync();
|
await handle.sync();
|
||||||
} finally {
|
} finally {
|
||||||
await handle.close();
|
await handle.close();
|
||||||
@@ -195,6 +202,7 @@ export class TeaLoginStore {
|
|||||||
if (missing(error)) return undefined;
|
if (missing(error)) return undefined;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
assertPrivate(snapshot);
|
||||||
const decoded: unknown = parse(snapshot.content.toString('utf8'));
|
const decoded: unknown = parse(snapshot.content.toString('utf8'));
|
||||||
if (
|
if (
|
||||||
typeof decoded !== 'object' ||
|
typeof decoded !== 'object' ||
|
||||||
|
|||||||
@@ -289,6 +289,11 @@ function result(
|
|||||||
},
|
},
|
||||||
evidence: {
|
evidence: {
|
||||||
providerIdentity: validation?.evidence.providerIdentity ?? null,
|
providerIdentity: validation?.evidence.providerIdentity ?? null,
|
||||||
|
tokenCapabilities: validation?.evidence.tokenCapabilities ?? {
|
||||||
|
state: 'not-measured',
|
||||||
|
scopes: [],
|
||||||
|
source: 'runtime-not-authorized',
|
||||||
|
},
|
||||||
repositoryPermission: validation?.evidence.repositoryPermission ?? null,
|
repositoryPermission: validation?.evidence.repositoryPermission ?? null,
|
||||||
writeDifferential: validation?.evidence.writeDifferential ?? null,
|
writeDifferential: validation?.evidence.writeDifferential ?? null,
|
||||||
collaboratorPermission: null,
|
collaboratorPermission: null,
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ export type {
|
|||||||
} from './credential-result.dto.js';
|
} from './credential-result.dto.js';
|
||||||
|
|
||||||
const JSON_CONTENT_TYPE = 'application/json';
|
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';
|
const RECEIVE_PACK_CONTENT_TYPE = 'application/x-git-receive-pack-advertisement';
|
||||||
|
|
||||||
interface ResultOptions {
|
interface ResultOptions {
|
||||||
@@ -72,6 +77,7 @@ function result(
|
|||||||
reason: { code: options.code, message: options.message },
|
reason: { code: options.code, message: options.message },
|
||||||
evidence: options.evidence ?? {
|
evidence: options.evidence ?? {
|
||||||
providerIdentity: null,
|
providerIdentity: null,
|
||||||
|
tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED,
|
||||||
repositoryPermission: null,
|
repositoryPermission: null,
|
||||||
writeDifferential: null,
|
writeDifferential: null,
|
||||||
},
|
},
|
||||||
@@ -186,6 +192,7 @@ function successfulEvidence(
|
|||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
providerIdentity: subjectIdentity,
|
providerIdentity: subjectIdentity,
|
||||||
|
tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED,
|
||||||
repositoryPermission: subjectPermission,
|
repositoryPermission: subjectPermission,
|
||||||
writeDifferential,
|
writeDifferential,
|
||||||
};
|
};
|
||||||
@@ -226,6 +233,7 @@ async function evaluateGiteaReadValidationUnsafe(
|
|||||||
);
|
);
|
||||||
const evidence: CredentialValidationEvidenceDto = {
|
const evidence: CredentialValidationEvidenceDto = {
|
||||||
providerIdentity: null,
|
providerIdentity: null,
|
||||||
|
tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED,
|
||||||
repositoryPermission,
|
repositoryPermission,
|
||||||
writeDifferential: null,
|
writeDifferential: null,
|
||||||
};
|
};
|
||||||
@@ -267,6 +275,7 @@ async function evaluateGiteaReadValidationUnsafe(
|
|||||||
);
|
);
|
||||||
const evidence: CredentialValidationEvidenceDto = {
|
const evidence: CredentialValidationEvidenceDto = {
|
||||||
providerIdentity,
|
providerIdentity,
|
||||||
|
tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED,
|
||||||
repositoryPermission,
|
repositoryPermission,
|
||||||
writeDifferential: null,
|
writeDifferential: null,
|
||||||
};
|
};
|
||||||
@@ -359,6 +368,7 @@ async function evaluateGiteaWriteValidationUnsafe(
|
|||||||
const receivePack = await dependencies.provider.probeReceivePack(resolved, request.repo);
|
const receivePack = await dependencies.provider.probeReceivePack(resolved, request.repo);
|
||||||
const evidence: CredentialValidationEvidenceDto = {
|
const evidence: CredentialValidationEvidenceDto = {
|
||||||
providerIdentity: null,
|
providerIdentity: null,
|
||||||
|
tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED,
|
||||||
repositoryPermission: permission,
|
repositoryPermission: permission,
|
||||||
writeDifferential: null,
|
writeDifferential: null,
|
||||||
};
|
};
|
||||||
@@ -400,6 +410,7 @@ async function evaluateGiteaWriteValidationUnsafe(
|
|||||||
}
|
}
|
||||||
const baseEvidence: CredentialValidationEvidenceDto = {
|
const baseEvidence: CredentialValidationEvidenceDto = {
|
||||||
providerIdentity: subjectEvidence.identity,
|
providerIdentity: subjectEvidence.identity,
|
||||||
|
tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED,
|
||||||
repositoryPermission: subjectEvidence.permission,
|
repositoryPermission: subjectEvidence.permission,
|
||||||
writeDifferential: null,
|
writeDifferential: null,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user