Compare commits

..
16 Commits
Author SHA1 Message Date
be-coder-06 12958610cb fix(cred): make rollback and audit outcomes truthful
ci/woodpecker/pr/ci Pipeline failed
2026-08-05 18:01:41 -05:00
be-coder-06 593b5a1f45 fix(mosaic): harden credential mutation boundaries 2026-08-05 18:01:41 -05:00
be-coder-06 f4efc311ab fix(mosaic): fail closed across lifecycle audit faults 2026-08-05 18:01:41 -05:00
be-coder-06 b3a6959e46 fix(mosaic): bind delegated lifecycle evidence 2026-08-05 18:01:41 -05:00
be-coder-06 b3d84662f9 fix(mosaic): serialize credential lifecycle mutations 2026-08-05 18:01:41 -05:00
be-coder-06 9512adcec2 fix(mosaic): preserve credential transaction evidence 2026-08-05 18:01:41 -05:00
be-coder-06 46e65d9ad4 fix(mosaic): derive credential resolution traces 2026-08-05 18:01:41 -05:00
be-coder-06 e46e114d3b fix(mosaic): harden credential lifecycle boundaries 2026-08-05 18:01:41 -05:00
be-coder-06 26203bd92c fix(mosaic): close credential evidence gaps 2026-08-05 18:01:41 -05:00
be-coder-06 d18e49e8f4 fix(mosaic): bind lifecycle across storage and runtime 2026-08-05 18:01:41 -05:00
be-coder-06 791c57157a fix(mosaic): make credential lifecycle transactional 2026-08-05 18:01:41 -05:00
be-coder-06 fa301afb8c feat(mosaic): complete credential lifecycle and fail closed 2026-08-05 18:01:41 -05:00
be-coder-06 d5ed9cfdf1 fix(mosaic): preserve grant safety invariants 2026-08-05 18:01:41 -05:00
be-coder-06 ce0a8ad7d1 feat(mosaic): add governed credential validation and grants 2026-08-05 18:01:41 -05:00
be-coder-08andMos 85d2108e4e fix(ci): remove upgrade rollback signal race (#1060)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: be-coder-08 <[email protected]>
2026-08-05 22:14:15 +00:00
be-coder-08andMos 16f91157a1 test(ci): make queue guard harness deterministic (#1062)
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: be-coder-08 <[email protected]>
2026-08-05 21:49:44 +00:00
18 changed files with 2327 additions and 154 deletions
+5 -5
View File
@@ -451,7 +451,7 @@ Phase 1 governs the existing per-identity Gitea token store and Tea login regist
1. `CRED-REQ-01`: The CLI SHALL expose `provision`, `wire`, `grant`, `get`, `validate`, `whoami`, `list`, `rotate`, `revoke`, and `audit`. Grant and validate SHALL conform to [`docs/credentials/GRANT-VALIDATE-CONTRACT.md`](./credentials/GRANT-VALIDATE-CONTRACT.md).
2. `CRED-REQ-02`: Every provider operation SHALL carry an explicit identity, estate, and host. Estate-to-host mapping SHALL come from strict non-secret configuration. Missing, ambiguous, inferred, or mismatched values SHALL refuse before credential resolution. Machine location SHALL grant no estate authority.
3. `CRED-REQ-03`: Token capability and Tea login identity are inseparable. Provisioning SHALL create/register both or neither. At mint time, delegated Basic authority SHALL read its provider principal back, the minted token object SHALL read back exact scopes, and both the token binding and exact host-bound Tea record SHALL contain that same minted credential. Runtime `/user` identity remeasurement is required only when the seat token already carries `read:user`; least-privilege tokens SHALL NOT be widened to service the instrument. A wrong-host or absent Tea login SHALL never fall back to a host default.
3. `CRED-REQ-03`: Token capability and Tea login identity are inseparable. Provisioning SHALL create/register both or neither. At mint time, delegated Basic authority SHALL read its provider principal back, the minted token object SHALL read back exact scopes, and both the token binding and exact host-bound Tea record SHALL contain that same minted credential. Rollback SHALL read provider, token-store, and Tea state back and may report complete only when the pre-operation state is established exactly; failed or unverified cleanup is `indeterminate`/`rollback-incomplete`. Provision, rotate, and revoke SHALL serialize the complete provider/token-store/Tea transaction between cooperating `mosaic cred` processes under one fixed estate/host/identity advisory lock that caller-selected state roots cannot bypass. That same-UID-replaceable filesystem lock and the file-store generation preconditions provide optimistic concurrency only for cooperating `mosaic cred` mutators; neither is an authorization boundary nor atomic CAS against a hostile same-UID filesystem writer. Provider authority is the authorization boundary. Hostile same-UID direct filesystem mutation is explicitly out of scope for this phase and deferred. Runtime `/user` identity remeasurement is required only when the seat token already carries `read:user`; least-privilege tokens SHALL NOT be widened to service the instrument. A wrong-host or absent Tea login SHALL never fall back to a host default.
4. `CRED-REQ-04`: Under fleet context, unset or unresolvable identity SHALL fail closed identically in the git credential helper and API resolver. Interactive shared credentials remain available only through an explicit non-fleet/shared selection; absence SHALL never select them.
5. `CRED-REQ-05`: Token scope, repository permission, and organization/team role are independent layers. Provision, grant, and validate SHALL report each separately from provider evidence. No layer substitutes for another, and a permission widening at one layer SHALL not be described as least privilege because another layer is narrow.
6. `CRED-REQ-06`: Gitea token creation SHALL use an explicit delegated provisioning step because this provider requires Basic Auth. Password-equivalent provisioning material SHALL enter only through a protected control-plane runtime credential channel, never caller bearer storage, argv, ordinary environment, logs, or output.
@@ -460,7 +460,7 @@ Phase 1 governs the existing per-identity Gitea token store and Tea login regist
9. `CRED-REQ-09`: All provider HTTP calls SHALL share one transport implementation for URL/host binding, TLS, User-Agent, content-type, JSON-shape validation, redaction, and bounded responses. A 2xx status alone SHALL never establish identity, scope, permission, grant, or revocation.
10. `CRED-REQ-10`: Operations SHALL return stable machine outcomes `ok`, `refused`, `error`, or `indeterminate`. Policy refusal, local operational failure, and incomplete/inconsistent evidence SHALL remain distinguishable. `provider-unavailable`, `identity-not-measured`, `identity-not-visible`, `identity-not-found`, and `credential-rejected` SHALL remain distinct diagnoses. Validation SHALL report capability from an in-scope probe separately from identity measurement. `/user` 401 is `credential-rejected`/refused; `/user` 403/404 plus successful in-scope capability is `identity-not-measured`, never a dead credential. A returned login mismatch is a binding refusal. No implemented operation may emit `identity-not-found`; that diagnosis requires a separately approved visibility-authorized inventory capability. Security callers SHALL fail closed on every outcome except `ok` without relabelling indeterminate evidence as a denial.
11. `CRED-REQ-11`: No command SHALL print a token, password, authorization header, fingerprint, partial secret, or secret-bearing provider body, including error paths. Secrets SHALL not appear in process argv. Phase-1 file storage SHALL remain private, symlink-safe, regular-file-only, test-overridable, and compatible with existing managed token consumers.
12. `CRED-REQ-12`: Every issue, provision, grant, rotate, revoke, and credential access SHALL be journaled with actor, subject, estate, host, repo/scope, operation, time, and non-secret provider evidence. The durable journal SHALL be opened and fsynced before the first mutation, append each mutation/read-back, and seal only after acceptance. Journal/audit write failure SHALL be fatal; an unsealed journal means incomplete/indeterminate work.
12. `CRED-REQ-12`: Every issue, provision, grant, rotate, revoke, and credential access SHALL be journaled with actor, subject, estate, host, repo/scope, operation, time, and non-secret provider evidence. The durable journal SHALL be opened and fsynced before the first mutation, append each mutation/read-back, and seal only after acceptance. Credential access SHALL durably record issuance start immediately before protected-fd disclosure; any partial write or post-write audit failure SHALL remain visibly `indeterminate` with mutation `unknown` or `applied`, never be relabelled as a pre-disclosure destination error. A journal seal SHALL remain staged and recovery-visible until its final rename and directory durability succeed; a final seal-commit fault SHALL revert to open classification and cannot leave an acceptance-bearing sealed-success object. Journal/audit write failure SHALL be fatal; an unsealed journal means incomplete/indeterminate work.
13. `CRED-REQ-13`: `wire` SHALL be idempotent and SHALL update the exact roster-derived `<identity>.env.generated` fleet projection so both identity axes survive restart. It SHALL authenticate the same explicit seat through a protected delegated credential channel and provider identity read-back before mutation, refuse actor/identity/path/roster disagreements, and never authorize from the shared Unix account. It SHALL not write linked-worktree git configuration or silently infer identity from pane/session names.
14. `CRED-REQ-14`: Rotate SHALL verify the new credential/provider identity before retiring the old credential. Revoke SHALL read back provider revocation/denial and preserve an auditable recovery record. A local file deletion or successful HTTP status is not revocation evidence.
15. `CRED-REQ-15`: Before the #1044 fail-closed resolver change is eligible to land, `mosaic cred validate` SHALL resolve every live HOMELAB mosaic-lane seat from `git.mosaicstack.dev` by provider read-back. Any unresolved seat HOLDS the fail-closed change; the implementation may not widen or restore shared fallback.
@@ -469,12 +469,12 @@ Phase 1 governs the existing per-identity Gitea token store and Tea login regist
### Acceptance criteria
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 reads back exact pre-operation provider/token-store/Tea state. Injected Tea cleanup failure returns `indeterminate`/`rollback-incomplete` and cannot claim mutation `none`; a second cooperating same-identity transaction is refused across caller-selected state roots. A code-level security-model assertion pins that advisory flock as cooperative serialization, provider authority as the authorization boundary, generation preconditions as optimistic protection for cooperating mutators, and hostile same-UID filesystem mutation as explicitly deferred.
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.
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. Protected credential output fault injection covers partial write, post-write append, and seal failure and preserves possibly-issued/applied truth in both DTO and durable journal.
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, stale generations from cooperating concurrent mutators, and production-store leakage into fixture tests. They do not claim atomic exclusion against hostile same-UID direct filesystem mutation. Existing canonical per-seat token consumers continue through the governed adapter.
8. `AC-CRED-08`: `wire` repeated twice is byte-idempotent, produces both required identity-axis values in the exact roster-derived generated environment, survives a fresh fleet projection/restart path, and leaves shared linked-worktree git config untouched. An unauthenticated caller, a caller authenticated as another seat, a caller-selected filename, or a file whose roster identity differs is refused before mutation.
9. `AC-CRED-09`: Rotate validates new identity/capabilities before retiring old material; injected failure leaves the previously valid credential usable and the journal open. Revoke is accepted only when provider read-back proves the credential no longer authenticates/authorizes.
10. `AC-CRED-10`: Every live HOMELAB mosaic-lane seat resolves from `git.mosaicstack.dev` before the #1044 fallback closes. The evidence names the complete seat population, provider endpoint/content type, and unresolved count; non-zero unresolved count blocks landing.
@@ -0,0 +1,71 @@
# #1019 — Zero-timeout queue-guard harness race
- **Issue:** #1019 (parent status remains `believed-fixed, pending jarvis validation`; do not close)
- **Branch:** `fix/1019-ci-queue-timeout-harness`
- **Owner:** `be-coder-08`
- **Base:** `origin/main` at `5916aeefd6ed12bcac086c6834c7f6c4ae38e1bc`
- **Charter:** `/home/hermes/agent-work/tl-mosaic/CHARTER-1019-HARNESS-FIX.md`
## Objective
Make `test-ci-queue-wait-tristate.sh` deterministic without changing any asserted outcome. Remove the indiscriminate zero-timeout race, require every status-classification case to prove the provider was observed, and prove the harness-controlled virtual clock is active.
## Scope
- In scope: `packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh` only, plus this evidence scratchpad.
- Out of scope: guard parsers, D2/D3 behavior, installer/reseed staleness, PR #1060, and issue closure.
## Acceptance criteria
1. RED deterministically reproduces deadline pre-emption before the provider call.
2. Every case that intends status classification positively proves provider observation.
3. Pending observes `pending` before deterministic virtual-time expiration.
4. The virtual clock has a positive interception control; a broken-clock mutant makes the suite red.
5. The exact CI-base image passes the final harness repeatedly with zero failures.
6. Baseline gates, independent code/security review, exact-head CI, and coordinator-authorized squash merge pass.
## Plan
1. Add deterministic RED instrumentation for the known merge/provider-unreachable pre-emption.
2. Replace global `-t 0` with a nonzero timeout interpreted under an event-driven virtual clock; stub sleep without wall waiting.
3. Add provider-observation and virtual-clock positive controls without changing outcome assertions.
4. Run focused shell checks, repeat in exact CI-base image, baseline gates, and independent reviews.
5. Commit with both identity layers, queue-guard plus direct Woodpecker terminal-state verification, push, self-post PR, verify poster/head/CI, obtain coordinator merge authorization, then squash merge without closing #1019.
## Budget
- No explicit token cap supplied. Keep scope to one harness file and one scratchpad; stop/report at the charter's 60% context gate.
## Evidence
- RED, deterministic pre-provider expiry: `evidence/1019-harness-fix/red-pre-provider-expiry.log` — rc 1; merge/provider-unreachable got rc 124 instead of 75, omitted CANNOT_ASSERT, did not observe the status provider, and wrote no additional audit record (four named failures).
- GREEN host focused harness: `evidence/1019-harness-fix/green-host.log` — rc 0, all outcome classes passed.
- Load-bearing clock negative control: a temporary same-directory mutant replaced the virtual `date` body with `/bin/date`; `evidence/1019-harness-fix/red-clock-not-intercepted.log` — rc 1 with named `virtual clock interception did not run` failures. The mutant file was removed after the run.
- Exact CI-base repeat: `git.mosaicstack.dev/mosaicstack/stack/ci-base:latest`, repository mounted read-only, harness work under container `/tmp`; `evidence/1019-harness-fix/ci-image-repeat/summary.log`**100 pass / 0 fail / 100 total**.
- Synchronization design: provider-status observation creates the event marker; virtual time is 1000 before the event and 1002 afterward. Pending alone reaches the stubbed no-op sleep and a post-observation deadline check. `-t 1` is uniquely load-bearing because removing it restores the 900-second default deadline at virtual time 1900, which 1002 does not cross. The numeric timeout is subject semantics under virtual time, not a wall-clock synchronization duration.
## Review remediation — semantic timeout vs. liveness bound
Security review found that virtual time remained at 1000 forever before provider observation and stubbed sleep never waited. A regression looping before the status endpoint—or blocking in the first provider call—therefore could prevent `run_guard` from returning, so the post-return provider assertion could never fire.
**General rule:** A timeout usually serves two purposes: semantics and liveness. Removing wall time from semantic synchronization can silently remove the only independent hang bound. Preserve deterministic virtual time for subject semantics, but provide a separately implemented real-clock liveness watchdog and prove that watchdog fires.
Remediation:
- Every guard subject invocation is launched by absolute `/usr/bin/python3` in a new session. Python's internal monotonic `wait(timeout=...)` provides real-clock liveness independently of PATH; expiry kills the entire isolated process group, so neither PATH-front shims nor a blocked provider descendant can retain the capture pipe.
- Watchdog expiry returns distinct harness rc 90 plus `FAIL HANG watchdog`, separate from subject timeout rc 124.
- A first attempt using absolute `/usr/bin/timeout -s KILL` passed on GNU coreutils but failed in the exact Alpine CI-base image: BusyBox killed the immediate wrapper while the guard/provider descendants survived and retained the command-substitution pipe. The process-group kill is therefore required behavior, not portability polish.
- A committed positive control hangs the branch-provider stub before the status endpoint. It must terminate through the watchdog, emit the hang-specific diagnostic, return rc 90, and prove the status provider was never reached.
- RED before remediation: a temporary ordinary-success mutant hung before provider observation; only an external control could kill the suite (rc 137), and there was no internal hang-specific diagnostic (`red-watchdog-absent.log`).
- The watchdog mutant/control is load-bearing: removing the internal watchdog leaves the control unable to produce its required rc 90 and diagnostic.
Post-review evidence:
- Host focused harness with process-group watchdog: rc 0 (`green-watchdog-process-group-host.log`).
- Exact Alpine CI-base focused harness with process-group watchdog: rc 0 (`green-watchdog-ci-image.log`).
- Hanging ordinary-success mutant: suite rc 1; success returned rc 90, emitted `FAIL HANG watchdog`, and loudly reported that provider/clock observation did not occur (`red-watchdog-fires.log`).
- Removed-`-t 1` mutant: suite rc 1; pending was terminated by the watchdog instead of producing `ASSERTED_NOT_READY`, proving the explicit timeout is load-bearing (`red-timeout-argument-removed.log`).
## 60% context hold
Stopped before baseline/review/commit as required by the charter. Remaining: inspect final diff, shell/static/baseline gates, independent code/security review, remediation if any, identity-bound commit/trailer verification, mandatory queue guard plus direct terminal Woodpecker `mosaic` enumeration, push, self-posted PR/provider poster read-back, exact-head terminal-green CI, coordinator merge authorization, squash merge, main CI verification, and leave #1019 unclosed as `believed-fixed, pending jarvis validation`.
+6 -2
View File
@@ -53,6 +53,9 @@ No explicit token cap supplied. Working cap: keep implementation in one package
- [x] Red-first principal-bound validate, estate-registry, file-store, provider-transport, and journal tests.
- [x] Implementation: validate, direct/team grant, protected delegated authority, provision/wire/get/whoami/list/rotate/revoke/audit, reverse registry, and fleet fail-closed resolver paths.
- [x] Review hardening: rotation returns visible open journals; team evidence records absent objects accurately; team scope is checked before/after under a host-qualified OS advisory lock with verified compensation; `wire` authenticates the exact seat/path/roster binding and preserves post-rename mutation semantics.
- [x] Author advisory remediation: provisioning rollback verifies provider/token-store/Tea state and cannot suppress Tea cleanup failure; Tea put/remove always unlink secret-bearing temporary files; unrelated hosts exit before Mosaic identity grammar; `cred get` persists issuance start before disclosure, writes through a short-write-safe loop, and preserves partial/applied indeterminate semantics under write/append/seal faults; provision/rotate/revoke share a fixed cross-state-root lifecycle lock for cooperating processes only (provider authority is the authorization boundary; generation preconditions are optimistic for cooperating mutators; hostile same-UID filesystem mutation is explicitly deferred); Tea pre-state is snapshotted/restored independently; final journal seal faults revert to open classification.
- [ ] Legacy-token estate binding is explicitly deferred: the legacy format contains no estate and the installed shell path has no distributed strict registry. Enabling refusal before registry distribution plus token-envelope migration would strand active legacy-only seats; governed envelopes already bind estate exactly.
- [ ] Hostile same-UID direct filesystem mutation is explicitly deferred to #1065. The finding is copied verbatim there; PR #1059 links it. Phase 1 claims only optimistic generation preconditions and advisory locking for cooperating mutators, with provider authority as the authorization boundary.
- [ ] Independent code/security approvals on the final exact head (Codex advisory iterations are not independent approval).
- [ ] Final exact-head CI and provider evidence.
@@ -66,7 +69,7 @@ Red-first evidence:
- read validation absent → 2 tests failed `evaluateGiteaReadValidation is not a function`; after implementation, 15/15 validate tests passed;
- estate registry, secure file resolver, Gitea transport, and audit journal each failed first because the module did not exist, then passed focused behavior suites.
Current focused evidence: 77/77 across 11 credential/command suites; package lint, typecheck, formatting, and build are green. Full package Vitest reached 1,578 passing tests and three unrelated CLI-smoke failures caused solely by the installed-version update banner writing to stderr. Provider bodies are stream-bounded and requests deadline-bounded; delegated fd input is ownership/mode/size/time bounded; token and Tea stores are private and atomic; grant mutation/read-back state is journaled.
Pre-remediation focused evidence: 80/80 across 11 credential/command suites; package lint, typecheck, formatting, and build were green. The advisory remediation adds thirty-five focused regression cases for rollback cleanup, Tea temporary-file cleanup, unsupported-host passthrough, and credential issuance partial-write/post-write-audit truthfulness; focused reruns are green (latest credential/command set: 109/109). Final uncommitted author advisories report code `approve` with no findings and security `none` with no findings; they remain advisory rather than independent approval. Full package Vitest reached 1,614 passing tests and three unrelated CLI-smoke failures caused solely by the installed-version update banner writing to stderr. Provider bodies are stream-bounded and requests deadline-bounded; delegated fd input is ownership/mode/size/time bounded; token and Tea stores are private and use atomic file replacement for cooperating mutators; grant mutation/read-back state is journaled.
Fail-closed resolver evidence: synthetic missing-token API and git paths each emitted stable `MOSAIC_CREDENTIAL_REFUSAL` with `reason=no-token-for-identity` and `shared_path_entered=false`; all 13 live token-bearing identities bypassed the shared path without over-fire in the same run. Evidence: `/home/hermes/agent-work/be-coder-06/review-evidence/failclosed-postcondition.jsonl`; independent verification remains tl-mosaic's obligation.
@@ -76,7 +79,8 @@ Write differential for be-coder-06 passed with the configured read-only control
## Risks/blockers
- Hostile same-UID direct filesystem mutation is outside phase-1 and requires a transactional service, broker/distinct identity, or equivalent non-bypassable primitive; lifecycle flock and generation preconditions cover cooperating `mosaic cred` mutators only. Track the explicit security deferral linked from PR #1059.
- The full CLI surface is broad; protect scope by sharing one provider/registry/journal core rather than per-command scripts.
- Gitea exact token-scope read-back may require delegated Basic Auth. If a bearer-only validation path cannot obtain an exact provider token object, return `indeterminate` rather than claim a scope.
- #1044 hold is LIFTED. The four least-privilege credentials are capability-confirmed and identity-not-measured, not dead. Fleet fail-closed paths now refuse with stable reason markers and never enter shared fallback under `MOSAIC_AGENT_NAME`; interactive callers retain explicit shared behavior. Runtime mismatch coverage remains limited to tokens holding `read:user`; future mints close identity binding at creation without widening seat scopes.
- C1 PR #1054 must first be rebuilt from only its four commits on `main`; the retargeted head `8b067839` carries 13 unrelated `next` commits and is not merge-eligible. MC-CRED remains sequenced after the clean C1 merge.
- C1 PR #1054 remains sequencing-prior and requires its own exact-head gates. MC-CRED PR #1059 must freeze only after a new advisory review, independent review, and exact-head CI.
@@ -42,7 +42,7 @@ resolution_path=unresolved
case "$host" in
git.uscllc.com) idpfx=gitea-usc;;
git.mosaicstack.dev) idpfx=gitea-mosaicstack;;
*) idpfx="";;
*) exit 0;;
esac
ident="$MOSAIC_GIT_IDENTITY"
[ -z "$ident" ] && ident=$(git config --get mosaic.gitIdentity 2>/dev/null)
@@ -9,10 +9,51 @@ WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-tristate}
REPO_DIR="$WORK_DIR/repo"
STUB_DIR="$WORK_DIR/stubs"
AUDIT_LOG="$WORK_DIR/audit/ci-queue-wait.jsonl"
STATUS_OBSERVED="$WORK_DIR/status-observed"
CLOCK_LOG="$WORK_DIR/clock.log"
WATCHDOG_PYTHON="/usr/bin/python3"
WATCHDOG_SCRIPT="$WORK_DIR/real-clock-watchdog.py"
WATCHDOG_TIMEOUT_SEC=5
WATCHDOG_EXIT=90
FEATURE_BRANCH="fix/rm-03-fixture"
if [[ ! -x "$WATCHDOG_PYTHON" ]]; then
echo "FAIL setup: required real-clock watchdog runtime is unavailable at $WATCHDOG_PYTHON" >&2
exit 1
fi
rm -rf "$WORK_DIR"
mkdir -p "$REPO_DIR" "$STUB_DIR"
cat > "$WATCHDOG_SCRIPT" <<'PY'
import os
import signal
import subprocess
import sys
if len(sys.argv) < 3:
raise SystemExit(2)
timeout_seconds = float(sys.argv[1])
process = subprocess.Popen(sys.argv[2:], start_new_session=True)
try:
return_code = process.wait(timeout=timeout_seconds)
except subprocess.TimeoutExpired:
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
process.wait()
print(
f"FAIL HANG watchdog: subject exceeded {timeout_seconds:g}s "
"before completing its intended path",
file=sys.stderr,
)
raise SystemExit(90)
if return_code < 0:
raise SystemExit(128 - return_code)
raise SystemExit(return_code)
PY
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" checkout -q -b "$FEATURE_BRANCH"
git -C "$REPO_DIR" remote add origin https://git.example.test/acme/widgets.git
@@ -33,6 +74,9 @@ printf '%s\n' "$url" >> "${MOSAIC_STUB_URL_LOG:?}"
case "$url" in
*/branches/*)
if [[ "${MOSAIC_STUB_BRANCH_MODE:-ok}" == "hang-before-provider" ]]; then
while :; do :; done
fi
if [[ "${MOSAIC_STUB_BRANCH_MODE:-ok}" == "unreachable" ]]; then
exit 7
fi
@@ -44,6 +88,7 @@ case "$url" in
fi
;;
*/status)
: > "${MOSAIC_STUB_STATUS_OBSERVED:?}"
case "${MOSAIC_STUB_STATUS_MODE:?}" in
success) printf '%s' '{"state":"success","statuses":[{"status":"success"}]}' ;;
pending) printf '%s' '{"state":"pending","statuses":[{"status":"pending","context":"ci/test"}]}' ;;
@@ -63,7 +108,31 @@ case "$url" in
*) echo "unexpected curl URL: $url" >&2; exit 2 ;;
esac
SH
chmod +x "$STUB_DIR/curl"
cat > "$STUB_DIR/date" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
if [[ "$#" -ne 1 || "$1" != "+%s" ]]; then
echo "unexpected date invocation: $*" >&2
exit 2
fi
if [[ -e "${MOSAIC_STUB_STATUS_OBSERVED:?}" ]]; then
printf 'date-phase=after-status\n' >> "${MOSAIC_STUB_CLOCK_LOG:?}"
printf '1002\n'
else
printf 'date-phase=before-status\n' >> "${MOSAIC_STUB_CLOCK_LOG:?}"
printf '1000\n'
fi
SH
cat > "$STUB_DIR/sleep" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
printf 'sleep-after-status=%s\n' "$*" >> "${MOSAIC_STUB_CLOCK_LOG:?}"
SH
chmod +x "$STUB_DIR/curl" "$STUB_DIR/date" "$STUB_DIR/sleep"
run_guard() {
local status_mode="$1"
@@ -83,13 +152,46 @@ run_guard() {
export GITEA_URL=https://git.example.test
export MOSAIC_STUB_STATUS_MODE="$status_mode"
fi
rm -f "$STATUS_OBSERVED" "$CLOCK_LOG"
export MOSAIC_STUB_URL_LOG="$WORK_DIR/urls.log"
export MOSAIC_STUB_STATUS_OBSERVED="$STATUS_OBSERVED"
export MOSAIC_STUB_CLOCK_LOG="$CLOCK_LOG"
export MOSAIC_CI_QUEUE_AUDIT_LOG="$audit_log"
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose "${MOSAIC_TEST_PURPOSE:-push}" -t 0 -i 0 "$@"
# Provider observation is the synchronization event. The one-second
# timeout is subject semantics under virtual time, never a wall wait.
# The absolute Python runtime uses an internal monotonic wait and kills
# the subject's isolated process group. Neither operation can resolve
# to the virtual date/sleep stubs at the front of PATH.
local subject_rc
if "$WATCHDOG_PYTHON" "$WATCHDOG_SCRIPT" "$WATCHDOG_TIMEOUT_SEC" \
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose "${MOSAIC_TEST_PURPOSE:-push}" -t 1 -i 1 "$@"; then
subject_rc=0
else
subject_rc=$?
fi
return "$subject_rc"
)
}
failures=0
assert_provider_observed() {
local name="$1" require_expiration="${2:-0}"
if [[ ! -e "$STATUS_OBSERVED" ]]; then
echo "FAIL $name: status provider was not observed" >&2
failures=$((failures + 1))
fi
if [[ ! -s "$CLOCK_LOG" ]] || ! grep -q '^date-phase=before-status$' "$CLOCK_LOG"; then
echo "FAIL $name: virtual clock interception did not run before provider observation" >&2
failures=$((failures + 1))
fi
if [[ "$require_expiration" -eq 1 ]]; then
if ! grep -q '^sleep-after-status=' "$CLOCK_LOG" || ! grep -q '^date-phase=after-status$' "$CLOCK_LOG"; then
echo "FAIL $name: pending path did not expire after provider observation" >&2
failures=$((failures + 1))
fi
fi
}
run_assertion() {
local name="$1" expected_rc="$2" status_mode="$3" required_text="$4"
local output rc
@@ -124,6 +226,13 @@ run_assertion() {
printf '%s\n' "$output" >&2
failures=$((failures + 1))
fi
if [[ "$status_mode" != "credential-unresolvable" ]]; then
if [[ "$status_mode" == "pending" ]]; then
assert_provider_observed "$name" 1
else
assert_provider_observed "$name"
fi
fi
}
set -e
@@ -140,6 +249,27 @@ run_assertion large-payload not126 large-success 'state=terminal-success'
run_assertion credential-unresolvable zero credential-unresolvable 'CANNOT_ASSERT'
run_assertion provider-unreachable zero unreachable 'CANNOT_ASSERT'
# Positive liveness control: a subject mutant hangs before the branch lookup
# can reach the status provider. Only the independent real-clock watchdog may
# terminate it, and its failure must be distinct from subject timeout rc=124.
set +e
watchdog_output=$(MOSAIC_STUB_BRANCH_MODE=hang-before-provider run_guard success "$AUDIT_LOG" 2>&1)
watchdog_rc=$?
set -e
if [[ "$watchdog_rc" -ne "$WATCHDOG_EXIT" ]]; then
echo "FAIL watchdog-control: expected hang-specific rc=$WATCHDOG_EXIT, got rc=$watchdog_rc" >&2
failures=$((failures + 1))
fi
if [[ "$watchdog_output" != *"FAIL HANG watchdog:"* ]]; then
echo "FAIL watchdog-control: expected distinct hang-specific diagnostic" >&2
printf '%s\n' "$watchdog_output" >&2
failures=$((failures + 1))
fi
if [[ -e "$STATUS_OBSERVED" ]]; then
echo "FAIL watchdog-control: hanging mutant unexpectedly reached the status provider" >&2
failures=$((failures + 1))
fi
if [[ ! -s "$AUDIT_LOG" ]] || ! grep -q '"outcome":"CANNOT_ASSERT"' "$AUDIT_LOG"; then
echo "FAIL provider-unreachable-audit: expected durable CANNOT_ASSERT JSONL record" >&2
failures=$((failures + 1))
@@ -160,6 +290,7 @@ if [[ "$merge_unreachable_output" != *"CANNOT_ASSERT"* ]]; then
echo "FAIL merge-provider-unreachable: expected loud CANNOT_ASSERT diagnostic" >&2
failures=$((failures + 1))
fi
assert_provider_observed merge-provider-unreachable
merge_audit_lines_after=$(wc -l < "$AUDIT_LOG")
if [[ "$merge_audit_lines_after" -le "$merge_audit_lines_before" ]]; then
echo "FAIL merge-provider-unreachable: expected an additional audit record" >&2
@@ -223,6 +354,7 @@ if [[ "$audit_failure_output" != *"audit"* ]]; then
echo "FAIL audit-unavailable: expected loud audit failure diagnostic" >&2
failures=$((failures + 1))
fi
assert_provider_observed audit-unavailable
if [[ "$failures" -ne 0 ]]; then
echo "ci-queue-wait tri-state regression failed ($failures assertions)" >&2
@@ -213,6 +213,8 @@ fi
# ---------------------------------------------------------------------------
out=$(run_helper "github.com" "agentA")
assert_eq "unknown host: no output" "" "$out"
out=$(run_helper "github.com" "[email protected]")
assert_eq "unknown host with non-Mosaic username: no output" "" "$out"
out=$(run_helper "github.com" "github-user" MOSAIC_AGENT_NAME=agentA)
assert_eq "unknown host in fleet context: no output" "" "$out"
@@ -39,11 +39,12 @@ ORIG_PATH="$PATH"
# loop — which would make the control a false negative. A root dotfile is
# operator-owned (unknown→operator), so the sync loop skips it. Clean up on exit.
STRIPPED="$FW/.install-rollback-control.tmp.sh"
SIGNALED="$FW/.install-signal-control.tmp.sh"
NOEXIT="$FW/.install-noexit-control.tmp.sh"
D1CTRL="$FW/.install-d1guard-control.tmp.sh"
D2CTRL="$FW/.install-d2guard-control.tmp.sh"
rm -f "$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
trap 'rm -f "$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"' EXIT
rm -f "$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
trap 'rm -f "$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"' EXIT
pass=0; fail=0
chk() { if eval "$2"; then echo "$1"; pass=$((pass + 1)); else echo "$1"; fail=$((fail + 1)); fi; }
@@ -180,41 +181,86 @@ chk "[control] without -E the mid-sync corruption survives (no rollback)" \
# ── Part C: an INT/TERM interrupt must terminate, not resume (blocker-A) ──────
# A bash signal trap that merely returns lets the script continue past the
# interrupt — restoring the snapshot, then resuming the sync and reporting
# success. We inject a SIGTERM mid-sync with a cp that SUCCEEDS (so set -e never
# fires and ONLY the signal path governs), and assert the shipped installer
# restores AND exits without reporting success. The control strips `exit 1` from
# the trap and shows the buggy resume-to-success.
make_term_shim() {
local dir="$1"
cat > "$dir/cp" <<SHIM
#!/usr/bin/env bash
dest="\${@: -1}"
case "\$dest" in
*/$POISON_REL)
kill -TERM "\$PPID" 2>/dev/null # signal install.sh; the copy still succeeds
exec env PATH="$ORIG_PATH" cp "\$@" ;;
esac
exec env PATH="$ORIG_PATH" cp "\$@"
SHIM
chmod +x "$dir/cp"
# success. The earlier test used a child cp shim to signal its parent, making
# child completion race Bash's interrupted wait. Concurrency is not part of the
# guarded property: sync_framework_keep() runs in the installer's own Bash
# process, and `kill` is a builtin. Generate two installer fixtures that signal
# themselves at the same known mid-sync point. Their TERM handlers emit the same
# observable before diverging, so missing signal delivery fails BOTH arms rather
# than manufacturing a pass. The only semantic difference between fixtures is
# the explicit `exit 1` whose load-bearing behavior this control proves.
TERM_MARKER='[test-control] TERM handler entered'
HANDLER_WITH_EXIT="trap 'echo \"$TERM_MARKER\" >&2; restore_snapshot; exit 1' TERM # TEST-TERM-HANDLER"
HANDLER_WITHOUT_EXIT="trap 'echo \"$TERM_MARKER\" >&2; restore_snapshot' TERM # TEST-TERM-HANDLER"
make_signal_installer() {
local output="$1" handler="$2"
local target_trap="trap 'restore_snapshot; exit 1' ERR INT TERM"
local target_cp=' cp "$abs" "$dst/$rel"'
local inject_open=" if [[ \"\$rel\" == \"$POISON_REL\" ]]; then"
local inject_kill=' kill -TERM "$$" # TEST-TERM-INJECTION'
local inject_close=' fi'
if ! awk \
-v target_trap="$target_trap" -v target_cp="$target_cp" \
-v handler="$handler" -v inject_open="$inject_open" \
-v inject_kill="$inject_kill" -v inject_close="$inject_close" '
$0 == target_cp {
print inject_open
print inject_kill
print inject_close
injection_sites++
}
{ print }
$0 == target_trap {
print handler
handler_sites++
}
END {
if (handler_sites != 1 || injection_sites != 1) exit 42
}
' "$INSTALL" > "$output"; then
rm -f "$output"
fail "Could not construct the self-TERM control installer at the exact trap/copy sites"
exit 1
fi
chmod +x "$output"
}
# Run one keep-mode upgrade with the SIGTERM shim. Echoes "<exit>\t<out>\t<home>".
make_signal_installer "$SIGNALED" "$HANDLER_WITH_EXIT"
make_signal_installer "$NOEXIT" "$HANDLER_WITHOUT_EXIT"
signal_fixture_ready() {
local fixture="$1" expected_handler="$2"
[[ "$(grep -cF '# TEST-TERM-INJECTION' "$fixture")" -eq 1 ]] \
&& [[ "$(grep -cF '# TEST-TERM-HANDLER' "$fixture")" -eq 1 ]] \
&& grep -Fqx "$expected_handler" "$fixture"
}
signaled_fixture_ready() { signal_fixture_ready "$SIGNALED" "$HANDLER_WITH_EXIT"; }
noexit_fixture_ready() { signal_fixture_ready "$NOEXIT" "$HANDLER_WITHOUT_EXIT"; }
chk "[signal] shipped fixture has exactly one self-TERM injection and marked handler" \
"signaled_fixture_ready"
chk "[control] no-exit fixture has exactly one self-TERM injection and marked handler" \
"noexit_fixture_ready"
chk "[control] removing the explicit TERM exit changes the fixture" \
"! cmp -s '$SIGNALED' '$NOEXIT'"
# Run one keep-mode upgrade whose own shell delivers SIGTERM synchronously at
# the selected copy. Echoes "<exit>\t<out>\t<home>".
run_signal_upgrade() {
local installer="$1" H OUT SHIM rc
H=$(mktemp -d); OUT=$(mktemp); SHIM=$(mktemp -d)
local installer="$1" H OUT rc
H=$(mktemp -d); OUT=$(mktemp)
seed_home "$H"
make_term_shim "$SHIM"
set +e
PATH="$SHIM:$ORIG_PATH" \
PATH="$ORIG_PATH" \
MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash "$installer" >"$OUT" 2>&1
rc=$?
set -e 2>/dev/null || true
rm -rf "$SHIM"
printf '%s\t%s\t%s\n' "$rc" "$OUT" "$H"
}
IFS=$'\t' read -r rcC OUTC HC < <(run_signal_upgrade "$INSTALL")
IFS=$'\t' read -r rcC OUTC HC < <(run_signal_upgrade "$SIGNALED")
chk "[signal] TERM handler observable fires exactly once" \
"[ \"\$(grep -cF '$TERM_MARKER' '$OUTC')\" -eq 1 ]"
chk "[signal] SIGTERM mid-sync aborts non-zero (trap exits, does not resume)" \
"[ '$rcC' -ne 0 ]"
chk "[signal] restore_snapshot fires on the interrupt" \
@@ -222,13 +268,13 @@ chk "[signal] restore_snapshot fires on the interrupt" \
chk "[signal] does NOT resume to report sync success after the interrupt" \
"! grep -q 'file phase complete' '$OUTC'"
# Control: strip `exit 1` from the signal trap → the handler returns, the script
# resumes past the interrupt and wrongly reports success. In $FW so SOURCE_DIR resolves.
sed "s/trap 'restore_snapshot; exit 1' ERR INT TERM/trap 'restore_snapshot' ERR INT TERM/" \
"$INSTALL" > "$NOEXIT"
chk "[control] the exit-strip actually changed the installer" \
"! cmp -s '$INSTALL' '$NOEXIT'"
IFS=$'\t' read -r _rcD OUTD HD < <(run_signal_upgrade "$NOEXIT")
IFS=$'\t' read -r rcD OUTD HD < <(run_signal_upgrade "$NOEXIT")
chk "[control] TERM handler observable fires exactly once" \
"[ \"\$(grep -cF '$TERM_MARKER' '$OUTD')\" -eq 1 ]"
chk "[control] without 'exit 1' the handler restores before returning" \
"grep -q 'restoring previous state from snapshot' '$OUTD'"
chk "[control] without 'exit 1' the installer exits zero after resuming" \
"[ '$rcD' -eq 0 ]"
chk "[control] without 'exit 1' the trap resumes and reports sync success (the bug)" \
"grep -q 'file phase complete' '$OUTD'"
@@ -309,10 +355,10 @@ chk "[control] without the D2 recovery line the operator gets no snapshot pointe
# Reap any snapshot the reset-fail runs left in /tmp (reset failed → never cleaned).
grep -o '/[^ ]*mosaic-snapshot[^ ]*' "$OUTH" 2>/dev/null | head -1 | while read -r s; do rm -rf "$s"; done
# Cleanup ($STRIPPED / $NOEXIT / $D1CTRL / $D2CTRL are also removed by the EXIT trap).
# Cleanup (generated installer controls are also removed by the EXIT trap).
for d in "$HA" "$REFA" "$HB" "$REFB" "$HC" "$HD" "$HE" "$REFE" "$HF" "$REFF" "$HG" "$HH"; do rm -rf "$d"; done
rm -f "$OUTA" "$OUTB" "$OUTC" "$OUTD" "$OUTE" "$OUTF" "$OUTG" "$OUTH" \
"$STRIPPED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
"$STRIPPED" "$SIGNALED" "$NOEXIT" "$D1CTRL" "$D2CTRL"
echo
echo "RESULT: $pass passed, $fail failed"
+377 -1
View File
@@ -7,8 +7,11 @@ import {
readdir,
rename,
rm,
symlink,
unlink,
writeFile,
} from 'node:fs/promises';
import { writeSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
@@ -19,7 +22,9 @@ import {
} from '../credentials/audit-journal.js';
import { parseCredentialEstateRegistry } from '../credentials/estate-registry.js';
import { FileCredentialStore } from '../credentials/file-credential-store.js';
import { executeCredentialRotate, executeCredentialWire } from './cred.js';
import { credentialLifecycleLocksDirectory } from '../credentials/lifecycle.js';
import { TeaLoginStore } from '../credentials/tea-login-store.js';
import { executeCredentialGet, executeCredentialRotate, executeCredentialWire } from './cred.js';
let cleanup: string | undefined;
afterEach(async (): Promise<void> => {
@@ -109,6 +114,377 @@ describe('credential lifecycle command controls', (): void => {
);
});
it.each(['partialWrite', 'shortWrite', 'recordMutation', 'seal'] as const)(
'handles %s without overstating credential disclosure',
async (method): Promise<void> => {
const paths = await fixture();
const registry = parseCredentialEstateRegistry(await readFile(paths.registryPath, 'utf8'));
await mkdir(join(paths.mosaicHome, 'secrets'), { mode: 0o700 });
const store = new FileCredentialStore(paths.tokenDirectory, registry);
await store.put(
{
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
providerLogin: 'seat-name',
tokenName: 'active-generation',
scopes: ['write:repository'],
createdAt: '2026-08-05T00:00:00.000Z',
},
new TextEncoder().encode('seat-token-canary'),
);
const authorityPath = join(cleanup!, 'authority.json');
const outputPath = join(cleanup!, 'credential.out');
await writeFile(
authorityPath,
JSON.stringify({
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
secret: 'seat-token-canary',
}),
{ mode: 0o600 },
);
vi.stubGlobal(
'fetch',
async (): Promise<Response> =>
new Response(JSON.stringify({ id: 7, login: 'seat-name' }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
if (method === 'recordMutation') {
const original = CredentialAuditJournal.prototype.recordMutation;
vi.spyOn(CredentialAuditJournal.prototype, 'recordMutation').mockImplementation(
async function (this: CredentialAuditJournal, decision): Promise<void> {
if (decision === 'credential-issued') {
throw new CredentialJournalError('journal-unavailable', 'injected append failure');
}
await original.call(this, decision);
},
);
} else if (method === 'seal') {
const original = CredentialAuditJournal.prototype.seal;
vi.spyOn(CredentialAuditJournal.prototype, 'seal').mockImplementation(async function (
this: CredentialAuditJournal,
outcome,
reason,
): Promise<string> {
if (outcome === 'ok') {
throw new CredentialJournalError('journal-unavailable', 'injected seal failure');
}
return original.call(this, outcome, reason);
});
}
let writes = 0;
const authority = await open(authorityPath, 'r');
const output = await open(outputPath, 'w+', 0o600);
try {
const result = await executeCredentialGet('seat-name', {
estate: 'homelab',
host: 'git.example.invalid',
actor: 'seat-name',
authorityFd: authority.fd.toString(),
outputFd: output.fd.toString(),
mosaicHome: paths.mosaicHome,
registry: paths.registryPath,
tokenDir: paths.tokenDirectory,
stateDir: paths.stateRoot,
credentialWrite:
method === 'partialWrite'
? (fd, data): number => {
writes += 1;
if (writes === 2) throw new Error('injected partial write failure');
return writeSync(fd, data);
}
: method === 'shortWrite'
? (fd, data): number =>
writeSync(fd, data.subarray(0, Math.max(1, Math.floor(data.byteLength / 2))))
: undefined,
});
if (method === 'shortWrite') {
expect(result).toMatchObject({
outcome: 'ok',
mutation: 'none',
reason: { code: 'get-verified' },
});
} else {
expect(result).toMatchObject({
outcome: 'indeterminate',
mutation: method === 'partialWrite' ? 'unknown' : 'applied',
reason: { code: 'credential-issuance-indeterminate' },
});
}
const emitted = await readFile(outputPath, 'utf8');
if (method === 'partialWrite') {
expect(emitted).toContain('username=seat-name');
expect(emitted).not.toContain('seat-token-canary');
} else {
expect(emitted).toContain('password=seat-token-canary');
}
} finally {
await output.close();
await authority.close();
}
},
);
it.each(['absent', 'divergent'] as const)(
'restores an independently %s Tea pre-state after rotation journal failure',
async (teaState): Promise<void> => {
const paths = await fixture();
const registry = parseCredentialEstateRegistry(await readFile(paths.registryPath, 'utf8'));
await mkdir(join(paths.mosaicHome, 'secrets'), { mode: 0o700 });
const store = new FileCredentialStore(paths.tokenDirectory, registry);
await store.put(
{
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
providerLogin: 'seat-name',
tokenName: 'old-generation',
scopes: ['write:repository'],
createdAt: '2026-08-05T00:00:00.000Z',
},
new TextEncoder().encode('old-token'),
);
const teaConfig = join(cleanup!, 'tea', 'config.yml');
const teaStore = new TeaLoginStore(teaConfig);
if (teaState === 'divergent') {
await teaStore.put(
'seat-name',
'git.example.invalid',
new TextEncoder().encode('divergent-tea-token'),
);
}
const authorityPath = join(cleanup!, 'authority.json');
await writeFile(
authorityPath,
JSON.stringify({
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
secret: 'authority-canary',
}),
{ mode: 0o600 },
);
let replacementRevoked = false;
vi.stubGlobal('fetch', async (input: string | URL | Request, init?: RequestInit) => {
const url = new URL(typeof input === 'string' || input instanceof URL ? input : input.url);
const method = init?.method ?? 'GET';
if (url.pathname === '/api/v1/user') {
return new Response(JSON.stringify({ id: 7, login: 'seat-name' }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.pathname.endsWith('/tokens') && method === 'POST') {
return new Response(
JSON.stringify({
name: 'new-generation',
sha1: 'replacement-token',
scopes: ['write:repository'],
}),
{ status: 201, headers: { 'content-type': 'application/json' } },
);
}
if (url.pathname.endsWith('/tokens') && method === 'GET') {
return new Response(
JSON.stringify(
replacementRevoked ? [] : [{ name: 'new-generation', scopes: ['write:repository'] }],
),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (url.pathname.endsWith('/tokens/new-generation') && method === 'DELETE') {
replacementRevoked = true;
return new Response(null, { status: 204 });
}
throw new Error(`unexpected provider request: ${method} ${url.pathname}`);
});
let mintRecords = 0;
const recordMutation = CredentialAuditJournal.prototype.recordMutation;
vi.spyOn(CredentialAuditJournal.prototype, 'recordMutation').mockImplementation(
async function (this: CredentialAuditJournal, decision): Promise<void> {
if (decision === 'token-mint-applied') {
mintRecords += 1;
if (mintRecords === 2) {
throw new CredentialJournalError('journal-unavailable', 'injected rotation failure');
}
}
await recordMutation.call(this, decision);
},
);
const authority = await open(authorityPath, 'r');
try {
const result = await executeCredentialRotate('seat-name', {
estate: 'homelab',
host: 'git.example.invalid',
actor: 'seat-name',
authorityFd: authority.fd.toString(),
tokenName: 'new-generation',
mosaicHome: paths.mosaicHome,
registry: paths.registryPath,
tokenDir: paths.tokenDirectory,
stateDir: paths.stateRoot,
teaConfig,
});
expect(result).toMatchObject({ outcome: 'error', mutation: 'none' });
if (teaState === 'absent') {
expect(teaStore.snapshot('seat-name', 'git.example.invalid')).toBeUndefined();
} else {
expect(
teaStore.matchesSecret(
'seat-name',
'git.example.invalid',
new TextEncoder().encode('divergent-tea-token'),
),
).toBe(true);
}
} finally {
await authority.close();
}
},
);
it('preserves an open nested provision journal in the rotate result', async (): Promise<void> => {
const paths = await fixture();
const registry = parseCredentialEstateRegistry(await readFile(paths.registryPath, 'utf8'));
await mkdir(paths.tokenDirectory, { recursive: true, mode: 0o700 });
const store = new FileCredentialStore(paths.tokenDirectory, registry);
await store.put(
{
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
providerLogin: 'seat-name',
tokenName: 'old-generation',
scopes: ['write:repository'],
createdAt: '2026-08-05T00:00:00.000Z',
},
new TextEncoder().encode('old-token'),
);
const authorityPath = join(cleanup!, 'authority.json');
await writeFile(
authorityPath,
JSON.stringify({
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
secret: 'authority-canary',
}),
{ mode: 0o600 },
);
const snapshot = TeaLoginStore.prototype.snapshot;
let snapshotCalls = 0;
vi.spyOn(TeaLoginStore.prototype, 'snapshot').mockImplementation(function (
this: TeaLoginStore,
identity,
host,
) {
snapshotCalls += 1;
if (snapshotCalls === 2) throw new Error('injected nested snapshot failure');
return snapshot.call(this, identity, host);
});
const seal = CredentialAuditJournal.prototype.seal;
vi.spyOn(CredentialAuditJournal.prototype, 'seal').mockImplementation(async function (
this: CredentialAuditJournal,
outcome,
reasonCode,
): Promise<string> {
if (reasonCode === 'credential-snapshot-unavailable') {
throw new CredentialJournalError('journal-unavailable', 'injected nested seal failure');
}
return seal.call(this, outcome, reasonCode);
});
const authority = await open(authorityPath, 'r');
try {
const result = await executeCredentialRotate('seat-name', {
estate: 'homelab',
host: 'git.example.invalid',
actor: 'seat-name',
authorityFd: authority.fd.toString(),
tokenName: 'new-generation',
mosaicHome: paths.mosaicHome,
registry: paths.registryPath,
tokenDir: paths.tokenDirectory,
stateDir: paths.stateRoot,
teaConfig: join(cleanup!, 'tea', 'config.yml'),
});
expect(result).toMatchObject({
operation: 'rotate',
outcome: 'error',
mutation: 'none',
audit: { state: 'open' },
});
} finally {
await authority.close();
}
});
it('preserves the journal failure diagnosis when rotate lock failure cannot be sealed', async (): Promise<void> => {
const paths = await fixture();
const registry = parseCredentialEstateRegistry(await readFile(paths.registryPath, 'utf8'));
await mkdir(paths.tokenDirectory, { recursive: true, mode: 0o700 });
const store = new FileCredentialStore(paths.tokenDirectory, registry);
await store.put(
{
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
providerLogin: 'seat-name',
tokenName: 'old-generation',
scopes: ['write:repository'],
createdAt: '2026-08-05T00:00:00.000Z',
},
new TextEncoder().encode('old-token'),
);
const authorityPath = join(cleanup!, 'authority.json');
await writeFile(
authorityPath,
JSON.stringify({
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
secret: 'authority-canary',
}),
{ mode: 0o600 },
);
const locksDirectory = credentialLifecycleLocksDirectory();
await mkdir(locksDirectory, { recursive: true, mode: 0o700 });
const lockPath = join(locksDirectory, 'homelab--git.example.invalid--seat-name.lock');
await unlink(lockPath).catch((): void => undefined);
await symlink('/dev/null', lockPath);
vi.spyOn(CredentialAuditJournal.prototype, 'seal').mockRejectedValue(
new CredentialJournalError('journal-recovery-required', 'injected final seal failure'),
);
const authority = await open(authorityPath, 'r');
try {
const result = await executeCredentialRotate('seat-name', {
estate: 'homelab',
host: 'git.example.invalid',
actor: 'seat-name',
authorityFd: authority.fd.toString(),
tokenName: 'new-generation',
mosaicHome: paths.mosaicHome,
registry: paths.registryPath,
tokenDir: paths.tokenDirectory,
stateDir: paths.stateRoot,
teaConfig: join(cleanup!, 'tea', 'config.yml'),
});
expect(result).toMatchObject({
operation: 'rotate',
outcome: 'error',
mutation: 'none',
reason: { code: 'journal-recovery-required' },
audit: { state: 'open' },
});
} finally {
await authority.close();
await unlink(lockPath).catch((): void => undefined);
}
});
it('refuses an unauthenticated actor before rewriting another seat environment', async (): Promise<void> => {
const paths = await fixture();
const agents = join(paths.mosaicHome, 'fleet', 'agents');
+130 -15
View File
@@ -27,7 +27,13 @@ import {
import type { CredentialGrantResultDto } from '../credentials/grant.dto.js';
import { grantTeamRepositoryPermission } from '../credentials/team-grant.js';
import type { TeamGrantResult } from '../credentials/team-grant.js';
import { provisionCredential, revokeCredential } from '../credentials/lifecycle.js';
import {
acquireCredentialLifecycleLock,
CredentialLifecycleLockError,
type CredentialLifecycleLock,
provisionCredential,
revokeCredential,
} from '../credentials/lifecycle.js';
import type { CredentialLifecycleResultDto } from '../credentials/lifecycle.dto.js';
import { TeaLoginStore } from '../credentials/tea-login-store.js';
import {
@@ -94,6 +100,7 @@ interface CredentialLifecycleCommandOptions {
readonly json?: boolean;
readonly wireBeforeRename?: () => Promise<void>;
readonly wireDirectorySync?: (path: string) => Promise<void>;
readonly credentialWrite?: (fd: number, data: Uint8Array) => number;
}
function defaultMosaicHome(options: { readonly mosaicHome?: string }): string {
@@ -499,7 +506,11 @@ export async function executeCredentialRotate(
): Promise<CredentialLifecycleResultDto> {
let journal: CredentialAuditJournal | undefined;
let old: Awaited<ReturnType<FileCredentialStore['snapshot']>> = undefined;
let oldTea: ReturnType<TeaLoginStore['snapshot']> = undefined;
let replacement: Awaited<ReturnType<FileCredentialStore['snapshot']>> = undefined;
let replacementTea: ReturnType<TeaLoginStore['snapshot']> = undefined;
let authority: Awaited<ReturnType<typeof readDelegatedCredentialFromFd>> | undefined;
let lifecycleLock: CredentialLifecycleLock | undefined;
let mutation: CredentialLifecycleResultDto['mutation'] = 'none';
try {
if (options.authorityFd === undefined || options.tokenName === undefined) {
@@ -519,6 +530,32 @@ export async function executeCredentialRotate(
repo: null,
});
await journal.recordIntent('rotate-requested');
try {
lifecycleLock = await acquireCredentialLifecycleLock(identity, options.estate, options.host);
} catch (error: unknown) {
const code =
error instanceof CredentialLifecycleLockError ? error.code : 'mutation-lock-unavailable';
try {
await journal.seal(code === 'concurrent-mutation' ? 'refused' : 'error', code);
return localLifecycleResult('rotate', identity, options, {
outcome: code === 'concurrent-mutation' ? 'refused' : 'error',
mutation: 'none',
code,
message: 'Credential lifecycle mutation lock could not be acquired.',
audit: { journalId: journal.journalId(), state: 'sealed' },
});
} catch (sealError: unknown) {
await journal.closeIncomplete().catch((): void => undefined);
return localLifecycleResult('rotate', identity, options, {
outcome: 'error',
mutation: 'none',
code:
sealError instanceof CredentialJournalError ? sealError.code : 'journal-unavailable',
message: 'Mutation lock failure could not be sealed durably.',
audit: { journalId: journal.journalId(), state: 'open' },
});
}
}
old = await context.store.snapshot(identity, options.estate, options.host);
if (old === undefined) {
await journal.seal('refused', 'no-token-for-identity');
@@ -529,6 +566,7 @@ export async function executeCredentialRotate(
audit: { journalId: journal.journalId(), state: 'sealed' },
});
}
oldTea = context.teaStore.snapshot(identity, options.host);
if (options.tokenName === old.binding.tokenName) {
await journal.seal('refused', 'replacement-token-name-conflict');
return localLifecycleResult('rotate', identity, options, {
@@ -558,16 +596,24 @@ export async function executeCredentialRotate(
allowReplace: true,
journal,
deferSuccessSeal: true,
lifecycleLock,
expectedStoreGeneration: old.generation,
expectedTeaGeneration: oldTea?.generation ?? null,
},
);
if (provisioned.outcome !== 'ok') {
return {
...provisioned,
operation: 'rotate',
audit: { journalId: journal.journalId(), state: 'sealed' },
};
return { ...provisioned, operation: 'rotate' };
}
mutation = 'applied';
replacement = await context.store.snapshot(identity, options.estate, options.host);
replacementTea = context.teaStore.snapshot(identity, options.host);
if (
replacement === undefined ||
replacement.binding.tokenName !== options.tokenName ||
replacementTea === undefined
) {
throw new Error('replacement generation could not be snapshotted exactly');
}
try {
await journal.recordMutation('token-mint-applied');
} catch (error: unknown) {
@@ -575,8 +621,22 @@ export async function executeCredentialRotate(
if (await context.provider.tokenExists(authority, identity, options.tokenName)) {
throw new Error('replacement rollback after journal failure could not be verified');
}
await context.store.put(old.binding, old.secret);
await context.teaStore.put(identity, options.host, old.secret);
await context.store.put(old.binding, old.secret, replacement.generation);
await context.teaStore.restore(identity, options.host, oldTea, replacementTea.generation);
const restored = await context.store.snapshot(identity, options.estate, options.host);
try {
if (
restored === undefined ||
JSON.stringify(restored.binding) !== JSON.stringify(old.binding) ||
restored.secret.byteLength !== old.secret.byteLength ||
!timingSafeEqual(Buffer.from(restored.secret), Buffer.from(old.secret)) ||
!context.teaStore.matchesSnapshot(identity, options.host, oldTea)
) {
throw new Error('rotation pre-operation state was not restored exactly');
}
} finally {
restored?.secret.fill(0);
}
mutation = 'none';
throw error;
}
@@ -630,6 +690,10 @@ export async function executeCredentialRotate(
} finally {
authority?.secret.fill(0);
old?.secret.fill(0);
oldTea?.secret.fill(0);
replacement?.secret.fill(0);
replacementTea?.secret.fill(0);
await lifecycleLock?.release();
}
}
@@ -861,6 +925,8 @@ export async function executeCredentialGet(
): Promise<CredentialLifecycleResultDto> {
const locations = lifecycleLocations(options);
let journal: CredentialAuditJournal | undefined;
let disclosureStarted = false;
let disclosureCompleted = false;
try {
journal = await CredentialAuditJournal.open(locations.stateRoot, {
operation: 'get',
@@ -924,9 +990,26 @@ export async function executeCredentialGet(
`protocol=https\nhost=${options.host}\nusername=${identity}\npassword=`,
);
await journal.recordMutation('credential-issuance-authorized');
writeSync(fd, prefix);
writeSync(fd, resolved.secret);
writeSync(fd, new TextEncoder().encode('\n\n'));
await journal.recordMutation('credential-issuance-started');
disclosureStarted = true;
const writeCredential =
options.credentialWrite ??
((targetFd: number, data: Uint8Array): number => writeSync(targetFd, data));
const writeAll = (data: Uint8Array): void => {
let offset = 0;
while (offset < data.byteLength) {
const remaining = data.subarray(offset);
const written = writeCredential(fd, remaining);
if (!Number.isSafeInteger(written) || written <= 0 || written > remaining.byteLength) {
throw new Error('credential output made invalid write progress');
}
offset += written;
}
};
writeAll(prefix);
writeAll(resolved.secret);
writeAll(new TextEncoder().encode('\n\n'));
disclosureCompleted = true;
await journal.recordMutation('credential-issued');
await journal.seal('ok', 'get-verified');
return localLifecycleResult('get', identity, options, {
@@ -943,19 +1026,51 @@ export async function executeCredentialGet(
message: 'Credential access journal could not be opened durably.',
});
}
if (disclosureStarted) {
try {
await journal.recordMutation(
disclosureCompleted ? 'credential-issued' : 'credential-issuance-possibly-issued',
);
await journal.seal('indeterminate', 'credential-issuance-indeterminate');
return localLifecycleResult('get', identity, options, {
outcome: 'indeterminate',
mutation: disclosureCompleted ? 'applied' : 'unknown',
code: 'credential-issuance-indeterminate',
message: disclosureCompleted
? 'Credential disclosure completed, but its final audit transition failed.'
: 'Credential disclosure began but could not be proven complete.',
audit: { journalId: journal.journalId(), state: 'sealed' },
});
} catch {
await journal.closeIncomplete().catch((): void => undefined);
return localLifecycleResult('get', identity, options, {
outcome: 'indeterminate',
mutation: disclosureCompleted ? 'applied' : 'unknown',
code: 'credential-issuance-indeterminate',
message: 'Credential disclosure may have occurred; inspect the durable open journal.',
audit: { journalId: journal.journalId(), state: 'open' },
});
}
}
const code =
error instanceof CredentialJournalError ? error.code : 'insecure-credential-destination';
try {
await journal.seal('error', 'insecure-credential-destination');
await journal.seal('error', code);
return localLifecycleResult('get', identity, options, {
outcome: 'error',
code: 'insecure-credential-destination',
message: 'Protected credential output fd was unavailable or unsafe.',
code,
message:
error instanceof CredentialJournalError
? 'Credential issuance audit failed before disclosure.'
: 'Protected credential output fd was unavailable or unsafe.',
audit: { journalId: journal.journalId(), state: 'sealed' },
});
} catch (sealError: unknown) {
await journal.closeIncomplete().catch((): void => undefined);
return localLifecycleResult('get', identity, options, {
outcome: 'error',
code: sealError instanceof CredentialJournalError ? sealError.code : 'journal-unavailable',
message: 'Credential access audit could not be sealed.',
message: 'Credential access audit could not be sealed before disclosure.',
audit: { journalId: journal.journalId(), state: 'open' },
});
}
@@ -48,6 +48,8 @@ export interface CredentialPopulationCorrectionDto {
export interface CredentialJournalRuntimeOptionsDto {
readonly id?: string;
readonly now?: () => string;
readonly syncDirectory?: (path: string) => Promise<void>;
readonly rename?: (source: string, destination: string) => Promise<void>;
}
export interface CredentialJournalSummaryDto {
@@ -1,4 +1,15 @@
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import {
chmod,
mkdir,
mkdtemp,
open,
readFile,
rename,
rm,
symlink,
truncate,
writeFile,
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
@@ -49,6 +60,164 @@ describe('credential durable audit journal', (): void => {
expect(records[3]).toContain('"phase":"sealed"');
});
it('keeps a final seal non-accepting while directory durability is pending', async (): Promise<void> => {
const root = await stateRoot();
let directorySyncs = 0;
let signalFinalSync: (() => void) | undefined;
let releaseFinalSync: (() => void) | undefined;
const finalSyncEntered = new Promise<void>((resolve): void => {
signalFinalSync = resolve;
});
const finalSyncRelease = new Promise<void>((resolve): void => {
releaseFinalSync = resolve;
});
const journal = await CredentialAuditJournal.open(
root,
{
operation: 'get',
actor: 'seat-name',
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
repo: null,
},
{
id: 'seal-pending',
syncDirectory: async (path): Promise<void> => {
directorySyncs += 1;
if (directorySyncs === 3) {
signalFinalSync?.();
await finalSyncRelease;
}
const handle = await open(path, 'r');
try {
await handle.sync();
} finally {
await handle.close();
}
},
},
);
const sealing = journal.seal('ok', 'get-verified');
await finalSyncEntered;
await expect(listCredentialJournals(root)).resolves.toContainEqual(
expect.objectContaining({ id: 'seal-pending', state: 'open' }),
);
releaseFinalSync?.();
await sealing;
await expect(listCredentialJournals(root)).resolves.toContainEqual(
expect.objectContaining({ id: 'seal-pending', state: 'sealed' }),
);
});
it('reverts a failed final seal commit to visible open state', async (): Promise<void> => {
const root = await stateRoot();
let directorySyncs = 0;
const journal = await CredentialAuditJournal.open(
root,
{
operation: 'get',
actor: 'seat-name',
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
repo: null,
},
{
id: 'seal-fault',
syncDirectory: async (path): Promise<void> => {
directorySyncs += 1;
if (directorySyncs === 3) throw new Error('injected final directory sync failure');
const handle = await open(path, 'r');
try {
await handle.sync();
} finally {
await handle.close();
}
},
},
);
await expect(journal.seal('ok', 'get-verified')).rejects.toThrow(/journal-unavailable/);
const [entry] = await listCredentialJournals(root);
expect(entry).toMatchObject({ id: 'seal-fault', state: 'open' });
expect(await readFile(entry?.path ?? '', 'utf8')).not.toContain('"phase":"sealed"');
});
it('uses a non-accepting recovery path when the compensating rename fails', async (): Promise<void> => {
const root = await stateRoot();
let directorySyncs = 0;
let renames = 0;
const journal = await CredentialAuditJournal.open(
root,
{
operation: 'get',
actor: 'seat-name',
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
repo: null,
},
{
id: 'seal-recovery-fault',
syncDirectory: async (path): Promise<void> => {
directorySyncs += 1;
if (directorySyncs === 3) throw new Error('injected final directory sync failure');
const handle = await open(path, 'r');
try {
await handle.sync();
} finally {
await handle.close();
}
},
rename: async (source, destination): Promise<void> => {
renames += 1;
if (renames === 3) throw new Error('injected compensating rename failure');
await rename(source, destination);
},
},
);
await expect(journal.seal('ok', 'get-verified')).rejects.toThrow(/journal-recovery-required/);
const [entry] = await listCredentialJournals(root);
expect(entry).toMatchObject({ id: 'seal-recovery-fault', state: 'open' });
expect(entry?.path).toMatch(/\.recovery\.jsonl$/);
expect(await readFile(entry?.path ?? '', 'utf8')).not.toContain('"phase":"sealed"');
});
it.each(['symlink', 'oversized'] as const)(
'classifies an unsafe %s sealed-looking journal as open without consuming it',
async (kind): Promise<void> => {
const root = await stateRoot();
const journal = await CredentialAuditJournal.open(root, {
operation: 'audit',
actor: 'seat-name',
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
repo: null,
});
await journal.closeIncomplete();
const journalsDirectory = join(root, 'journals');
const candidate = join(journalsDirectory, `unsafe-${kind}.sealed.jsonl`);
if (kind === 'symlink') {
if (cleanup === undefined) throw new Error('test fixture root is unavailable');
const outside = join(cleanup, 'outside-journal');
await writeFile(outside, '{"phase":"sealed","outcome":"ok"}\n', { mode: 0o600 });
await symlink(outside, candidate);
} else {
await writeFile(candidate, '', { mode: 0o600 });
await truncate(candidate, 4 * 1024 * 1024 + 1);
}
const entry = (await listCredentialJournals(root)).find(
(value): boolean => value.id === `unsafe-${kind}`,
);
expect(entry).toMatchObject({ state: 'open' });
},
);
it('leaves an unsealed journal visible for recovery', async (): Promise<void> => {
const root = await stateRoot();
const journal = await CredentialAuditJournal.open(root, {
+148 -21
View File
@@ -1,9 +1,10 @@
import { spawnSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { lstatSync } from 'node:fs';
import { constants, lstatSync } from 'node:fs';
import { open, readdir, rename } from 'node:fs/promises';
import type { FileHandle } from 'node:fs/promises';
import { join } from 'node:path';
import { ensureManagedDirectory } from '../fleet/secure-file.js';
import { ensureManagedDirectory, readRegularFileSecure } from '../fleet/secure-file.js';
import type {
CredentialJournalContextDto,
CredentialJournalCorrectionDto,
@@ -19,6 +20,7 @@ const SAFE_HOST = /^[a-z0-9][a-z0-9.-]*$/;
const SAFE_REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
const SAFE_ENDPOINT = /^(?:GET|PUT|POST|DELETE) \/[A-Za-z0-9_./{}:-]+$/;
const SAFE_CONTENT_TYPE = /^[A-Za-z0-9!#$&^_.+/-]+(?:;[A-Za-z0-9=._+-]+)*$/;
const MAX_JOURNAL_BYTES = 4 * 1024 * 1024;
const SAFE_DECISIONS = new Set<string>([
'provider-grant',
'permission-none',
@@ -61,6 +63,8 @@ const SAFE_DECISIONS = new Set<string>([
'token-revoke-applied',
'wire-applied',
'credential-issuance-authorized',
'credential-issuance-started',
'credential-issuance-possibly-issued',
'credential-issued',
'classification-correction',
]);
@@ -127,15 +131,43 @@ async function syncDirectory(path: string): Promise<void> {
}
}
async function acquireJournalLock(
path: string,
mode: 'exclusive' | 'shared',
): Promise<FileHandle | undefined> {
let handle: FileHandle | undefined;
try {
handle = await open(path, constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW, 0o600);
const stat = await handle.stat();
if (!stat.isFile() || stat.uid !== process.getuid?.() || (stat.mode & 0o077) !== 0) {
throw new Error('journal lock file is unsafe');
}
const acquired = spawnSync('/usr/bin/flock', ['-n', mode === 'exclusive' ? '-x' : '-s', '3'], {
stdio: ['ignore', 'ignore', 'ignore', handle.fd],
});
if (acquired.error !== undefined || acquired.status !== 0) {
await handle.close();
return undefined;
}
return handle;
} catch {
await handle?.close().catch((): void => undefined);
return undefined;
}
}
export class CredentialAuditJournal {
private closed = false;
private constructor(
private readonly handle: FileHandle,
private readonly lockHandle: FileHandle,
private readonly openPath: string,
private readonly journalsDirectory: string,
private readonly id: string,
private readonly now: () => string,
private readonly syncJournalDirectory: (path: string) => Promise<void>,
private readonly renameJournal: (source: string, destination: string) => Promise<void>,
) {}
static async open(
@@ -150,19 +182,43 @@ export class CredentialAuditJournal {
}
const now = runtime.now ?? ((): string => new Date().toISOString());
const journalsDirectory = join(stateRoot, 'journals');
const locksDirectory = join(stateRoot, 'journal-locks');
let handle: FileHandle | undefined;
let lockHandle: FileHandle | undefined;
try {
ensureManagedDirectory(stateRoot, journalsDirectory);
ensureManagedDirectory(stateRoot, locksDirectory);
assertPrivateDirectory(stateRoot);
assertPrivateDirectory(journalsDirectory);
assertPrivateDirectory(locksDirectory);
const openPath = join(journalsDirectory, `${id}.open.jsonl`);
const lockPath = join(locksDirectory, `${id}.lock`);
handle = await open(openPath, 'wx', 0o600);
const journal = new CredentialAuditJournal(handle, openPath, journalsDirectory, id, now);
lockHandle = await acquireJournalLock(lockPath, 'exclusive');
if (lockHandle === undefined) {
throw new CredentialJournalError(
'journal-unavailable',
'journal lock could not be acquired',
);
}
const syncJournalDirectory = runtime.syncDirectory ?? syncDirectory;
const renameJournal = runtime.rename ?? rename;
const journal = new CredentialAuditJournal(
handle,
lockHandle,
openPath,
journalsDirectory,
id,
now,
syncJournalDirectory,
renameJournal,
);
await journal.append({ phase: 'opened', at: now(), context });
await syncDirectory(journalsDirectory);
await syncJournalDirectory(journalsDirectory);
return journal;
} catch (error: unknown) {
if (handle !== undefined) await handle.close().catch((): void => undefined);
if (lockHandle !== undefined) await lockHandle.close().catch((): void => undefined);
if (error instanceof CredentialJournalError) throw error;
throw new CredentialJournalError(
'journal-unavailable',
@@ -264,17 +320,55 @@ export class CredentialAuditJournal {
'reason code is outside the non-secret grammar',
);
}
await this.append({ phase: 'sealed', at: this.now(), outcome, reasonCode });
await this.handle.close();
this.closed = true;
const preSealSize = (await this.handle.stat()).size;
const sealingPath = join(this.journalsDirectory, `${this.id}.sealing.jsonl`);
const sealedPath = join(this.journalsDirectory, `${this.id}.sealed.jsonl`);
const recoveryPath = join(this.journalsDirectory, `${this.id}.recovery.jsonl`);
let currentPath = this.openPath;
try {
await rename(this.openPath, sealedPath);
await syncDirectory(this.journalsDirectory);
await this.renameJournal(currentPath, sealingPath);
currentPath = sealingPath;
await this.syncJournalDirectory(this.journalsDirectory);
await this.append({ phase: 'sealed', at: this.now(), outcome, reasonCode });
await this.renameJournal(currentPath, sealedPath);
currentPath = sealedPath;
await this.syncJournalDirectory(this.journalsDirectory);
await this.handle.close();
await this.lockHandle.close();
this.closed = true;
return sealedPath;
} catch {
let recovered = true;
try {
await this.handle.truncate(preSealSize);
await this.handle.sync();
} catch {
recovered = false;
}
if (currentPath !== this.openPath) {
try {
await this.renameJournal(currentPath, this.openPath);
currentPath = this.openPath;
} catch {
recovered = false;
try {
await this.renameJournal(currentPath, recoveryPath);
currentPath = recoveryPath;
} catch {
// The non-success return below remains authoritative; the path is reported by audit scan.
}
}
}
try {
await this.syncJournalDirectory(this.journalsDirectory);
} catch {
recovered = false;
}
await this.handle.close().catch((): void => undefined);
await this.lockHandle.close().catch((): void => undefined);
this.closed = true;
throw new CredentialJournalError(
'journal-unavailable',
recovered ? 'journal-unavailable' : 'journal-recovery-required',
'sealed journal could not be committed durably',
);
}
@@ -283,6 +377,7 @@ export class CredentialAuditJournal {
async closeIncomplete(): Promise<void> {
if (this.closed) return;
await this.handle.close();
await this.lockHandle.close();
this.closed = true;
}
}
@@ -291,24 +386,56 @@ export async function listCredentialJournals(
stateRoot: string,
): Promise<readonly CredentialJournalSummaryDto[]> {
const journalsDirectory = join(stateRoot, 'journals');
const locksDirectory = join(stateRoot, 'journal-locks');
let names: string[];
try {
assertPrivateDirectory(stateRoot);
assertPrivateDirectory(journalsDirectory);
ensureManagedDirectory(stateRoot, locksDirectory);
assertPrivateDirectory(locksDirectory);
names = await readdir(journalsDirectory);
} catch (error: unknown) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return [];
throw new CredentialJournalError('journal-unavailable', 'journal directory could not be read');
}
return names
.filter((name: string): boolean => /\.(?:open|sealed)\.jsonl$/.test(name))
.sort()
.map((name: string): CredentialJournalSummaryDto => {
const state = name.endsWith('.open.jsonl') ? 'open' : 'sealed';
return {
id: name.replace(/\.(?:open|sealed)\.jsonl$/, ''),
state,
path: join(journalsDirectory, name),
};
});
return Promise.all(
names
.filter((name: string): boolean => /\.(?:open|sealing|recovery|sealed)\.jsonl$/.test(name))
.sort()
.map(async (name: string): Promise<CredentialJournalSummaryDto> => {
const path = join(journalsDirectory, name);
const id = name.replace(/\.(?:open|sealing|recovery|sealed)\.jsonl$/, '');
let committedSeal = false;
let scanLock: FileHandle | undefined;
if (name.endsWith('.sealed.jsonl') && SAFE_NAME.test(id)) {
try {
scanLock = await acquireJournalLock(join(locksDirectory, `${id}.lock`), 'shared');
if (scanLock === undefined) throw new Error('journal seal is still in progress');
const snapshot = readRegularFileSecure(path, {
root: journalsDirectory,
maxBytes: MAX_JOURNAL_BYTES,
});
if (snapshot.uid !== process.getuid?.() || (snapshot.mode & 0o077) !== 0) {
throw new Error('journal owner or mode is unsafe');
}
const records = snapshot.content.toString('utf8').trim().split('\n');
const finalRecord: unknown = JSON.parse(records.at(-1) ?? 'null');
committedSeal =
typeof finalRecord === 'object' &&
finalRecord !== null &&
'phase' in finalRecord &&
finalRecord.phase === 'sealed';
} catch {
committedSeal = false;
} finally {
await scanLock?.close().catch((): void => undefined);
}
}
return {
id,
state: committedSeal ? 'sealed' : 'open',
path,
};
}),
);
}
@@ -148,6 +148,57 @@ describe('phase-1 governed file credential resolver', (): void => {
await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual([]);
});
it('changes generation for metadata-only rebinding and rejects stale replacement or removal', async (): Promise<void> => {
const root = await fixtureRoot();
const store = new FileCredentialStore(root, registry());
const secret = new TextEncoder().encode('same-private-token');
const originalBinding = {
identity: 'seat',
estate: 'homelab',
host: 'git.example.invalid',
providerLogin: 'seat',
tokenName: 'mosaic-seat-original',
scopes: ['write:repository'],
createdAt: '2026-08-05T00:00:00.000Z',
};
await store.put(originalBinding, secret);
const original = await store.snapshot('seat', 'homelab', 'git.example.invalid');
if (original === undefined) throw new Error('original generation was not stored');
try {
await store.put(
{
...originalBinding,
tokenName: 'mosaic-seat-rebound',
createdAt: '2026-08-05T00:00:01.000Z',
},
secret,
original.generation,
);
const rebound = await store.snapshot('seat', 'homelab', 'git.example.invalid');
try {
expect(rebound?.generation).not.toBe(original.generation);
await expect(
store.put(
{ ...originalBinding, tokenName: 'mosaic-seat-stale' },
secret,
original.generation,
),
).rejects.toThrow(/credential-generation-mismatch/);
await expect(
store.remove('seat', 'homelab', 'git.example.invalid', original.generation),
).rejects.toThrow(/credential-generation-mismatch/);
await expect(
store.readBinding('seat', 'homelab', 'git.example.invalid'),
).resolves.toMatchObject({ tokenName: 'mosaic-seat-rebound' });
} finally {
rebound?.secret.fill(0);
}
} finally {
original.secret.fill(0);
secret.fill(0);
}
});
it('returns undefined for an absent token without borrowing another identity', async (): Promise<void> => {
const root = await fixtureRoot();
await writeFile(join(root, 'gitea-example-shared.token'), 'shared-canary', { mode: 0o600 });
@@ -53,6 +53,25 @@ function isMissingFile(error: unknown): boolean {
);
}
export function credentialBindingGeneration(
metadata: CredentialBindingMetadataDto,
secret: Uint8Array,
): string {
const tokenDigest = createHash('sha256').update(secret).digest('hex');
const canonicalState = JSON.stringify({
schemaVersion: 1,
identity: metadata.identity,
estate: metadata.estate,
host: metadata.host,
providerLogin: metadata.providerLogin,
tokenName: metadata.tokenName,
scopes: [...metadata.scopes].sort(),
createdAt: metadata.createdAt,
tokenDigest,
});
return createHash('sha256').update(canonicalState).digest('hex');
}
function validateSecret(content: Buffer): Uint8Array {
if (content.byteLength === 0 || content.byteLength > MAX_TOKEN_BYTES) {
throw new CredentialStoreError('invalid-token-size', 'token file size is outside bounds');
@@ -285,7 +304,11 @@ export class FileCredentialStore {
};
}
async put(metadata: CredentialBindingMetadataDto, secret: Uint8Array): Promise<void> {
async put(
metadata: CredentialBindingMetadataDto,
secret: Uint8Array,
expectedGeneration?: string | null,
): Promise<void> {
const paths = this.paths(metadata.identity, metadata.estate, metadata.host);
ensureManagedDirectory(this.tokenDirectory, this.tokenDirectory);
const directoryIdentity = assertPrivateTokenDirectory(this.tokenDirectory);
@@ -309,6 +332,20 @@ export class FileCredentialStore {
);
}
try {
if (expectedGeneration !== undefined) {
const current = await this.snapshot(metadata.identity, metadata.estate, metadata.host);
try {
const actualGeneration = current?.generation ?? null;
if (actualGeneration !== expectedGeneration) {
throw new CredentialStoreError(
'credential-generation-mismatch',
'credential generation changed before replacement',
);
}
} finally {
current?.secret.fill(0);
}
}
const handle = await open(envelopeTemp, 'wx', 0o600);
try {
await handle.writeFile(`${JSON.stringify(envelope)}\n`, 'utf8');
@@ -344,6 +381,7 @@ export class FileCredentialStore {
| {
readonly binding: CredentialBindingMetadataDto;
readonly secret: Uint8Array;
readonly generation: string;
}
| undefined
> {
@@ -359,7 +397,10 @@ export class FileCredentialStore {
'binding metadata exists without its token generation',
);
}
return { binding, secret: new Uint8Array(resolved.secret) };
const secret = new Uint8Array(resolved.secret);
const generation = credentialBindingGeneration(binding, resolved.secret);
resolved.secret.fill(0);
return { binding, secret, generation };
}
async readBinding(
@@ -467,12 +508,16 @@ export class FileCredentialStore {
}
try {
if (expectedTokenDigest !== undefined) {
const current = await this.readBinding(identity, estate, host);
if (current === undefined || current.tokenDigest !== expectedTokenDigest) {
throw new CredentialStoreError(
'credential-generation-mismatch',
'credential generation changed before removal',
);
const current = await this.snapshot(identity, estate, host);
try {
if (current === undefined || current.generation !== expectedTokenDigest) {
throw new CredentialStoreError(
'credential-generation-mismatch',
'credential generation changed before removal',
);
}
} finally {
current?.secret.fill(0);
}
}
const beforeRemoval = assertPrivateTokenDirectory(this.tokenDirectory);
@@ -1,15 +1,25 @@
import { mkdtemp, mkdir, rm } from 'node:fs/promises';
import { spawnSync } from 'node:child_process';
import { mkdtemp, mkdir, rm, symlink, unlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ResolvedCredential } from './credential-provider.dto.js';
import { CredentialAuditJournal, CredentialJournalError } from './audit-journal.js';
import { parseCredentialEstateRegistry } from './estate-registry.js';
import { FileCredentialStore } from './file-credential-store.js';
import { provisionCredential, revokeCredential, type GiteaLifecycleProvider } from './lifecycle.js';
import {
acquireCredentialLifecycleLock,
CREDENTIAL_LIFECYCLE_LOCK_SECURITY_MODEL,
credentialLifecycleLocksDirectory,
provisionCredential,
revokeCredential,
type GiteaLifecycleProvider,
} from './lifecycle.js';
import { TeaLoginStore } from './tea-login-store.js';
let cleanup: string | undefined;
afterEach(async (): Promise<void> => {
vi.restoreAllMocks();
if (cleanup !== undefined) await rm(cleanup, { recursive: true, force: true });
cleanup = undefined;
});
@@ -88,6 +98,181 @@ function provider(): GiteaLifecycleProvider {
}
describe('credential lifecycle', (): void => {
it('pins lifecycle flock as cooperative serialization rather than authorization', (): void => {
expect(CREDENTIAL_LIFECYCLE_LOCK_SECURITY_MODEL).toStrictEqual({
purpose: 'cooperative-serialization',
authorizationBoundary: 'provider-authority',
generationPreconditions: 'optimistic-cooperating-mutators',
hostileSameUidFilesystem: 'out-of-scope-deferred',
});
});
it('refuses a concurrent same-identity lifecycle mutation across state roots', async (): Promise<void> => {
const { root, store, teaStore } = await fixture();
const lock = await acquireCredentialLifecycleLock('seat', 'homelab', 'git.example.invalid');
try {
const result = await provisionCredential(
{
identity: 'seat',
estate: 'homelab',
host: 'git.example.invalid',
tokenName: 'mosaic-seat-contended',
scopes: ['write:repository'],
},
authority,
provider(),
store,
teaStore,
{
stateRoot: `${root}-other`,
actor: 'seat',
lifecycleLock: {
identity: 'seat',
estate: 'homelab',
host: 'git.example.invalid',
async release(): Promise<void> {},
},
},
);
expect(result).toMatchObject({
outcome: 'refused',
mutation: 'none',
reason: { code: 'concurrent-mutation' },
});
} finally {
await lock.release();
}
});
it('keeps cooperative lifecycle locks below the validated user home, not shared tmp', (): void => {
const locksDirectory = credentialLifecycleLocksDirectory();
expect(locksDirectory).not.toMatch(/^\/tmp(?:\/|$)/);
expect(locksDirectory).toMatch(/\/\.local\/state\/mosaic\/credential-lifecycle-locks$/);
});
it.each([
['../seat', 'homelab', 'git.example.invalid'],
['seat', '../homelab', 'git.example.invalid'],
['seat', 'homelab', '../git.example.invalid'],
])(
'rejects traversal before constructing a lifecycle lock path',
async (identity, estate, host): Promise<void> => {
await expect(acquireCredentialLifecycleLock(identity, estate, host)).rejects.toMatchObject({
code: 'mutation-lock-unavailable',
});
},
);
it('returns a structured error when the lifecycle lock path cannot be opened', async (): Promise<void> => {
const { root, store, teaStore } = await fixture();
const locksDirectory = credentialLifecycleLocksDirectory();
await mkdir(locksDirectory, { recursive: true, mode: 0o700 });
const lockPath = join(locksDirectory, 'homelab--git.example.invalid--open-failure-seat.lock');
await unlink(lockPath).catch((): void => undefined);
await symlink('/dev/null', lockPath);
try {
const result = await provisionCredential(
{
identity: 'open-failure-seat',
estate: 'homelab',
host: 'git.example.invalid',
tokenName: 'mosaic-open-failure-seat',
scopes: ['write:repository'],
},
authority,
provider(),
store,
teaStore,
{ stateRoot: root, actor: 'open-failure-seat' },
);
expect(result).toMatchObject({
outcome: 'error',
mutation: 'none',
reason: { code: 'mutation-lock-unavailable' },
});
} finally {
await unlink(lockPath).catch((): void => undefined);
}
});
it.each(['provision', 'revoke'] as const)(
'returns an open structured %s result when lock failure cannot be sealed',
async (operation): Promise<void> => {
const { root, store, teaStore } = await fixture();
const identity = `seal-failure-${operation}`;
const locksDirectory = credentialLifecycleLocksDirectory();
await mkdir(locksDirectory, { recursive: true, mode: 0o700 });
const lockPath = join(locksDirectory, `homelab--git.example.invalid--${identity}.lock`);
await unlink(lockPath).catch((): void => undefined);
await symlink('/dev/null', lockPath);
vi.spyOn(CredentialAuditJournal.prototype, 'seal').mockRejectedValue(
new CredentialJournalError('journal-recovery-required', 'injected final seal failure'),
);
try {
const result =
operation === 'provision'
? await provisionCredential(
{
identity,
estate: 'homelab',
host: 'git.example.invalid',
tokenName: `mosaic-${identity}`,
scopes: ['write:repository'],
},
{ ...authority, identity },
provider(),
store,
teaStore,
{ stateRoot: root, actor: identity },
)
: await revokeCredential(
{ identity, estate: 'homelab', host: 'git.example.invalid' },
{ ...authority, identity },
provider(),
store,
teaStore,
{ stateRoot: root, actor: identity },
);
expect(result).toMatchObject({
outcome: 'error',
mutation: 'none',
reason: { code: 'journal-recovery-required' },
audit: { state: 'open' },
});
} finally {
await unlink(lockPath).catch((): void => undefined);
}
},
);
it('returns a sealed structured error when the Tea pre-state cannot be snapshotted', async (): Promise<void> => {
const { root, store, teaStore } = await fixture();
await teaStore.put('seat', 'git.example.invalid', new TextEncoder().encode('prior-tea-token'));
await writeFile(join(cleanup!, 'tea', 'config.yml'), 'logins: not-an-array\n', { mode: 0o600 });
const result = await provisionCredential(
{
identity: 'seat',
estate: 'homelab',
host: 'git.example.invalid',
tokenName: 'mosaic-seat-snapshot-failure',
scopes: ['write:repository'],
},
authority,
provider(),
store,
teaStore,
{ stateRoot: root, actor: 'seat' },
);
expect(result).toMatchObject({
outcome: 'error',
mutation: 'none',
reason: { code: 'credential-snapshot-unavailable' },
audit: { state: 'sealed' },
});
});
it('accepts provision only after exact principal and scope read-back', async (): Promise<void> => {
const { root, store, teaStore } = await fixture();
const result = await provisionCredential(
@@ -147,6 +332,209 @@ describe('credential lifecycle', (): void => {
await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual([]);
});
it.each(['store', 'tea'] as const)(
'removes %s state committed before a post-commit storage failure',
async (target): Promise<void> => {
const { root, store, teaStore } = await fixture();
if (target === 'store') {
const put = store.put.bind(store);
store.put = async (binding, secret): Promise<void> => {
await put(binding, secret);
throw new Error('injected store post-commit failure');
};
} else {
const put = teaStore.put.bind(teaStore);
teaStore.put = async (identity, host, secret): Promise<void> => {
await put(identity, host, secret);
throw new Error('injected Tea post-commit failure');
};
}
const result = await provisionCredential(
{
identity: 'seat',
estate: 'homelab',
host: 'git.example.invalid',
tokenName: `mosaic-seat-${target}-fault`,
scopes: ['write:repository'],
},
authority,
provider(),
store,
teaStore,
{ stateRoot: root, actor: 'seat' },
);
expect(result).toMatchObject({ outcome: 'error', mutation: 'none' });
await expect(
store.snapshot('seat', 'homelab', 'git.example.invalid'),
).resolves.toBeUndefined();
expect(teaStore.readBack('seat', 'git.example.invalid')).toBeUndefined();
},
);
it('rejects a stale lifecycle write when a competing generation bypasses serialization', async (): Promise<void> => {
const { root, store, teaStore } = await fixture();
const put = store.put.bind(store);
let injected = false;
store.put = async (binding, secret, expectedGeneration): Promise<void> => {
if (!injected) {
injected = true;
await put(
{
...binding,
tokenName: 'competing-generation',
createdAt: '2026-08-05T00:00:01.000Z',
},
new TextEncoder().encode('competing-token'),
expectedGeneration,
);
}
await put(binding, secret, expectedGeneration);
};
const result = await provisionCredential(
{
identity: 'seat',
estate: 'homelab',
host: 'git.example.invalid',
tokenName: 'stale-generation',
scopes: ['write:repository'],
},
authority,
provider(),
store,
teaStore,
{ stateRoot: root, actor: 'seat' },
);
expect(result).toMatchObject({
outcome: 'indeterminate',
mutation: 'applied',
reason: { code: 'rollback-incomplete' },
});
const current = await store.snapshot('seat', 'homelab', 'git.example.invalid');
try {
expect(current?.binding.tokenName).toBe('competing-generation');
expect(Buffer.from(current?.secret ?? []).toString('utf8')).toBe('competing-token');
} finally {
current?.secret.fill(0);
}
});
it.each(['absent', 'divergent'] as const)(
'restores an independently %s pre-operation Tea state exactly',
async (teaState): Promise<void> => {
const { root, store, teaStore } = await fixture();
await store.put(
{
identity: 'seat',
estate: 'homelab',
host: 'git.example.invalid',
providerLogin: 'seat',
tokenName: 'old-generation',
scopes: ['write:repository'],
createdAt: '2026-08-05T00:00:00.000Z',
},
new TextEncoder().encode('old-store-token'),
);
if (teaState === 'divergent') {
await teaStore.put(
'seat',
'git.example.invalid',
new TextEncoder().encode('divergent-tea-token'),
);
}
const journal = await CredentialAuditJournal.open(root, {
operation: 'provision',
actor: 'seat',
identity: 'seat',
estate: 'homelab',
host: 'git.example.invalid',
repo: null,
});
let evidenceWrites = 0;
const recordProviderEvidence = journal.recordProviderEvidence.bind(journal);
journal.recordProviderEvidence = async (evidence): Promise<void> => {
evidenceWrites += 1;
if (evidenceWrites === 2) throw new Error('injected post-registration failure');
await recordProviderEvidence(evidence);
};
const result = await provisionCredential(
{
identity: 'seat',
estate: 'homelab',
host: 'git.example.invalid',
tokenName: 'replacement-generation',
scopes: ['write:repository'],
},
authority,
provider(),
store,
teaStore,
{ stateRoot: root, actor: 'seat', journal, allowReplace: true },
);
expect(result).toMatchObject({ outcome: 'error', mutation: 'none' });
if (teaState === 'absent') {
expect(teaStore.readBack('seat', 'git.example.invalid')).toBeUndefined();
} else {
expect(
teaStore.matchesSecret(
'seat',
'git.example.invalid',
new TextEncoder().encode('divergent-tea-token'),
),
).toBe(true);
}
},
);
it('reports an incomplete rollback when Tea removal cannot be verified', async (): Promise<void> => {
const { root, store, teaStore } = await fixture();
const journal = await CredentialAuditJournal.open(root, {
operation: 'provision',
actor: 'seat',
identity: 'seat',
estate: 'homelab',
host: 'git.example.invalid',
repo: null,
});
let evidenceWrites = 0;
const recordProviderEvidence = journal.recordProviderEvidence.bind(journal);
journal.recordProviderEvidence = async (evidence): Promise<void> => {
evidenceWrites += 1;
if (evidenceWrites === 2) throw new Error('injected post-registration failure');
await recordProviderEvidence(evidence);
};
teaStore.restore = async (): Promise<void> => {
throw new Error('injected Tea restoration failure');
};
const result = await provisionCredential(
{
identity: 'seat',
estate: 'homelab',
host: 'git.example.invalid',
tokenName: 'mosaic-seat-rollback',
scopes: ['write:repository'],
},
authority,
provider(),
store,
teaStore,
{ stateRoot: root, actor: 'seat', journal },
);
expect(result).toMatchObject({
outcome: 'indeterminate',
mutation: 'applied',
reason: { code: 'rollback-incomplete' },
});
expect(teaStore.readBack('seat', 'git.example.invalid')).toBeDefined();
});
it('revokes at provider before removing the local binding', async (): Promise<void> => {
const { root, store, teaStore } = await fixture();
await provisionCredential(
@@ -181,6 +569,49 @@ describe('credential lifecycle', (): void => {
await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual([]);
});
it('releases an internally owned journal lock after an append failure', async (): Promise<void> => {
const { root, store, teaStore } = await fixture();
await provisionCredential(
{
identity: 'seat',
estate: 'homelab',
host: 'git.example.invalid',
tokenName: 'mosaic-seat-lock-release',
scopes: ['write:repository'],
},
authority,
provider(),
store,
teaStore,
{ stateRoot: root, actor: 'seat' },
);
const recordProviderEvidence = CredentialAuditJournal.prototype.recordProviderEvidence;
CredentialAuditJournal.prototype.recordProviderEvidence = async (): Promise<void> => {
throw new CredentialJournalError('journal-unavailable', 'injected append failure');
};
try {
const result = await revokeCredential(
{ identity: 'seat', estate: 'homelab', host: 'git.example.invalid' },
authority,
provider(),
store,
teaStore,
{ stateRoot: root, actor: 'seat' },
);
expect(result).toMatchObject({
outcome: 'indeterminate',
mutation: 'none',
audit: { state: 'open' },
});
if (result.audit.journalId === null) throw new Error('journal id was not returned');
const lockPath = join(root, 'journal-locks', `${result.audit.journalId}.lock`);
const lockProbe = spawnSync('/usr/bin/flock', ['-n', lockPath, '/usr/bin/true']);
expect(lockProbe.status).toBe(0);
} finally {
CredentialAuditJournal.prototype.recordProviderEvidence = recordProviderEvidence;
}
});
it('preserves the local recovery binding when provider revocation read-back still finds the token', async (): Promise<void> => {
const { root, store, teaStore } = await fixture();
await provisionCredential(
+404 -32
View File
@@ -1,6 +1,13 @@
import { spawnSync } from 'node:child_process';
import { timingSafeEqual } from 'node:crypto';
import { constants, lstatSync } from 'node:fs';
import { open } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { ensureManagedDirectory } from '../fleet/secure-file.js';
import { CredentialAuditJournal, CredentialJournalError } from './audit-journal.js';
import type { ResolvedCredential } from './credential-provider.dto.js';
import type { FileCredentialStore } from './file-credential-store.js';
import { credentialBindingGeneration, type FileCredentialStore } from './file-credential-store.js';
import type { TeaLoginStore } from './tea-login-store.js';
import type {
CredentialLifecycleOperation,
@@ -52,6 +59,137 @@ export interface LifecycleOptions {
readonly allowReplace?: boolean;
readonly journal?: CredentialAuditJournal;
readonly deferSuccessSeal?: boolean;
readonly lifecycleLock?: CredentialLifecycleLock;
readonly expectedStoreGeneration?: string | null;
readonly expectedTeaGeneration?: string | null;
}
export interface CredentialLifecycleLock {
readonly identity: string;
readonly estate: string;
readonly host: string;
release(): Promise<void>;
}
const activeLifecycleLocks = new WeakSet<CredentialLifecycleLock>();
/**
* This flock coordinates cooperating `mosaic cred` processes only. A process
* sharing the Unix uid can replace the pathname/inode, so the lock is never
* authorization evidence. Provider authority is the authorization boundary;
* generation preconditions are optimistic concurrency for cooperating store
* mutators, not atomic CAS against a hostile same-uid filesystem writer.
*/
export const CREDENTIAL_LIFECYCLE_LOCK_SECURITY_MODEL = Object.freeze({
purpose: 'cooperative-serialization',
authorizationBoundary: 'provider-authority',
generationPreconditions: 'optimistic-cooperating-mutators',
hostileSameUidFilesystem: 'out-of-scope-deferred',
} as const);
export class CredentialLifecycleLockError extends Error {
constructor(public readonly code: 'concurrent-mutation' | 'mutation-lock-unavailable') {
super(code);
this.name = 'CredentialLifecycleLockError';
}
}
// Do not promote this advisory lock into a same-uid authorization boundary;
// CREDENTIAL_LIFECYCLE_LOCK_SECURITY_MODEL is a tested public invariant.
export function credentialLifecycleLocksDirectory(): string {
return join(homedir(), '.local', 'state', 'mosaic', 'credential-lifecycle-locks');
}
export async function acquireCredentialLifecycleLock(
identity: string,
estate: string,
host: string,
): Promise<CredentialLifecycleLock> {
if (
!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(identity) ||
!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(estate) ||
!/^[a-z0-9][a-z0-9.-]*$/.test(host)
) {
throw new CredentialLifecycleLockError('mutation-lock-unavailable');
}
const uid = process.getuid?.();
if (uid === undefined) throw new CredentialLifecycleLockError('mutation-lock-unavailable');
const home = homedir();
const homeDirectory = lstatSync(home);
if (
!homeDirectory.isDirectory() ||
homeDirectory.isSymbolicLink() ||
homeDirectory.uid !== uid ||
(homeDirectory.mode & 0o022) !== 0
) {
throw new CredentialLifecycleLockError('mutation-lock-unavailable');
}
const locksDirectory = credentialLifecycleLocksDirectory();
ensureManagedDirectory(home, locksDirectory);
const directory = lstatSync(locksDirectory);
if (
!directory.isDirectory() ||
directory.isSymbolicLink() ||
directory.uid !== uid ||
(directory.mode & 0o077) !== 0
) {
throw new CredentialLifecycleLockError('mutation-lock-unavailable');
}
const lockPath = join(locksDirectory, `${estate}--${host}--${identity}.lock`);
let handle: Awaited<ReturnType<typeof open>> | undefined;
try {
handle = await open(
lockPath,
constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW,
0o600,
);
const file = await handle.stat();
if (!file.isFile() || file.uid !== uid || (file.mode & 0o077) !== 0) {
throw new Error('credential lifecycle lock file is unsafe');
}
} catch {
await handle?.close().catch((): void => undefined);
throw new CredentialLifecycleLockError('mutation-lock-unavailable');
}
if (handle === undefined) throw new CredentialLifecycleLockError('mutation-lock-unavailable');
const acquired = spawnSync('/usr/bin/flock', ['-n', '3'], {
stdio: ['ignore', 'ignore', 'ignore', handle.fd],
});
if (acquired.error !== undefined || acquired.status !== 0) {
await handle.close().catch((): void => undefined);
throw new CredentialLifecycleLockError(
acquired.status === 1 ? 'concurrent-mutation' : 'mutation-lock-unavailable',
);
}
let released = false;
const lock: CredentialLifecycleLock = {
identity,
estate,
host,
async release(): Promise<void> {
if (released) return;
released = true;
activeLifecycleLocks.delete(lock);
await handle.close();
},
};
activeLifecycleLocks.add(lock);
return lock;
}
function holdsCredentialLifecycleLock(
lock: CredentialLifecycleLock | undefined,
identity: string,
estate: string,
host: string,
): boolean {
return (
lock !== undefined &&
activeLifecycleLocks.has(lock) &&
lock.identity === identity &&
lock.estate === estate &&
lock.host === host
);
}
function lifecycleResult(
@@ -90,6 +228,35 @@ function lifecycleResult(
};
}
async function lifecycleLockFailureResult(
operation: 'provision' | 'revoke',
request: LifecycleRequest,
journal: CredentialAuditJournal,
code: 'concurrent-mutation' | 'mutation-lock-unavailable',
): Promise<CredentialLifecycleResultDto> {
try {
await journal.seal(code === 'concurrent-mutation' ? 'refused' : 'error', code);
return lifecycleResult(operation, request, {
outcome: code === 'concurrent-mutation' ? 'refused' : 'error',
mutation: 'none',
code,
message: 'Credential lifecycle mutation lock could not be acquired.',
journalId: journal.journalId(),
auditState: 'sealed',
});
} catch (sealError: unknown) {
await journal.closeIncomplete().catch((): void => undefined);
return lifecycleResult(operation, request, {
outcome: 'error',
mutation: 'none',
code: sealError instanceof CredentialJournalError ? sealError.code : 'journal-unavailable',
message: 'Mutation lock failure could not be sealed durably.',
journalId: journal.journalId(),
auditState: 'open',
});
}
}
async function openLifecycleJournal(
operation: 'provision' | 'rotate' | 'revoke',
request: LifecycleRequest,
@@ -103,8 +270,13 @@ async function openLifecycleJournal(
host: request.host,
repo: null,
});
await journal.recordIntent(`${operation}-requested`);
return journal;
try {
await journal.recordIntent(`${operation}-requested`);
return journal;
} catch (error: unknown) {
await journal.closeIncomplete().catch((): void => undefined);
throw error;
}
}
export async function provisionCredential(
@@ -115,22 +287,124 @@ export async function provisionCredential(
teaStore: TeaLoginStore,
options: LifecycleOptions,
): Promise<CredentialLifecycleResultDto> {
const ownsJournal = options.journal === undefined;
const journal = options.journal ?? (await openLifecycleJournal('provision', request, options));
let ownedLock: CredentialLifecycleLock | undefined;
if (
!holdsCredentialLifecycleLock(
options.lifecycleLock,
request.identity,
request.estate,
request.host,
)
) {
try {
ownedLock = await acquireCredentialLifecycleLock(
request.identity,
request.estate,
request.host,
);
} catch (error: unknown) {
const code =
error instanceof CredentialLifecycleLockError ? error.code : 'mutation-lock-unavailable';
return lifecycleLockFailureResult('provision', request, journal, code);
}
}
try {
return await provisionCredentialLocked(
request,
authority,
provider,
store,
teaStore,
options,
journal,
);
} catch (error: unknown) {
if (ownsJournal) await journal.closeIncomplete().catch((): void => undefined);
throw error;
} finally {
await ownedLock?.release();
}
}
async function provisionCredentialLocked(
request: ProvisionRequest,
authority: ResolvedCredential,
provider: GiteaLifecycleProvider,
store: FileCredentialStore,
teaStore: TeaLoginStore,
options: LifecycleOptions,
journal: CredentialAuditJournal,
): Promise<CredentialLifecycleResultDto> {
let mutation: 'none' | 'unknown' | 'applied' = 'none';
let minted: MintedToken | undefined;
let failureCode = 'mutation-state-unknown';
const prior = await store.snapshot(request.identity, request.estate, request.host);
if (prior !== undefined && options.allowReplace !== true) {
await journal.seal('refused', 'credential-already-exists');
let mintedStoreGeneration: string | undefined;
let prior: Awaited<ReturnType<FileCredentialStore['snapshot']>> = undefined;
let priorTea: ReturnType<TeaLoginStore['snapshot']> = undefined;
try {
prior = await store.snapshot(request.identity, request.estate, request.host);
priorTea = teaStore.snapshot(request.identity, request.host);
} catch {
prior?.secret.fill(0);
priorTea?.secret.fill(0);
try {
await journal.seal('error', 'credential-snapshot-unavailable');
return lifecycleResult('provision', request, {
outcome: 'error',
mutation: 'none',
code: 'credential-snapshot-unavailable',
message: 'Pre-operation credential state could not be snapshotted safely.',
journalId: journal.journalId(),
auditState: 'sealed',
});
} catch (sealError: unknown) {
await journal.closeIncomplete().catch((): void => undefined);
return lifecycleResult('provision', request, {
outcome: 'error',
mutation: 'none',
code: sealError instanceof CredentialJournalError ? sealError.code : 'journal-unavailable',
message: 'Credential snapshot failure could not be sealed durably.',
journalId: journal.journalId(),
auditState: 'open',
});
}
}
if (
(options.expectedStoreGeneration !== undefined &&
(prior?.generation ?? null) !== options.expectedStoreGeneration) ||
(options.expectedTeaGeneration !== undefined &&
(priorTea?.generation ?? null) !== options.expectedTeaGeneration)
) {
prior?.secret.fill(0);
priorTea?.secret.fill(0);
await journal.seal('refused', 'concurrent-mutation');
return lifecycleResult('provision', request, {
outcome: 'refused',
mutation: 'none',
code: 'credential-already-exists',
message: 'A governed credential already exists; use rotate.',
code: 'concurrent-mutation',
message: 'Credential generation changed before the lifecycle transaction began.',
journalId: journal.journalId(),
auditState: 'sealed',
});
}
if (prior !== undefined && options.allowReplace !== true) {
try {
await journal.seal('refused', 'credential-already-exists');
return lifecycleResult('provision', request, {
outcome: 'refused',
mutation: 'none',
code: 'credential-already-exists',
message: 'A governed credential already exists; use rotate.',
journalId: journal.journalId(),
auditState: 'sealed',
});
} finally {
prior.secret.fill(0);
priorTea?.secret.fill(0);
}
}
try {
const identity = await provider.readBasicIdentity(authority);
if (identity.login !== request.identity || authority.identity !== request.identity) {
@@ -166,20 +440,19 @@ export async function provisionCredential(
failureCode = 'scope-not-evaluable';
throw new Error('scope read-back disagreed');
}
await store.put(
{
identity: request.identity,
estate: request.estate,
host: request.host,
providerLogin: identity.login,
tokenName: request.tokenName,
scopes: readBack.scopes,
createdAt: options.now?.() ?? new Date().toISOString(),
},
minted.secret,
);
const mintedBinding = {
identity: request.identity,
estate: request.estate,
host: request.host,
providerLogin: identity.login,
tokenName: request.tokenName,
scopes: readBack.scopes,
createdAt: options.now?.() ?? new Date().toISOString(),
};
mintedStoreGeneration = credentialBindingGeneration(mintedBinding, minted.secret);
await store.put(mintedBinding, minted.secret, prior?.generation ?? null);
await journal.recordMutation('token-binding-stored');
await teaStore.put(request.identity, request.host, minted.secret);
await teaStore.put(request.identity, request.host, minted.secret, priorTea?.generation ?? null);
const teaLogin = teaStore.readBack(request.identity, request.host);
if (
teaLogin === undefined ||
@@ -231,18 +504,67 @@ export async function provisionCredential(
if (await provider.tokenExists(authority, request.identity, request.tokenName)) {
throw new Error('minted token still exists after rollback');
}
if (prior === undefined) {
await store.remove(request.identity, request.estate, request.host);
await teaStore.remove(request.identity, request.host).catch((): void => undefined);
} else {
await store.put(prior.binding, prior.secret);
await teaStore.put(request.identity, request.host, prior.secret);
const expectedStoreGeneration = prior?.generation ?? null;
const current = await store.snapshot(request.identity, request.estate, request.host);
try {
if ((current?.generation ?? null) !== expectedStoreGeneration) {
if (
mintedStoreGeneration === undefined ||
current?.generation !== mintedStoreGeneration
) {
throw new Error('credential generation changed during rollback');
}
if (prior === undefined) {
await store.remove(
request.identity,
request.estate,
request.host,
mintedStoreGeneration,
);
} else {
await store.put(prior.binding, prior.secret, mintedStoreGeneration);
}
}
} finally {
current?.secret.fill(0);
}
const expectedTeaGeneration = priorTea?.generation ?? null;
const currentTea = teaStore.snapshot(request.identity, request.host);
try {
if ((currentTea?.generation ?? null) !== expectedTeaGeneration) {
if (
currentTea === undefined ||
currentTea.secret.byteLength !== minted.secret.byteLength ||
!timingSafeEqual(Buffer.from(currentTea.secret), Buffer.from(minted.secret))
) {
throw new Error('Tea login generation changed during rollback');
}
await teaStore.restore(request.identity, request.host, priorTea, currentTea.generation);
}
} finally {
currentTea?.secret.fill(0);
}
const restored = await store.snapshot(request.identity, request.estate, request.host);
try {
const storeRestored =
prior === undefined
? restored === undefined
: restored !== undefined &&
JSON.stringify(restored.binding) === JSON.stringify(prior.binding) &&
restored.secret.byteLength === prior.secret.byteLength &&
timingSafeEqual(Buffer.from(restored.secret), Buffer.from(prior.secret));
if (!storeRestored || !teaStore.matchesSnapshot(request.identity, request.host, priorTea)) {
throw new Error('pre-operation credential state was not restored exactly');
}
} finally {
restored?.secret.fill(0);
}
rollbackComplete = true;
} catch {
rollbackComplete = false;
} finally {
prior?.secret.fill(0);
priorTea?.secret.fill(0);
}
if (journalFailure) {
if (rollbackComplete) {
@@ -266,6 +588,7 @@ export async function provisionCredential(
} finally {
minted?.secret.fill(0);
prior?.secret.fill(0);
priorTea?.secret.fill(0);
}
}
@@ -278,10 +601,48 @@ export async function revokeCredential(
options: LifecycleOptions,
): Promise<CredentialLifecycleResultDto> {
const journal = await openLifecycleJournal('revoke', request, options);
let mutation: 'none' | 'unknown' | 'applied' = 'none';
let ownedLock: CredentialLifecycleLock | undefined;
if (
!holdsCredentialLifecycleLock(
options.lifecycleLock,
request.identity,
request.estate,
request.host,
)
) {
try {
ownedLock = await acquireCredentialLifecycleLock(
request.identity,
request.estate,
request.host,
);
} catch (error: unknown) {
const code =
error instanceof CredentialLifecycleLockError ? error.code : 'mutation-lock-unavailable';
return lifecycleLockFailureResult('revoke', request, journal, code);
}
}
try {
const binding = await store.readBinding(request.identity, request.estate, request.host);
if (binding === undefined) {
return await revokeCredentialLocked(request, authority, provider, store, teaStore, journal);
} finally {
await ownedLock?.release();
}
}
async function revokeCredentialLocked(
request: LifecycleRequest,
authority: ResolvedCredential,
provider: GiteaLifecycleProvider,
store: FileCredentialStore,
teaStore: TeaLoginStore,
journal: CredentialAuditJournal,
): Promise<CredentialLifecycleResultDto> {
let mutation: 'none' | 'unknown' | 'applied' = 'none';
let credentialSnapshot: Awaited<ReturnType<FileCredentialStore['snapshot']>> = undefined;
let teaSnapshot: ReturnType<TeaLoginStore['snapshot']> = undefined;
try {
credentialSnapshot = await store.snapshot(request.identity, request.estate, request.host);
if (credentialSnapshot === undefined) {
await journal.seal('refused', 'no-token-for-identity');
return lifecycleResult('revoke', request, {
outcome: 'refused',
@@ -292,6 +653,8 @@ export async function revokeCredential(
auditState: 'sealed',
});
}
const binding = credentialSnapshot.binding;
teaSnapshot = teaStore.snapshot(request.identity, request.host);
const identity = await provider.readBasicIdentity(authority);
if (identity.login !== request.identity || authority.identity !== request.identity) {
await journal.seal('refused', 'provider-identity-mismatch');
@@ -325,12 +688,17 @@ export async function revokeCredential(
auditState: 'sealed',
});
}
await teaStore.remove(request.identity, request.host);
await teaStore.remove(request.identity, request.host, teaSnapshot?.generation ?? null);
if (teaStore.readBack(request.identity, request.host) !== undefined) {
throw new Error('Tea login still exists after revocation');
}
await journal.recordMutation('tea-login-removed');
await store.remove(request.identity, request.estate, request.host, binding.tokenDigest);
await store.remove(
request.identity,
request.estate,
request.host,
credentialSnapshot.generation,
);
await journal.seal('ok', 'revoke-verified');
return lifecycleResult('revoke', request, {
outcome: 'ok',
@@ -342,6 +710,7 @@ export async function revokeCredential(
});
} catch (error: unknown) {
if (error instanceof CredentialJournalError) {
await journal.closeIncomplete().catch((): void => undefined);
return lifecycleResult('revoke', request, {
outcome: 'indeterminate',
mutation,
@@ -360,5 +729,8 @@ export async function revokeCredential(
journalId: journal.journalId(),
auditState: 'sealed',
});
} finally {
credentialSnapshot?.secret.fill(0);
teaSnapshot?.secret.fill(0);
}
}
@@ -1,4 +1,4 @@
import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { chmod, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
@@ -33,6 +33,58 @@ describe('host-bound Tea login store', (): void => {
});
});
it('snapshots and restores the exact host-bound Tea record fields', 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('original-token'));
const customized = (await readFile(configPath, 'utf8')).replace(
'default: false',
'default: true\n extension-field: preserved',
);
await writeFile(configPath, customized, { mode: 0o600 });
const snapshot = store.snapshot('seat', 'git.one.invalid');
expect(snapshot).toBeDefined();
await store.put('seat', 'git.one.invalid', new TextEncoder().encode('replacement-token'));
await store.restore('seat', 'git.one.invalid', snapshot);
expect(store.matchesSnapshot('seat', 'git.one.invalid', snapshot)).toBe(true);
expect(await readFile(configPath, 'utf8')).toContain('extension-field: preserved');
snapshot?.secret.fill(0);
});
it.each(['put', 'remove'] as const)(
'removes secret-bearing temporary files when %s fails before rename',
async (operation): Promise<void> => {
root = await mkdtemp(join(tmpdir(), 'mosaic-tea-store-'));
const configPath = join(root, 'tea', 'config.yml');
const baseline = new TeaLoginStore(configPath);
if (operation === 'remove') {
await baseline.put('seat', 'git.one.invalid', new TextEncoder().encode('token-one'));
}
const store = new TeaLoginStore(configPath, {
beforeRename: async (candidate): Promise<void> => {
if (candidate === operation) throw new Error('injected pre-rename failure');
},
});
if (operation === 'put') {
await expect(
store.put('seat', 'git.one.invalid', new TextEncoder().encode('token-one')),
).rejects.toThrow('injected pre-rename failure');
} else {
await expect(store.remove('seat', 'git.one.invalid')).rejects.toThrow(
'injected pre-rename failure',
);
}
expect((await readdir(join(root, 'tea'))).filter((name) => name.endsWith('.tmp'))).toEqual(
[],
);
},
);
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');
@@ -1,6 +1,7 @@
import { randomUUID, timingSafeEqual } from 'node:crypto';
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
import { open, rename, unlink } from 'node:fs/promises';
import { dirname } from 'node:path';
import { isDeepStrictEqual } from 'node:util';
import { parse, stringify } from 'yaml';
import { z } from 'zod';
import { ensureManagedDirectory, readRegularFileSecure } from '../fleet/secure-file.js';
@@ -24,6 +25,7 @@ interface TeaLoginRecord {
readonly token: string;
readonly user: string;
readonly default: boolean;
readonly [key: string]: unknown;
}
interface TeaConfig {
@@ -82,10 +84,95 @@ function missing(error: unknown): boolean {
return error instanceof Error && 'code' in error && error.code === 'ENOENT';
}
export class TeaLoginStore {
constructor(private readonly configPath: string) {}
export interface TeaLoginSnapshot {
readonly fields: Readonly<Record<string, unknown>>;
readonly secret: Uint8Array;
readonly generation: string;
}
async put(identity: string, host: string, secret: Uint8Array): Promise<void> {
function snapshotFromConfig(
config: TeaConfig,
identity: string,
host: string,
): TeaLoginSnapshot | undefined {
const matches = config.logins.filter(
(login): boolean => login.name === loginName(identity, host) && login.url === `https://${host}`,
);
if (matches.length === 0) return undefined;
if (matches.length !== 1 || matches[0] === undefined) {
throw new TeaLoginStoreError('tea-config-invalid', 'Tea login binding is ambiguous');
}
const { token, ...fields } = matches[0];
const secret = new TextEncoder().encode(token);
const generation = createHash('sha256')
.update(JSON.stringify(fields))
.update('\0')
.update(secret)
.digest('hex');
return { fields: structuredClone(fields), secret, generation };
}
function assertExpectedGeneration(
config: TeaConfig,
identity: string,
host: string,
expectedGeneration: string | null | undefined,
): void {
if (expectedGeneration === undefined) return;
const current = snapshotFromConfig(config, identity, host);
try {
if ((current?.generation ?? null) !== expectedGeneration) {
throw new TeaLoginStoreError(
'tea-generation-mismatch',
'Tea login generation changed before mutation',
);
}
} finally {
current?.secret.fill(0);
}
}
export interface TeaLoginStoreRuntimeOptions {
readonly beforeRename?: (
operation: 'put' | 'remove' | 'restore',
tempPath: string,
) => Promise<void>;
}
export class TeaLoginStore {
constructor(
private readonly configPath: string,
private readonly runtime: TeaLoginStoreRuntimeOptions = {},
) {}
private async commit(
operation: 'put' | 'remove' | 'restore',
config: TeaConfig,
directory: string,
): Promise<void> {
const temp = `${this.configPath}.${randomUUID()}.tmp`;
try {
const handle = await open(temp, 'wx', 0o600);
try {
await handle.writeFile(stringify(config), 'utf8');
await handle.sync();
} finally {
await handle.close();
}
await this.runtime.beforeRename?.(operation, temp);
await rename(temp, this.configPath);
await syncDirectory(directory);
} finally {
await unlink(temp).catch((): void => undefined);
}
}
async put(
identity: string,
host: string,
secret: Uint8Array,
expectedGeneration?: string | null,
): Promise<void> {
if (!SAFE_NAME.test(identity) || !/^[a-z0-9][a-z0-9.-]*$/.test(host)) {
throw new TeaLoginStoreError('invalid-input', 'identity or host is outside the grammar');
}
@@ -109,6 +196,7 @@ export class TeaLoginStore {
} catch (error: unknown) {
if (!missing(error)) throw error;
}
assertExpectedGeneration(current, identity, host, expectedGeneration);
const token = Buffer.from(secret).toString('utf8');
const record: TeaLoginRecord = {
name: loginName(identity, host),
@@ -122,16 +210,100 @@ export class TeaLoginStore {
!(login.name === loginName(identity, host) && login.url === `https://${host}`),
);
logins.push(record);
const temp = `${this.configPath}.${randomUUID()}.tmp`;
const handle = await open(temp, 'wx', 0o600);
await this.commit('put', { ...current, logins }, directory);
} finally {
await lock.close();
await unlink(lockPath).catch((): void => undefined);
}
}
snapshot(identity: string, host: string): TeaLoginSnapshot | undefined {
const directory = dirname(this.configPath);
let snapshot;
try {
snapshot = readRegularFileSecure(this.configPath, {
root: directory,
maxBytes: 1024 * 1024,
});
} catch (error: unknown) {
if (missing(error)) return undefined;
throw error;
}
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');
}
return snapshotFromConfig(decoded.data, identity, host);
}
matchesSnapshot(identity: string, host: string, expected: TeaLoginSnapshot | undefined): boolean {
const actual = this.snapshot(identity, host);
try {
if (actual === undefined || expected === undefined) return actual === expected;
return (
isDeepStrictEqual(actual.fields, expected.fields) &&
actual.secret.byteLength === expected.secret.byteLength &&
timingSafeEqual(Buffer.from(actual.secret), Buffer.from(expected.secret))
);
} finally {
actual?.secret.fill(0);
}
}
async restore(
identity: string,
host: string,
snapshot: TeaLoginSnapshot | undefined,
expectedGeneration?: string | null,
): Promise<void> {
if (!SAFE_NAME.test(identity) || !/^[a-z0-9][a-z0-9.-]*$/.test(host)) {
throw new TeaLoginStoreError('invalid-input', 'identity or host is outside the grammar');
}
const directory = dirname(this.configPath);
ensureManagedDirectory(directory, directory);
const lockPath = `${this.configPath}.lock`;
const lock = await acquireLock(lockPath);
try {
let current: TeaConfig = { logins: [] };
try {
await handle.writeFile(stringify({ ...current, logins }), 'utf8');
await handle.sync();
} finally {
await handle.close();
const currentSnapshot = readRegularFileSecure(this.configPath, {
root: directory,
maxBytes: 1024 * 1024,
});
assertPrivate(currentSnapshot);
const decoded = configSchema.safeParse(parse(currentSnapshot.content.toString('utf8')));
if (!decoded.success) {
throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation');
}
current = decoded.data;
} catch (error: unknown) {
if (!missing(error)) throw error;
if (
snapshot === undefined &&
(expectedGeneration === undefined || expectedGeneration === null)
)
return;
}
await rename(temp, this.configPath);
await syncDirectory(directory);
assertExpectedGeneration(current, identity, host, expectedGeneration);
const logins = current.logins.filter(
(login): boolean =>
!(login.name === loginName(identity, host) && login.url === `https://${host}`),
);
if (snapshot !== undefined) {
const restored = loginSchema.parse({
...structuredClone(snapshot.fields),
token: Buffer.from(snapshot.secret).toString('utf8'),
});
if (restored.name !== loginName(identity, host) || restored.url !== `https://${host}`) {
throw new TeaLoginStoreError(
'tea-config-invalid',
'Tea snapshot does not match the requested identity and host',
);
}
logins.push(restored);
}
await this.commit('restore', { ...current, logins }, directory);
} finally {
await lock.close();
await unlink(lockPath).catch((): void => undefined);
@@ -175,34 +347,40 @@ export class TeaLoginStore {
return timingSafeEqual(Buffer.from(resolved.secret), Buffer.from(secret));
}
async remove(identity: string, host: string): Promise<void> {
async remove(identity: string, host: string, expectedGeneration?: string | null): Promise<void> {
const directory = dirname(this.configPath);
const lockPath = `${this.configPath}.lock`;
const lock = await acquireLock(lockPath);
try {
const snapshot = readRegularFileSecure(this.configPath, {
root: directory,
maxBytes: 1024 * 1024,
});
let snapshot;
try {
snapshot = readRegularFileSecure(this.configPath, {
root: directory,
maxBytes: 1024 * 1024,
});
} catch (error: unknown) {
if (missing(error)) {
if (expectedGeneration !== undefined && expectedGeneration !== null) {
throw new TeaLoginStoreError(
'tea-generation-mismatch',
'Tea login generation changed before removal',
);
}
return;
}
throw error;
}
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');
}
assertExpectedGeneration(decoded.data, identity, host, expectedGeneration);
const logins = decoded.data.logins.filter(
(login): boolean =>
!(login.name === loginName(identity, host) && login.url === `https://${host}`),
);
const temp = `${this.configPath}.${randomUUID()}.tmp`;
const handle = await open(temp, 'wx', 0o600);
try {
await handle.writeFile(stringify({ ...decoded.data, logins }), 'utf8');
await handle.sync();
} finally {
await handle.close();
}
await rename(temp, this.configPath);
await syncDirectory(directory);
await this.commit('remove', { ...decoded.data, logins }, directory);
} finally {
await lock.close();
await unlink(lockPath).catch((): void => undefined);