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
+61 -28
View File
@@ -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<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 {
if (json) {
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('--json', 'Emit one machine result object')
.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);
process.exitCode = result.exitCode;
});
@@ -912,7 +944,7 @@ export function registerCredentialCommand(parent: Command): void {
.option('--state-dir <path>', 'Durable credential journal root')
.option('--json', 'Emit one machine result object')
.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);
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 <estate>', 'Explicit target estate')
.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')
.option('--registry <path>', 'Strict non-secret estate registry')
.option('--token-dir <path>', 'Governed token directory')
.option('--state-dir <path>', 'Durable credential journal root')
.option('--json', 'Emit one non-secret machine result object')
.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);
process.exitCode = result.exitCode;
});
@@ -949,7 +982,7 @@ export function registerCredentialCommand(parent: Command): void {
.option('--state-dir <path>', 'Durable credential journal root')
.option('--json', 'Emit one machine result object')
.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);
process.exitCode = result.exitCode;
});
@@ -966,7 +999,7 @@ export function registerCredentialCommand(parent: Command): void {
.option('--state-dir <path>', 'Durable credential journal root')
.option('--json', 'Emit one machine result object')
.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);
process.exitCode = result.exitCode;
});
@@ -983,7 +1016,7 @@ export function registerCredentialCommand(parent: Command): void {
.option('--state-dir <path>', 'Durable credential journal root')
.option('--json', 'Emit one machine result object')
.action(async (options: CredentialLifecycleCommandOptions): Promise<void> => {
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 <path>', 'Durable credential journal root')
.option('--json', 'Emit one machine result object')
.action(async (options: CredentialLifecycleCommandOptions): Promise<void> => {
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<void> => {
const result = await executeCredentialValidate(identity, {
...options,
...withParentMosaicHome(cred, options),
require: 'read',
operation: 'whoami',
});
@@ -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,
@@ -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<void> => {
const observed = fixture();
observed.dependencies.provider.readIdentity = async (): Promise<ProviderIdentityEvidence> => {
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<ProviderIdentityEvidence> => {
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<void> => {
const { result, observed } = await validate();
+33 -86
View File
@@ -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<ReturnType<typeof readSubjectEvidence>>;
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,
});
}