fix(mosaic): preserve credential transaction evidence

This commit is contained in:
2026-08-05 13:27:19 -05:00
parent 6ca8758f8f
commit b309896067
4 changed files with 120 additions and 120 deletions
+60 -27
View File
@@ -1,3 +1,4 @@
import { timingSafeEqual } from 'node:crypto';
import { fstatSync, writeSync } from 'node:fs'; import { fstatSync, writeSync } from 'node:fs';
import { open, rename } from 'node:fs/promises'; import { open, rename } from 'node:fs/promises';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
@@ -552,7 +553,18 @@ export async function executeCredentialRotate(
audit: { journalId: journal.journalId(), state: 'sealed' }, audit: { journalId: journal.journalId(), state: 'sealed' },
}; };
} }
try {
await journal.recordMutation('token-mint-applied'); 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 { try {
await context.provider.revokeToken(authority, identity, old.binding.tokenName); await context.provider.revokeToken(authority, identity, old.binding.tokenName);
if (await context.provider.tokenExists(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'); await journal.recordMutation('token-revoke-applied');
} catch { } catch {
await context.provider.revokeToken(authority, identity, options.tokenName); await journal.seal('indeterminate', 'old-credential-state-unknown');
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); old.secret.fill(0);
return localLifecycleResult('rotate', identity, options, { return localLifecycleResult('rotate', identity, options, {
outcome: 'indeterminate', outcome: 'indeterminate',
mutation: 'none', mutation: 'unknown',
code: 'old-credential-preserved', code: 'old-credential-state-unknown',
message: 'Replacement failed; the previous credential remains the canonical binding.', message:
'The verified replacement remains canonical, but old-token revocation state is unknown.',
audit: { journalId: journal.journalId(), state: 'sealed' }, audit: { journalId: journal.journalId(), state: 'sealed' },
}); });
} }
@@ -707,16 +713,12 @@ export async function executeCredentialGet(
}); });
await journal.recordIntent('get-requested'); await journal.recordIntent('get-requested');
try { try {
if ( if (options.authorityFd === undefined || options.actor !== identity) {
process.env['MOSAIC_AGENT_NAME'] === undefined ||
process.env['MOSAIC_AGENT_NAME'] !== identity ||
options.actor !== identity
) {
await journal.seal('refused', 'provider-identity-mismatch'); await journal.seal('refused', 'provider-identity-mismatch');
return localLifecycleResult('get', identity, options, { return localLifecycleResult('get', identity, options, {
outcome: 'refused', outcome: 'refused',
code: 'provider-identity-mismatch', 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' }, audit: { journalId: journal.journalId(), state: 'sealed' },
}); });
} }
@@ -731,11 +733,27 @@ export async function executeCredentialGet(
throw new Error('unsafe output fd'); throw new Error('unsafe output fd');
} }
const context = await lifecycleContext(options); 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( const resolved = await new FileCredentialResolver(
lifecycleLocations(options).tokenDirectory, lifecycleLocations(options).tokenDirectory,
context.registry, context.registry,
).resolve(identity, options.estate, options.host); ).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'); await journal.seal('refused', 'no-token-for-identity');
return localLifecycleResult('get', identity, options, { return localLifecycleResult('get', identity, options, {
outcome: 'refused', outcome: 'refused',
@@ -862,6 +880,17 @@ type PrintableCredentialResult = Pick<
'operation' | 'outcome' | 'exitCode' | 'reason' 'operation' | 'outcome' | 'exitCode' | 'reason'
>; >;
function withParentMosaicHome<T extends { readonly mosaicHome?: string }>(
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 { function printCredentialResult(result: PrintableCredentialResult, json: boolean): void {
if (json) { if (json) {
process.stdout.write(`${JSON.stringify(result)}\n`); process.stdout.write(`${JSON.stringify(result)}\n`);
@@ -897,7 +926,10 @@ export function registerCredentialCommand(parent: Command): void {
.option('--state-dir <path>', 'Durable credential journal root') .option('--state-dir <path>', 'Durable credential journal root')
.option('--json', 'Emit one machine result object') .option('--json', 'Emit one machine result object')
.action(async (identity: string, options: CredentialLifecycleCommandOptions): Promise<void> => { .action(async (identity: string, options: CredentialLifecycleCommandOptions): Promise<void> => {
const result = await executeCredentialProvision(identity, options); const result = await executeCredentialProvision(
identity,
withParentMosaicHome(cred, options),
);
printCredentialResult(result, options.json === true); printCredentialResult(result, options.json === true);
process.exitCode = result.exitCode; process.exitCode = result.exitCode;
}); });
@@ -912,7 +944,7 @@ export function registerCredentialCommand(parent: Command): void {
.option('--state-dir <path>', 'Durable credential journal root') .option('--state-dir <path>', 'Durable credential journal root')
.option('--json', 'Emit one machine result object') .option('--json', 'Emit one machine result object')
.action(async (identity: string, options: CredentialLifecycleCommandOptions): Promise<void> => { .action(async (identity: string, options: CredentialLifecycleCommandOptions): Promise<void> => {
const result = await executeCredentialWire(identity, options); const result = await executeCredentialWire(identity, withParentMosaicHome(cred, options));
printCredentialResult(result, options.json === true); printCredentialResult(result, options.json === true);
process.exitCode = result.exitCode; 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') .description('Emit one exact credential only to a protected inherited fd')
.requiredOption('--estate <estate>', 'Explicit target estate') .requiredOption('--estate <estate>', 'Explicit target estate')
.requiredOption('--host <host>', 'Explicit provider host') .requiredOption('--host <host>', 'Explicit provider host')
.requiredOption('--actor <identity>', 'Explicit audit actor') .requiredOption('--actor <identity>', 'Explicit protected authority identity')
.requiredOption('--authority-fd <fd>', 'Inherited protected credential authority fd')
.requiredOption('--output-fd <fd>', 'Protected inherited credential output fd') .requiredOption('--output-fd <fd>', 'Protected inherited credential output fd')
.option('--registry <path>', 'Strict non-secret estate registry') .option('--registry <path>', 'Strict non-secret estate registry')
.option('--token-dir <path>', 'Governed token directory') .option('--token-dir <path>', 'Governed token directory')
.option('--state-dir <path>', 'Durable credential journal root') .option('--state-dir <path>', 'Durable credential journal root')
.option('--json', 'Emit one non-secret machine result object') .option('--json', 'Emit one non-secret machine result object')
.action(async (identity: string, options: CredentialLifecycleCommandOptions): Promise<void> => { .action(async (identity: string, options: CredentialLifecycleCommandOptions): Promise<void> => {
const result = await executeCredentialGet(identity, options); const result = await executeCredentialGet(identity, withParentMosaicHome(cred, options));
printCredentialResult(result, options.json === true); printCredentialResult(result, options.json === true);
process.exitCode = result.exitCode; process.exitCode = result.exitCode;
}); });
@@ -949,7 +982,7 @@ export function registerCredentialCommand(parent: Command): void {
.option('--state-dir <path>', 'Durable credential journal root') .option('--state-dir <path>', 'Durable credential journal root')
.option('--json', 'Emit one machine result object') .option('--json', 'Emit one machine result object')
.action(async (identity: string, options: CredentialLifecycleCommandOptions): Promise<void> => { .action(async (identity: string, options: CredentialLifecycleCommandOptions): Promise<void> => {
const result = await executeCredentialRotate(identity, options); const result = await executeCredentialRotate(identity, withParentMosaicHome(cred, options));
printCredentialResult(result, options.json === true); printCredentialResult(result, options.json === true);
process.exitCode = result.exitCode; process.exitCode = result.exitCode;
}); });
@@ -966,7 +999,7 @@ export function registerCredentialCommand(parent: Command): void {
.option('--state-dir <path>', 'Durable credential journal root') .option('--state-dir <path>', 'Durable credential journal root')
.option('--json', 'Emit one machine result object') .option('--json', 'Emit one machine result object')
.action(async (identity: string, options: CredentialLifecycleCommandOptions): Promise<void> => { .action(async (identity: string, options: CredentialLifecycleCommandOptions): Promise<void> => {
const result = await executeCredentialRevoke(identity, options); const result = await executeCredentialRevoke(identity, withParentMosaicHome(cred, options));
printCredentialResult(result, options.json === true); printCredentialResult(result, options.json === true);
process.exitCode = result.exitCode; process.exitCode = result.exitCode;
}); });
@@ -983,7 +1016,7 @@ export function registerCredentialCommand(parent: Command): void {
.option('--state-dir <path>', 'Durable credential journal root') .option('--state-dir <path>', 'Durable credential journal root')
.option('--json', 'Emit one machine result object') .option('--json', 'Emit one machine result object')
.action(async (options: CredentialLifecycleCommandOptions): Promise<void> => { .action(async (options: CredentialLifecycleCommandOptions): Promise<void> => {
const result = await executeCredentialList(options); const result = await executeCredentialList(withParentMosaicHome(cred, options));
printCredentialResult(result, options.json === true); printCredentialResult(result, options.json === true);
process.exitCode = result.exitCode; process.exitCode = result.exitCode;
}); });
@@ -999,7 +1032,7 @@ export function registerCredentialCommand(parent: Command): void {
.option('--state-dir <path>', 'Durable credential journal root') .option('--state-dir <path>', 'Durable credential journal root')
.option('--json', 'Emit one machine result object') .option('--json', 'Emit one machine result object')
.action(async (options: CredentialLifecycleCommandOptions): Promise<void> => { .action(async (options: CredentialLifecycleCommandOptions): Promise<void> => {
const result = await executeCredentialAudit(options); const result = await executeCredentialAudit(withParentMosaicHome(cred, options));
printCredentialResult(result, options.json === true); printCredentialResult(result, options.json === true);
process.exitCode = result.exitCode; process.exitCode = result.exitCode;
}); });
@@ -1066,7 +1099,7 @@ export function registerCredentialCommand(parent: Command): void {
.option('--json', 'Emit one machine result object') .option('--json', 'Emit one machine result object')
.action(async (identity: string, options: CredentialValidateCommandOptions): Promise<void> => { .action(async (identity: string, options: CredentialValidateCommandOptions): Promise<void> => {
const result = await executeCredentialValidate(identity, { const result = await executeCredentialValidate(identity, {
...options, ...withParentMosaicHome(cred, options),
require: 'read', require: 'read',
operation: 'whoami', operation: 'whoami',
}); });
@@ -343,6 +343,14 @@ export class FileCredentialStore {
if (!parsed.success) { if (!parsed.success) {
throw new CredentialStoreError('invalid-binding', 'credential envelope is malformed'); 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 { return {
schemaVersion: 1, schemaVersion: 1,
identity: parsed.data.identity, identity: parsed.data.identity,
@@ -239,11 +239,19 @@ describe('Gitea read validation', (): void => {
describe('principal-bound Gitea write validation contract v1.1', (): 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<void> => { it('confirms write capability when identity is scope-forbidden without exposing the internal reason', async (): Promise<void> => {
const observed = fixture(); const observed = fixture();
observed.dependencies.provider.readIdentity = async (): Promise<ProviderIdentityEvidence> => { const readIdentity = observed.dependencies.provider.readIdentity.bind(
observed.dependencies.provider,
);
observed.dependencies.provider.readIdentity = async (
resolved,
): Promise<ProviderIdentityEvidence> => {
if (resolved.identity === SUBJECT) {
throw new CredentialProviderEvidenceError( throw new CredentialProviderEvidenceError(
'identity-read-forbidden', 'identity-read-forbidden',
'identity endpoint scope forbidden', 'identity endpoint scope forbidden',
); );
}
return readIdentity(resolved);
}; };
const result = await evaluateGiteaWriteValidation( 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.outcome).toBe('indeterminate');
expect(result.reason.code).toBe('identity-not-measured'); expect(result.reason.code).toBe('identity-not-measured');
expect(result.evidence.repositoryPermission?.effective).toBe('write'); 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<void> => { it('uses one immutable subject credential handle for identity, permission, and receive-pack', async (): Promise<void> => {
const { result, observed } = await validate(); const { result, observed } = await validate();
+32 -85
View File
@@ -1,7 +1,6 @@
import { CredentialProviderEvidenceError } from './gitea-provider.js'; import { CredentialProviderEvidenceError } from './gitea-provider.js';
import type { import type {
CredentialValidationDependencies, CredentialValidationDependencies,
GiteaCredentialProvider,
GiteaReadValidationRequestDto, GiteaReadValidationRequestDto,
GiteaWriteValidationRequestDto, GiteaWriteValidationRequestDto,
ResolvedCredential, ResolvedCredential,
@@ -150,23 +149,9 @@ async function resolveCredential(
return dependencies.resolver.resolve(identity, request.estate, request.host); 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( function successfulEvidence(
subjectIdentity: ProviderIdentityEvidenceDto, subjectLogin: string,
subjectIdentity: ProviderIdentityEvidenceDto | null,
subjectPermission: RepositoryPermissionEvidenceDto, subjectPermission: RepositoryPermissionEvidenceDto,
subjectReceivePack: ReceivePackEvidenceDto, subjectReceivePack: ReceivePackEvidenceDto,
controlIdentity: ProviderIdentityEvidenceDto, controlIdentity: ProviderIdentityEvidenceDto,
@@ -176,7 +161,7 @@ function successfulEvidence(
const writeDifferential: WriteDifferentialEvidenceDto = { const writeDifferential: WriteDifferentialEvidenceDto = {
state: 'can-write', state: 'can-write',
credentialBinding: 'same-resolution', credentialBinding: 'same-resolution',
transportPrincipal: subjectIdentity.login, transportPrincipal: subjectLogin,
authenticatedReceivePack: 'advertised', authenticatedReceivePack: 'advertised',
readOnlyControl: { readOnlyControl: {
identity: controlIdentity.login, identity: controlIdentity.login,
@@ -353,69 +338,30 @@ async function evaluateGiteaWriteValidationUnsafe(
}); });
} }
let subjectEvidence: Awaited<ReturnType<typeof readSubjectEvidence>>; let subjectIdentity: ProviderIdentityEvidenceDto | null = null;
try { try {
subjectEvidence = await readSubjectEvidence(request, resolved, dependencies.provider); subjectIdentity = await dependencies.provider.readIdentity(resolved);
} catch (error: unknown) { } catch (error: unknown) {
if ( if (
error instanceof CredentialProviderEvidenceError && !(error instanceof CredentialProviderEvidenceError) ||
error.code === 'identity-read-forbidden' error.code !== 'identity-read-forbidden'
) { ) {
const permission = await dependencies.provider.readRepositoryPermission( throw error;
}
}
const subjectPermission = await dependencies.provider.readRepositoryPermission(
resolved, resolved,
request.repo, request.repo,
); );
const receivePack = await dependencies.provider.probeReceivePack(resolved, request.repo); const subjectReceivePack = 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;
}
const baseEvidence: CredentialValidationEvidenceDto = { const baseEvidence: CredentialValidationEvidenceDto = {
providerIdentity: subjectEvidence.identity, providerIdentity: subjectIdentity,
tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED, tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED,
repositoryPermission: subjectEvidence.permission, repositoryPermission: subjectPermission,
writeDifferential: null, writeDifferential: null,
}; };
if (!identityContentTypeValid(subjectEvidence.identity)) { if (subjectIdentity !== null && !identityContentTypeValid(subjectIdentity)) {
return indeterminate( return indeterminate(
request, request,
{ {
@@ -425,7 +371,7 @@ async function evaluateGiteaWriteValidationUnsafe(
baseEvidence, baseEvidence,
); );
} }
if (subjectEvidence.identity.login !== request.identity) { if (subjectIdentity !== null && subjectIdentity.login !== request.identity) {
return refused( return refused(
request, request,
{ {
@@ -435,7 +381,7 @@ async function evaluateGiteaWriteValidationUnsafe(
baseEvidence, baseEvidence,
); );
} }
if (!permissionContentTypeValid(subjectEvidence.permission)) { if (!permissionContentTypeValid(subjectPermission)) {
return indeterminate( return indeterminate(
request, request,
{ {
@@ -445,7 +391,7 @@ async function evaluateGiteaWriteValidationUnsafe(
baseEvidence, baseEvidence,
); );
} }
if (request.requiredPermission === 'admin' && subjectEvidence.permission.effective !== 'admin') { if (request.requiredPermission === 'admin' && subjectPermission.effective !== 'admin') {
return refused( return refused(
request, request,
{ {
@@ -455,10 +401,7 @@ async function evaluateGiteaWriteValidationUnsafe(
baseEvidence, baseEvidence,
); );
} }
if ( if (subjectPermission.effective === 'read' || subjectPermission.effective === 'none') {
subjectEvidence.permission.effective === 'read' ||
subjectEvidence.permission.effective === 'none'
) {
return refused( return refused(
request, request,
{ {
@@ -469,8 +412,8 @@ async function evaluateGiteaWriteValidationUnsafe(
); );
} }
if ( if (
subjectEvidence.receivePack.principal !== request.identity || subjectReceivePack.principal !== request.identity ||
subjectEvidence.receivePack.resolutionId !== resolved.resolutionId subjectReceivePack.resolutionId !== resolved.resolutionId
) { ) {
return indeterminate( return indeterminate(
request, request,
@@ -481,7 +424,7 @@ async function evaluateGiteaWriteValidationUnsafe(
baseEvidence, baseEvidence,
); );
} }
if (!advertised(subjectEvidence.receivePack)) { if (!advertised(subjectReceivePack)) {
return indeterminate( return indeterminate(
request, request,
{ {
@@ -544,17 +487,21 @@ async function evaluateGiteaWriteValidationUnsafe(
} }
const evidence = successfulEvidence( const evidence = successfulEvidence(
subjectEvidence.identity, request.identity,
subjectEvidence.permission, subjectIdentity,
subjectEvidence.receivePack, subjectPermission,
subjectReceivePack,
controlIdentity, controlIdentity,
controlPermission, controlPermission,
controlReceivePack, controlReceivePack,
); );
return result(request, { return result(request, {
outcome: 'ok', outcome: subjectIdentity === null ? 'indeterminate' : 'ok',
code: 'validation-verified', code: subjectIdentity === null ? 'identity-not-measured' : 'validation-verified',
message: 'Every required provider evidence layer agreed.', 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, evidence,
}); });
} }