From 9512adcec2c630761b3422bc32438faf0482a800 Mon Sep 17 00:00:00 2001 From: be-coder-06 Date: Wed, 5 Aug 2026 13:27:19 -0500 Subject: [PATCH] fix(mosaic): preserve credential transaction evidence --- packages/mosaic/src/commands/cred.ts | 89 ++++++++----- .../src/credentials/file-credential-store.ts | 8 ++ .../mosaic/src/credentials/validate.spec.ts | 24 +++- packages/mosaic/src/credentials/validate.ts | 119 +++++------------- 4 files changed, 120 insertions(+), 120 deletions(-) diff --git a/packages/mosaic/src/commands/cred.ts b/packages/mosaic/src/commands/cred.ts index c7c135a1..d6255b7a 100644 --- a/packages/mosaic/src/commands/cred.ts +++ b/packages/mosaic/src/commands/cred.ts @@ -1,3 +1,4 @@ +import { timingSafeEqual } from 'node:crypto'; import { fstatSync, writeSync } from 'node:fs'; import { open, rename } from 'node:fs/promises'; import { homedir } from 'node:os'; @@ -552,7 +553,18 @@ export async function executeCredentialRotate( audit: { journalId: journal.journalId(), state: 'sealed' }, }; } - await journal.recordMutation('token-mint-applied'); + try { + await journal.recordMutation('token-mint-applied'); + } catch (error: unknown) { + await context.provider.revokeToken(authority, identity, options.tokenName); + 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); + old.secret.fill(0); + throw error; + } try { await context.provider.revokeToken(authority, identity, old.binding.tokenName); if (await context.provider.tokenExists(authority, identity, old.binding.tokenName)) { @@ -560,20 +572,14 @@ export async function executeCredentialRotate( } 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'); + await journal.seal('indeterminate', 'old-credential-state-unknown'); 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.', + mutation: 'unknown', + code: 'old-credential-state-unknown', + message: + 'The verified replacement remains canonical, but old-token revocation state is unknown.', audit: { journalId: journal.journalId(), state: 'sealed' }, }); } @@ -707,16 +713,12 @@ export async function executeCredentialGet( }); await journal.recordIntent('get-requested'); try { - if ( - process.env['MOSAIC_AGENT_NAME'] === undefined || - process.env['MOSAIC_AGENT_NAME'] !== identity || - options.actor !== identity - ) { + if (options.authorityFd === undefined || options.actor !== identity) { await journal.seal('refused', 'provider-identity-mismatch'); return localLifecycleResult('get', identity, options, { outcome: 'refused', code: 'provider-identity-mismatch', - message: 'Runtime fleet identity, explicit actor, and requested identity must match.', + message: 'Protected authority, explicit actor, and requested identity must match.', audit: { journalId: journal.journalId(), state: 'sealed' }, }); } @@ -731,11 +733,27 @@ export async function executeCredentialGet( throw new Error('unsafe output fd'); } const context = await lifecycleContext(options); + const authority = await lifecycleAuthority(identity, options); + const providerIdentity = await context.provider.readIdentity(authority); + if (providerIdentity.login !== identity) { + await journal.seal('refused', 'provider-identity-mismatch'); + return localLifecycleResult('get', identity, options, { + outcome: 'refused', + code: 'provider-identity-mismatch', + message: + 'Provider read-back did not bind the protected authority to the requested identity.', + audit: { journalId: journal.journalId(), state: 'sealed' }, + }); + } const resolved = await new FileCredentialResolver( lifecycleLocations(options).tokenDirectory, context.registry, ).resolve(identity, options.estate, options.host); - if (resolved === undefined) { + if ( + resolved === undefined || + authority.secret.byteLength !== resolved.secret.byteLength || + !timingSafeEqual(Buffer.from(authority.secret), Buffer.from(resolved.secret)) + ) { await journal.seal('refused', 'no-token-for-identity'); return localLifecycleResult('get', identity, options, { outcome: 'refused', @@ -862,6 +880,17 @@ type PrintableCredentialResult = Pick< 'operation' | 'outcome' | 'exitCode' | 'reason' >; +function withParentMosaicHome( + parent: Command, + options: T, +): T { + const inherited = parent.opts<{ mosaicHome?: string }>(); + return { + ...options, + ...(inherited.mosaicHome === undefined ? {} : { mosaicHome: inherited.mosaicHome }), + }; +} + function printCredentialResult(result: PrintableCredentialResult, json: boolean): void { if (json) { process.stdout.write(`${JSON.stringify(result)}\n`); @@ -897,7 +926,10 @@ export function registerCredentialCommand(parent: Command): void { .option('--state-dir ', 'Durable credential journal root') .option('--json', 'Emit one machine result object') .action(async (identity: string, options: CredentialLifecycleCommandOptions): Promise => { - const result = await executeCredentialProvision(identity, options); + const result = await executeCredentialProvision( + identity, + withParentMosaicHome(cred, options), + ); printCredentialResult(result, options.json === true); process.exitCode = result.exitCode; }); @@ -912,7 +944,7 @@ export function registerCredentialCommand(parent: Command): void { .option('--state-dir ', 'Durable credential journal root') .option('--json', 'Emit one machine result object') .action(async (identity: string, options: CredentialLifecycleCommandOptions): Promise => { - const result = await executeCredentialWire(identity, options); + const result = await executeCredentialWire(identity, withParentMosaicHome(cred, options)); printCredentialResult(result, options.json === true); process.exitCode = result.exitCode; }); @@ -922,14 +954,15 @@ export function registerCredentialCommand(parent: Command): void { .description('Emit one exact credential only to a protected inherited fd') .requiredOption('--estate ', 'Explicit target estate') .requiredOption('--host ', 'Explicit provider host') - .requiredOption('--actor ', 'Explicit audit actor') + .requiredOption('--actor ', 'Explicit protected authority identity') + .requiredOption('--authority-fd ', 'Inherited protected credential authority fd') .requiredOption('--output-fd ', 'Protected inherited credential output fd') .option('--registry ', 'Strict non-secret estate registry') .option('--token-dir ', 'Governed token directory') .option('--state-dir ', 'Durable credential journal root') .option('--json', 'Emit one non-secret machine result object') .action(async (identity: string, options: CredentialLifecycleCommandOptions): Promise => { - const result = await executeCredentialGet(identity, options); + const result = await executeCredentialGet(identity, withParentMosaicHome(cred, options)); printCredentialResult(result, options.json === true); process.exitCode = result.exitCode; }); @@ -949,7 +982,7 @@ export function registerCredentialCommand(parent: Command): void { .option('--state-dir ', 'Durable credential journal root') .option('--json', 'Emit one machine result object') .action(async (identity: string, options: CredentialLifecycleCommandOptions): Promise => { - const result = await executeCredentialRotate(identity, options); + const result = await executeCredentialRotate(identity, withParentMosaicHome(cred, options)); printCredentialResult(result, options.json === true); process.exitCode = result.exitCode; }); @@ -966,7 +999,7 @@ export function registerCredentialCommand(parent: Command): void { .option('--state-dir ', 'Durable credential journal root') .option('--json', 'Emit one machine result object') .action(async (identity: string, options: CredentialLifecycleCommandOptions): Promise => { - const result = await executeCredentialRevoke(identity, options); + const result = await executeCredentialRevoke(identity, withParentMosaicHome(cred, options)); printCredentialResult(result, options.json === true); process.exitCode = result.exitCode; }); @@ -983,7 +1016,7 @@ export function registerCredentialCommand(parent: Command): void { .option('--state-dir ', 'Durable credential journal root') .option('--json', 'Emit one machine result object') .action(async (options: CredentialLifecycleCommandOptions): Promise => { - const result = await executeCredentialList(options); + const result = await executeCredentialList(withParentMosaicHome(cred, options)); printCredentialResult(result, options.json === true); process.exitCode = result.exitCode; }); @@ -999,7 +1032,7 @@ export function registerCredentialCommand(parent: Command): void { .option('--state-dir ', 'Durable credential journal root') .option('--json', 'Emit one machine result object') .action(async (options: CredentialLifecycleCommandOptions): Promise => { - const result = await executeCredentialAudit(options); + const result = await executeCredentialAudit(withParentMosaicHome(cred, options)); printCredentialResult(result, options.json === true); process.exitCode = result.exitCode; }); @@ -1066,7 +1099,7 @@ export function registerCredentialCommand(parent: Command): void { .option('--json', 'Emit one machine result object') .action(async (identity: string, options: CredentialValidateCommandOptions): Promise => { const result = await executeCredentialValidate(identity, { - ...options, + ...withParentMosaicHome(cred, options), require: 'read', operation: 'whoami', }); diff --git a/packages/mosaic/src/credentials/file-credential-store.ts b/packages/mosaic/src/credentials/file-credential-store.ts index cbb9d7fe..569e525c 100644 --- a/packages/mosaic/src/credentials/file-credential-store.ts +++ b/packages/mosaic/src/credentials/file-credential-store.ts @@ -343,6 +343,14 @@ export class FileCredentialStore { if (!parsed.success) { throw new CredentialStoreError('invalid-binding', 'credential envelope is malformed'); } + const verified = await new FileCredentialResolver( + this.tokenDirectory, + this.estateRegistry, + ).resolve(identity, estate, host); + if (verified === undefined) { + throw new CredentialStoreError('invalid-binding', 'credential envelope was not resolvable'); + } + verified.secret.fill(0); return { schemaVersion: 1, identity: parsed.data.identity, diff --git a/packages/mosaic/src/credentials/validate.spec.ts b/packages/mosaic/src/credentials/validate.spec.ts index fe945f35..238e114c 100644 --- a/packages/mosaic/src/credentials/validate.spec.ts +++ b/packages/mosaic/src/credentials/validate.spec.ts @@ -239,11 +239,19 @@ describe('Gitea read validation', (): void => { describe('principal-bound Gitea write validation contract v1.1', (): void => { it('confirms write capability when identity is scope-forbidden without exposing the internal reason', async (): Promise => { const observed = fixture(); - observed.dependencies.provider.readIdentity = async (): Promise => { - throw new CredentialProviderEvidenceError( - 'identity-read-forbidden', - 'identity endpoint scope forbidden', - ); + const readIdentity = observed.dependencies.provider.readIdentity.bind( + observed.dependencies.provider, + ); + observed.dependencies.provider.readIdentity = async ( + resolved, + ): Promise => { + if (resolved.identity === SUBJECT) { + throw new CredentialProviderEvidenceError( + 'identity-read-forbidden', + 'identity endpoint scope forbidden', + ); + } + return readIdentity(resolved); }; const result = await evaluateGiteaWriteValidation( { @@ -259,7 +267,11 @@ describe('principal-bound Gitea write validation contract v1.1', (): void => { expect(result.outcome).toBe('indeterminate'); expect(result.reason.code).toBe('identity-not-measured'); expect(result.evidence.repositoryPermission?.effective).toBe('write'); - expect(observed.receivePackHandles).toEqual([expect.objectContaining({ identity: SUBJECT })]); + expect(observed.receivePackHandles).toEqual([ + expect.objectContaining({ identity: SUBJECT }), + expect.objectContaining({ identity: CONTROL }), + undefined, + ]); }); it('uses one immutable subject credential handle for identity, permission, and receive-pack', async (): Promise => { const { result, observed } = await validate(); diff --git a/packages/mosaic/src/credentials/validate.ts b/packages/mosaic/src/credentials/validate.ts index 2affcd5b..a5932f3f 100644 --- a/packages/mosaic/src/credentials/validate.ts +++ b/packages/mosaic/src/credentials/validate.ts @@ -1,7 +1,6 @@ import { CredentialProviderEvidenceError } from './gitea-provider.js'; import type { CredentialValidationDependencies, - GiteaCredentialProvider, GiteaReadValidationRequestDto, GiteaWriteValidationRequestDto, ResolvedCredential, @@ -150,23 +149,9 @@ async function resolveCredential( return dependencies.resolver.resolve(identity, request.estate, request.host); } -async function readSubjectEvidence( - request: GiteaWriteValidationRequestDto, - resolved: ResolvedCredential, - provider: GiteaCredentialProvider, -): Promise<{ - readonly identity: ProviderIdentityEvidenceDto; - readonly permission: RepositoryPermissionEvidenceDto; - readonly receivePack: ReceivePackEvidenceDto; -}> { - const identity = await provider.readIdentity(resolved); - const permission = await provider.readRepositoryPermission(resolved, request.repo); - const receivePack = await provider.probeReceivePack(resolved, request.repo); - return { identity, permission, receivePack }; -} - function successfulEvidence( - subjectIdentity: ProviderIdentityEvidenceDto, + subjectLogin: string, + subjectIdentity: ProviderIdentityEvidenceDto | null, subjectPermission: RepositoryPermissionEvidenceDto, subjectReceivePack: ReceivePackEvidenceDto, controlIdentity: ProviderIdentityEvidenceDto, @@ -176,7 +161,7 @@ function successfulEvidence( const writeDifferential: WriteDifferentialEvidenceDto = { state: 'can-write', credentialBinding: 'same-resolution', - transportPrincipal: subjectIdentity.login, + transportPrincipal: subjectLogin, authenticatedReceivePack: 'advertised', readOnlyControl: { identity: controlIdentity.login, @@ -353,69 +338,30 @@ async function evaluateGiteaWriteValidationUnsafe( }); } - let subjectEvidence: Awaited>; + let subjectIdentity: ProviderIdentityEvidenceDto | null = null; try { - subjectEvidence = await readSubjectEvidence(request, resolved, dependencies.provider); + subjectIdentity = await dependencies.provider.readIdentity(resolved); } catch (error: unknown) { if ( - error instanceof CredentialProviderEvidenceError && - error.code === 'identity-read-forbidden' + !(error instanceof CredentialProviderEvidenceError) || + error.code !== 'identity-read-forbidden' ) { - const permission = await dependencies.provider.readRepositoryPermission( - resolved, - request.repo, - ); - const receivePack = await dependencies.provider.probeReceivePack(resolved, request.repo); - const evidence: CredentialValidationEvidenceDto = { - providerIdentity: null, - tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED, - repositoryPermission: permission, - writeDifferential: null, - }; - if (permission.effective === 'none' || permission.effective === 'read') { - return refused( - request, - { - code: 'permission-denied', - message: 'The in-scope provider object denies required write capability.', - }, - evidence, - ); - } - if ( - receivePack.principal !== request.identity || - receivePack.resolutionId !== resolved.resolutionId || - !advertised(receivePack) - ) { - return indeterminate( - request, - { - code: 'permission-evidence-disagrees', - message: 'In-scope repository and write transport evidence did not agree.', - }, - evidence, - ); - } - return indeterminate( - request, - { - code: 'identity-not-measured', - message: - 'Write capability was confirmed, but identity was not measured because this least-privilege token cannot read /user.', - }, - evidence, - ); + throw error; } - throw error; } + const subjectPermission = await dependencies.provider.readRepositoryPermission( + resolved, + request.repo, + ); + const subjectReceivePack = await dependencies.provider.probeReceivePack(resolved, request.repo); const baseEvidence: CredentialValidationEvidenceDto = { - providerIdentity: subjectEvidence.identity, + providerIdentity: subjectIdentity, tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED, - repositoryPermission: subjectEvidence.permission, + repositoryPermission: subjectPermission, writeDifferential: null, }; - if (!identityContentTypeValid(subjectEvidence.identity)) { + if (subjectIdentity !== null && !identityContentTypeValid(subjectIdentity)) { return indeterminate( request, { @@ -425,7 +371,7 @@ async function evaluateGiteaWriteValidationUnsafe( baseEvidence, ); } - if (subjectEvidence.identity.login !== request.identity) { + if (subjectIdentity !== null && subjectIdentity.login !== request.identity) { return refused( request, { @@ -435,7 +381,7 @@ async function evaluateGiteaWriteValidationUnsafe( baseEvidence, ); } - if (!permissionContentTypeValid(subjectEvidence.permission)) { + if (!permissionContentTypeValid(subjectPermission)) { return indeterminate( request, { @@ -445,7 +391,7 @@ async function evaluateGiteaWriteValidationUnsafe( baseEvidence, ); } - if (request.requiredPermission === 'admin' && subjectEvidence.permission.effective !== 'admin') { + if (request.requiredPermission === 'admin' && subjectPermission.effective !== 'admin') { return refused( request, { @@ -455,10 +401,7 @@ async function evaluateGiteaWriteValidationUnsafe( baseEvidence, ); } - if ( - subjectEvidence.permission.effective === 'read' || - subjectEvidence.permission.effective === 'none' - ) { + if (subjectPermission.effective === 'read' || subjectPermission.effective === 'none') { return refused( request, { @@ -469,8 +412,8 @@ async function evaluateGiteaWriteValidationUnsafe( ); } if ( - subjectEvidence.receivePack.principal !== request.identity || - subjectEvidence.receivePack.resolutionId !== resolved.resolutionId + subjectReceivePack.principal !== request.identity || + subjectReceivePack.resolutionId !== resolved.resolutionId ) { return indeterminate( request, @@ -481,7 +424,7 @@ async function evaluateGiteaWriteValidationUnsafe( baseEvidence, ); } - if (!advertised(subjectEvidence.receivePack)) { + if (!advertised(subjectReceivePack)) { return indeterminate( request, { @@ -544,17 +487,21 @@ async function evaluateGiteaWriteValidationUnsafe( } const evidence = successfulEvidence( - subjectEvidence.identity, - subjectEvidence.permission, - subjectEvidence.receivePack, + request.identity, + subjectIdentity, + subjectPermission, + subjectReceivePack, controlIdentity, controlPermission, controlReceivePack, ); return result(request, { - outcome: 'ok', - code: 'validation-verified', - message: 'Every required provider evidence layer agreed.', + outcome: subjectIdentity === null ? 'indeterminate' : 'ok', + code: subjectIdentity === null ? 'identity-not-measured' : 'validation-verified', + message: + subjectIdentity === null + ? 'Write capability and both controls were confirmed, but identity was not measured because this least-privilege token cannot read /user.' + : 'Every required provider evidence layer agreed.', evidence, }); }