diff --git a/docs/PRD.md b/docs/PRD.md index ad4a8406..e6981f75 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -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, and SHALL read the provider `/user` object back through each path. 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. 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. diff --git a/packages/mosaic/framework/tools/git/detect-platform.sh b/packages/mosaic/framework/tools/git/detect-platform.sh index 214dbf67..01b6f0c6 100755 --- a/packages/mosaic/framework/tools/git/detect-platform.sh +++ b/packages/mosaic/framework/tools/git/detect-platform.sh @@ -516,6 +516,11 @@ get_gitea_token() { _ident="$(git config --get mosaic.gitIdentity 2>/dev/null || true)" _ident_src="git config mosaic.gitIdentity" fi + if [[ -n "${MOSAIC_AGENT_NAME:-}" && -n "$_ident" && "$_ident" != "$MOSAIC_AGENT_NAME" ]]; then + printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=provider-identity-mismatch identity=%s fleet_identity=%s host=%s shared_path_entered=false source=%s\n' \ + "$_ident" "$MOSAIC_AGENT_NAME" "$host" "$_ident_src" >&2 + return 1 + fi if [[ -n "$_ident" ]]; then local _idpfx="" case "$host" in diff --git a/packages/mosaic/framework/tools/git/git-credential-mosaic b/packages/mosaic/framework/tools/git/git-credential-mosaic index b0bffc4c..a1a1048c 100755 --- a/packages/mosaic/framework/tools/git/git-credential-mosaic +++ b/packages/mosaic/framework/tools/git/git-credential-mosaic @@ -32,6 +32,12 @@ done ident="$MOSAIC_GIT_IDENTITY" [ -z "$ident" ] && ident=$(git config --get mosaic.gitIdentity 2>/dev/null) [ -z "$ident" ] && ident="$username_in" +if [ -n "${MOSAIC_AGENT_NAME:-}" ] && [ -n "$ident" ] && [ "$ident" != "$MOSAIC_AGENT_NAME" ]; then + echo "quit=true" + printf 'MOSAIC_CREDENTIAL_REFUSAL outcome=refused reason=provider-identity-mismatch identity=%s fleet_identity=%s host=%s shared_path_entered=false source=git-credential-mosaic\n' \ + "$ident" "$MOSAIC_AGENT_NAME" "$host" >&2 + exit 1 +fi if [ -n "$ident" ]; then case "$host" in git.uscllc.com) idpfx=gitea-usc;; diff --git a/packages/mosaic/framework/tools/git/test-git-credential-mosaic.sh b/packages/mosaic/framework/tools/git/test-git-credential-mosaic.sh index 456d2f67..eb99cb86 100755 --- a/packages/mosaic/framework/tools/git/test-git-credential-mosaic.sh +++ b/packages/mosaic/framework/tools/git/test-git-credential-mosaic.sh @@ -103,6 +103,15 @@ echo -n "agentA-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-to out=$(run_helper "git.mosaicstack.dev" "agentA") assert_eq "username-resolved identity: username" "username=agentA" "$(echo "$out" | grep '^username=')" assert_eq "username-resolved identity: password" "password=agentA-mosaicstack-token" "$(echo "$out" | grep '^password=')" +set +e +out=$(run_helper "git.mosaicstack.dev" "agentA" MOSAIC_AGENT_NAME=agentB 2>"$WORK_DIR/fleet-mismatch.stderr") +rc=$? +set -e +err=$(cat "$WORK_DIR/fleet-mismatch.stderr") +if [[ "$rc" -eq 0 || "$out" != *"quit=true"* || "$err" != *"reason=provider-identity-mismatch"* || "$err" != *"shared_path_entered=false"* ]]; then + echo "FAIL: fleet identity override was not refused before token resolution" >&2 + fail=1 +fi # --------------------------------------------------------------------------- # 3. git config mosaic.gitIdentity (per-worktree) beats git-supplied username. @@ -131,7 +140,7 @@ assert_eq "no per-slot token: username" "username=git" "$(echo "$out" | grep '^u assert_eq "no per-slot token: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')" set +e -out=$(run_helper "git.mosaicstack.dev" "no-such-agent" MOSAIC_AGENT_NAME=synthetic-seat 2>"$WORK_DIR/fleet-missing.stderr") +out=$(run_helper "git.mosaicstack.dev" "no-such-agent" MOSAIC_AGENT_NAME=no-such-agent 2>"$WORK_DIR/fleet-missing.stderr") rc=$? set -e err=$(cat "$WORK_DIR/fleet-missing.stderr") diff --git a/packages/mosaic/framework/tools/git/test-gitea-token-identity.sh b/packages/mosaic/framework/tools/git/test-gitea-token-identity.sh index 4c01116b..1db4bb39 100755 --- a/packages/mosaic/framework/tools/git/test-gitea-token-identity.sh +++ b/packages/mosaic/framework/tools/git/test-gitea-token-identity.sh @@ -111,6 +111,15 @@ echo -n "agentA-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-to git -C "$REPO_DIR" config mosaic.gitIdentity agentA out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_AGENT_NAME=agentA) assert_eq "confirmed fleet identity bypasses shared path" "agentA-mosaicstack-token" "$out" +set +e +out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_AGENT_NAME=agentA MOSAIC_GIT_IDENTITY=agentB 2>"$WORK_DIR/fleet-mismatch.stderr") +rc=$? +set -e +err=$(cat "$WORK_DIR/fleet-mismatch.stderr") +if [[ "$rc" -eq 0 || -n "$out" || "$err" != *"reason=provider-identity-mismatch"* || "$err" != *"shared_path_entered=false"* ]]; then + echo "FAIL: fleet identity override was not refused before token resolution" >&2 + fail=1 +fi # --------------------------------------------------------------------------- # 3. MOSAIC_GIT_IDENTITY env beats git config mosaic.gitIdentity. diff --git a/packages/mosaic/src/commands/cred.ts b/packages/mosaic/src/commands/cred.ts index c677e7ae..210eab98 100644 --- a/packages/mosaic/src/commands/cred.ts +++ b/packages/mosaic/src/commands/cred.ts @@ -1,5 +1,5 @@ import { fstatSync, writeSync } from 'node:fs'; -import { open, readFile, rename } from 'node:fs/promises'; +import { open, rename } from 'node:fs/promises'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import type { Command } from 'commander'; @@ -486,7 +486,7 @@ export async function executeCredentialRotate( }); } const context = await lifecycleContext(options); - const old = await context.store.readBinding(identity, options.estate, options.host); + const old = await context.store.snapshot(identity, options.estate, options.host); if (old === undefined) { return localLifecycleResult('rotate', identity, options, { outcome: 'refused', @@ -510,16 +510,17 @@ export async function executeCredentialRotate( estate: options.estate, host: options.host, tokenName: options.tokenName, - scopes: (options.scopes ?? old.scopes.join(',')).split(',').filter(Boolean), + scopes: (options.scopes ?? old.binding.scopes.join(',')).split(',').filter(Boolean), }, authority, context.provider, context.store, context.teaStore, - { stateRoot: context.stateRoot, actor: options.actor }, + { stateRoot: context.stateRoot, actor: options.actor, allowReplace: true }, ); if (provisioned.outcome !== 'ok') { await journal.seal(provisioned.outcome, provisioned.reason.code); + old.secret.fill(0); return { ...provisioned, operation: 'rotate', @@ -527,9 +528,32 @@ export async function executeCredentialRotate( }; } await journal.recordMutation('token-mint-applied'); - await context.provider.revokeToken(authority, identity, old.tokenName); - await journal.recordMutation('token-revoke-applied'); + try { + await context.provider.revokeToken(authority, identity, old.binding.tokenName); + if (await context.provider.tokenExists(authority, identity, old.binding.tokenName)) { + throw new Error('old token still exists'); + } + await journal.recordMutation('token-revoke-applied'); + } catch { + await context.provider.revokeToken(authority, identity, options.tokenName); + if (await context.provider.tokenExists(authority, identity, options.tokenName)) { + throw new Error('replacement rollback could not be verified'); + } + await context.store.put(old.binding, old.secret); + await context.teaStore.put(identity, options.host, old.secret); + await journal.recordMutation('rotate-rollback-verified'); + await journal.seal('indeterminate', 'old-credential-preserved'); + old.secret.fill(0); + return localLifecycleResult('rotate', identity, options, { + outcome: 'indeterminate', + mutation: 'none', + code: 'old-credential-preserved', + message: 'Replacement failed; the previous credential remains the canonical binding.', + audit: { journalId: journal.journalId(), state: 'sealed' }, + }); + } await journal.seal('ok', 'rotate-verified'); + old.secret.fill(0); return { ...provisioned, operation: 'rotate', @@ -571,7 +595,19 @@ export async function executeCredentialWire( }); await journal.recordIntent('wire-requested'); try { - const existing = await readFile(options.seatEnv, 'utf8').catch((): string => ''); + let existing = ''; + try { + const snapshot = readRegularFileSecure(options.seatEnv, { + root: dirname(options.seatEnv), + maxBytes: 1024 * 1024, + }); + if ((snapshot.mode & 0o022) !== 0 || snapshot.uid !== process.getuid?.()) { + throw new Error('seat environment ownership or mode is unsafe'); + } + existing = snapshot.content.toString('utf8'); + } catch (error: unknown) { + if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error; + } const lines = existing .split(/\r?\n/) .filter( diff --git a/packages/mosaic/src/credentials/audit-journal.ts b/packages/mosaic/src/credentials/audit-journal.ts index e95431d3..71f75da1 100644 --- a/packages/mosaic/src/credentials/audit-journal.ts +++ b/packages/mosaic/src/credentials/audit-journal.ts @@ -48,6 +48,8 @@ const SAFE_DECISIONS = new Set([ 'token-mint-applied', 'token-binding-stored', 'tea-login-stored', + 'provision-rollback-verified', + 'rotate-rollback-verified', 'token-revoke-applied', 'wire-applied', 'credential-issued', diff --git a/packages/mosaic/src/credentials/credential-binding.dto.ts b/packages/mosaic/src/credentials/credential-binding.dto.ts index ac62bfcc..2d77eadb 100644 --- a/packages/mosaic/src/credentials/credential-binding.dto.ts +++ b/packages/mosaic/src/credentials/credential-binding.dto.ts @@ -7,4 +7,5 @@ export interface CredentialBindingMetadataDto { readonly tokenName: string; readonly scopes: readonly string[]; readonly createdAt: string; + readonly tokenDigest?: string; } diff --git a/packages/mosaic/src/credentials/file-credential-store.ts b/packages/mosaic/src/credentials/file-credential-store.ts index 88ddf769..9f0cb93c 100644 --- a/packages/mosaic/src/credentials/file-credential-store.ts +++ b/packages/mosaic/src/credentials/file-credential-store.ts @@ -1,4 +1,4 @@ -import { randomUUID } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; import { lstatSync } from 'node:fs'; import { open, readdir, rename, unlink } from 'node:fs/promises'; import { join } from 'node:path'; @@ -24,6 +24,10 @@ const bindingSchema = z tokenName: z.string().regex(IDENTITY), scopes: z.array(z.string().regex(/^[a-z]+(?::[a-z]+)?$/)).max(32), createdAt: z.string().datetime(), + tokenDigest: z + .string() + .regex(/^[a-f0-9]{64}$/) + .optional(), }) .strict(); @@ -110,6 +114,28 @@ export class FileCredentialResolver implements CredentialResolver { throw error; } + const bindingPath = join( + this.tokenDirectory, + `${hostConfig.tokenPrefix}-${identity}.binding.json`, + ); + try { + const bindingSnapshot = readRegularFileSecure(bindingPath, { + root: this.tokenDirectory, + maxBytes: 64 * 1024, + }); + const binding = bindingSchema.safeParse(JSON.parse(bindingSnapshot.content.toString('utf8'))); + const digest = createHash('sha256').update(snapshot.content).digest('hex'); + if (!binding.success || binding.data.tokenDigest !== digest) { + throw new CredentialStoreError( + 'credential-generation-mismatch', + 'token and binding metadata are not one committed generation', + ); + } + } catch (error: unknown) { + if (!isMissingFile(error)) throw error; + // Legacy token files predate binding metadata and remain readable until rotated. + } + const permissions = snapshot.mode & 0o777; if (snapshot.uid !== currentUid) { throw new CredentialStoreError( @@ -180,10 +206,24 @@ export class FileCredentialStore { const paths = this.paths(metadata.identity, metadata.estate, metadata.host); ensureManagedDirectory(this.tokenDirectory, this.tokenDirectory); const token = validateSecret(Buffer.from(secret)); - const binding = bindingSchema.parse({ ...metadata, schemaVersion: 1 }); + const binding = bindingSchema.parse({ + ...metadata, + schemaVersion: 1, + tokenDigest: createHash('sha256').update(token).digest('hex'), + }); const suffix = randomUUID(); const tokenTemp = `${paths.token}.${suffix}.tmp`; const bindingTemp = `${paths.binding}.${suffix}.tmp`; + const lockPath = join(this.tokenDirectory, `${paths.prefix}.lock`); + let lock; + try { + lock = await open(lockPath, 'wx', 0o600); + } catch { + throw new CredentialStoreError( + 'conflicting-credential-mutation', + 'another mutation owns the identity lock', + ); + } const tokenHandle = await open(tokenTemp, 'wx', 0o600); const bindingHandle = await open(bindingTemp, 'wx', 0o600); try { @@ -195,9 +235,41 @@ export class FileCredentialStore { await tokenHandle.close(); await bindingHandle.close(); } - await rename(bindingTemp, paths.binding); - await rename(tokenTemp, paths.token); - await syncDirectory(this.tokenDirectory); + try { + await rename(bindingTemp, paths.binding); + await rename(tokenTemp, paths.token); + await syncDirectory(this.tokenDirectory); + } finally { + await lock.close(); + await unlink(lockPath).catch((): void => undefined); + await syncDirectory(this.tokenDirectory); + } + } + + async snapshot( + identity: string, + estate: string, + host: string, + ): Promise< + | { + readonly binding: CredentialBindingMetadataDto; + readonly secret: Uint8Array; + } + | undefined + > { + const binding = await this.readBinding(identity, estate, host); + if (binding === undefined) return undefined; + const resolved = await new FileCredentialResolver( + this.tokenDirectory, + this.estateRegistry, + ).resolve(identity, estate, host); + if (resolved === undefined) { + throw new CredentialStoreError( + 'invalid-binding', + 'binding metadata exists without its token generation', + ); + } + return { binding, secret: new Uint8Array(resolved.secret) }; } async readBinding( diff --git a/packages/mosaic/src/credentials/gitea-provider.ts b/packages/mosaic/src/credentials/gitea-provider.ts index 5492b7f5..db0bef6e 100644 --- a/packages/mosaic/src/credentials/gitea-provider.ts +++ b/packages/mosaic/src/credentials/gitea-provider.ts @@ -812,6 +812,39 @@ export class GiteaLifecycleProviderAdapter }; } + async tokenExists( + authority: ResolvedCredential, + identity: string, + name: string, + ): Promise { + const response = await this.request( + `${this.origin}/api/v1/users/${encodeURIComponent(identity)}/tokens`, + { + method: 'GET', + headers: { + Accept: JSON_CONTENT_TYPE, + Authorization: basicAuthorization(authority), + 'User-Agent': USER_AGENT, + }, + }, + ); + if (!response.ok) { + await boundedBody(response); + throw new CredentialProviderEvidenceError( + 'readback-missing', + 'token absence read-back failed', + ); + } + const parsed = z.array(tokenObjectSchema).safeParse(await jsonObject(response)); + if (!parsed.success) { + throw new CredentialProviderEvidenceError( + 'unexpected-provider-shape', + 'token absence read-back was malformed', + ); + } + return parsed.data.some((token): boolean => token.name === name); + } + async revokeToken(authority: ResolvedCredential, identity: string, name: string): Promise { const response = await this.request( `${this.origin}/api/v1/users/${encodeURIComponent(identity)}/tokens/${encodeURIComponent(name)}`, diff --git a/packages/mosaic/src/credentials/lifecycle.spec.ts b/packages/mosaic/src/credentials/lifecycle.spec.ts index cce91727..a695cf17 100644 --- a/packages/mosaic/src/credentials/lifecycle.spec.ts +++ b/packages/mosaic/src/credentials/lifecycle.spec.ts @@ -81,6 +81,9 @@ function provider(): GiteaLifecycleProvider { }; }, async revokeToken(): Promise {}, + async tokenExists(): Promise { + return false; + }, }; } @@ -108,6 +111,42 @@ describe('credential lifecycle', (): void => { expect(JSON.stringify(result)).not.toContain('minted-token-canary'); }); + it('rolls back a minted token when exact scope read-back disagrees', async (): Promise => { + const { root, store, teaStore } = await fixture(); + let revoked = false; + const lifecycleProvider = provider(); + lifecycleProvider.readToken = async (_authority, _identity, name) => ({ + name, + scopes: ['admin'], + endpoint: 'GET /api/v1/users/seat/tokens', + contentType: 'application/json', + }); + lifecycleProvider.revokeToken = async (): Promise => { + revoked = true; + }; + lifecycleProvider.tokenExists = async (): Promise => false; + + const result = await provisionCredential( + { + identity: 'seat', + estate: 'homelab', + host: 'git.example.invalid', + tokenName: 'mosaic-seat-bad', + scopes: ['write:repository'], + }, + authority, + lifecycleProvider, + store, + teaStore, + { stateRoot: root, actor: 'seat' }, + ); + + expect(result.outcome).toBe('error'); + expect(result.mutation).toBe('none'); + expect(revoked).toBe(true); + await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual([]); + }); + it('revokes at provider before removing the local binding', async (): Promise => { const { root, store, teaStore } = await fixture(); await provisionCredential( @@ -140,4 +179,34 @@ describe('credential lifecycle', (): void => { expect(revoked).toBe(true); await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual([]); }); + + it('preserves the local recovery binding when provider revocation read-back still finds the token', async (): Promise => { + const { root, store, teaStore } = await fixture(); + await provisionCredential( + { + identity: 'seat', + estate: 'homelab', + host: 'git.example.invalid', + tokenName: 'mosaic-seat-1', + scopes: ['write:repository'], + }, + authority, + provider(), + store, + teaStore, + { stateRoot: root, actor: 'seat' }, + ); + const lifecycleProvider = provider(); + lifecycleProvider.tokenExists = async (): Promise => true; + const result = await revokeCredential( + { identity: 'seat', estate: 'homelab', host: 'git.example.invalid' }, + authority, + lifecycleProvider, + store, + { stateRoot: root, actor: 'seat' }, + ); + + expect(result.outcome).toBe('indeterminate'); + await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual(['seat']); + }); }); diff --git a/packages/mosaic/src/credentials/lifecycle.ts b/packages/mosaic/src/credentials/lifecycle.ts index 406ef554..3148e51b 100644 --- a/packages/mosaic/src/credentials/lifecycle.ts +++ b/packages/mosaic/src/credentials/lifecycle.ts @@ -31,6 +31,7 @@ export interface GiteaLifecycleProvider { name: string, ): Promise; revokeToken(authority: ResolvedCredential, identity: string, name: string): Promise; + tokenExists(authority: ResolvedCredential, identity: string, name: string): Promise; } export interface LifecycleRequest { @@ -48,6 +49,7 @@ export interface LifecycleOptions { readonly stateRoot: string; readonly actor: string; readonly now?: () => string; + readonly allowReplace?: boolean; } function lifecycleResult( @@ -114,6 +116,19 @@ export async function provisionCredential( const journal = await openLifecycleJournal('provision', request, options); 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'); + 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', + }); + } try { const identity = await provider.readBasicIdentity(authority); if (identity.login !== request.identity || authority.identity !== request.identity) { @@ -146,17 +161,8 @@ export async function provisionCredential( const expected = [...request.scopes].sort(); const actual = [...readBack.scopes].sort(); if (JSON.stringify(expected) !== JSON.stringify(actual)) { - await journal.seal('indeterminate', 'scope-not-evaluable'); - return lifecycleResult('provision', request, { - outcome: 'indeterminate', - mutation, - code: 'scope-not-evaluable', - message: 'Minted token scope read-back disagreed with the request.', - journalId: journal.journalId(), - auditState: 'sealed', - providerIdentity: identity.login, - token: readBack, - }); + failureCode = 'scope-not-evaluable'; + throw new Error('scope read-back disagreed'); } await store.put( { @@ -173,18 +179,12 @@ export async function provisionCredential( await journal.recordMutation('token-binding-stored'); await teaStore.put(request.identity, request.host, minted.secret); const teaLogin = teaStore.readBack(request.identity, request.host); - if (teaLogin === undefined) { - await journal.seal('indeterminate', 'tea-login-missing'); - return lifecycleResult('provision', request, { - outcome: 'indeterminate', - mutation: 'applied', - code: 'tea-login-missing', - message: 'Token was stored but the host-bound Tea login did not read back.', - journalId: journal.journalId(), - auditState: 'sealed', - providerIdentity: identity.login, - token: readBack, - }); + if ( + teaLogin === undefined || + !teaStore.matchesSecret(request.identity, request.host, minted.secret) + ) { + failureCode = 'tea-login-missing'; + throw new Error('Tea login did not resolve exactly'); } await journal.recordMutation('tea-login-stored'); await journal.recordProviderEvidence({ @@ -206,18 +206,52 @@ export async function provisionCredential( }); } catch (error: unknown) { if (error instanceof CredentialJournalError) throw error; - const code = mutation === 'none' ? 'provider-unavailable' : 'mutation-state-unknown'; - await journal.seal('indeterminate', code); + if (minted === undefined) { + await journal.seal('indeterminate', 'provider-unavailable'); + return lifecycleResult('provision', request, { + outcome: 'indeterminate', + mutation, + code: 'provider-unavailable', + message: 'Provider token mint did not complete.', + journalId: journal.journalId(), + auditState: 'sealed', + }); + } + let rollbackComplete = false; + try { + await provider.revokeToken(authority, request.identity, request.tokenName); + 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); + } + rollbackComplete = true; + await journal.recordMutation('provision-rollback-verified'); + } catch { + rollbackComplete = false; + } finally { + prior?.secret.fill(0); + } + const code = rollbackComplete ? failureCode : 'rollback-incomplete'; + await journal.seal(rollbackComplete ? 'error' : 'indeterminate', code); return lifecycleResult('provision', request, { - outcome: 'indeterminate', - mutation, + outcome: rollbackComplete ? 'error' : 'indeterminate', + mutation: rollbackComplete ? 'none' : mutation, code, - message: 'Provisioning did not produce complete provider and storage evidence.', + message: rollbackComplete + ? 'Provisioning failed and every completed mutation was rolled back.' + : 'Provisioning failed and rollback could not be proven complete.', journalId: journal.journalId(), auditState: 'sealed', }); } finally { minted?.secret.fill(0); + prior?.secret.fill(0); } } @@ -247,6 +281,17 @@ export async function revokeCredential( await provider.revokeToken(authority, request.identity, binding.tokenName); mutation = 'applied'; await journal.recordMutation('token-revoke-applied'); + if (await provider.tokenExists(authority, request.identity, binding.tokenName)) { + await journal.seal('indeterminate', 'revoke-readback-missing'); + return lifecycleResult('revoke', request, { + outcome: 'indeterminate', + mutation, + code: 'revoke-readback-missing', + message: 'Provider still returned the token after revocation acknowledgement.', + journalId: journal.journalId(), + auditState: 'sealed', + }); + } await store.remove(request.identity, request.estate, request.host); await journal.seal('ok', 'revoke-verified'); return lifecycleResult('revoke', request, { diff --git a/packages/mosaic/src/credentials/tea-login-store.ts b/packages/mosaic/src/credentials/tea-login-store.ts index 5fb3cda9..67cb3ee4 100644 --- a/packages/mosaic/src/credentials/tea-login-store.ts +++ b/packages/mosaic/src/credentials/tea-login-store.ts @@ -1,9 +1,10 @@ -import { randomUUID } from 'node:crypto'; +import { randomUUID, timingSafeEqual } from 'node:crypto'; import { open, rename } from 'node:fs/promises'; import { dirname } from 'node:path'; import { parse, stringify } from 'yaml'; import { z } from 'zod'; import { ensureManagedDirectory, readRegularFileSecure } from '../fleet/secure-file.js'; +import type { ResolvedCredential } from './credential-provider.dto.js'; const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/; @@ -91,6 +92,64 @@ export class TeaLoginStore { await rename(temp, this.configPath); } + resolve(identity: string, estate: string, host: string): ResolvedCredential | 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; + } + const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8'))); + if (!decoded.success) return undefined; + const matches = decoded.data.logins.filter( + (login): boolean => + login.name === identity && login.url === `https://${host}` && login.user === identity, + ); + if (matches.length !== 1 || matches[0] === undefined) return undefined; + return Object.freeze({ + identity, + estate, + host, + resolutionId: randomUUID(), + secret: new TextEncoder().encode(matches[0].token), + }); + } + + matchesSecret(identity: string, host: string, secret: Uint8Array): boolean { + const resolved = this.resolve(identity, 'binding-check', host); + if (resolved === undefined || resolved.secret.byteLength !== secret.byteLength) return false; + return timingSafeEqual(Buffer.from(resolved.secret), Buffer.from(secret)); + } + + async remove(identity: string, host: string): Promise { + const directory = dirname(this.configPath); + const snapshot = readRegularFileSecure(this.configPath, { + root: directory, + maxBytes: 1024 * 1024, + }); + const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8'))); + if (!decoded.success) { + throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation'); + } + const logins = decoded.data.logins.filter( + (login): boolean => !(login.name === identity && login.url === `https://${host}`), + ); + const temp = `${this.configPath}.${randomUUID()}.tmp`; + const handle = await open(temp, 'wx', 0o600); + try { + await handle.writeFile(stringify({ logins }), 'utf8'); + await handle.sync(); + } finally { + await handle.close(); + } + await rename(temp, this.configPath); + } + readBack( identity: string, host: string,