fix(mosaic): preserve credential transaction evidence
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user