fix(cred): make rollback and audit outcomes truthful
ci/woodpecker/pr/ci Pipeline failed

This commit is contained in:
2026-08-05 18:01:41 -05:00
parent 593b5a1f45
commit 12958610cb
15 changed files with 2041 additions and 117 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.
+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)
@@ -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"
+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);