fix(mosaic): fail closed across lifecycle audit faults

This commit is contained in:
2026-08-05 13:43:02 -05:00
parent 389a8d36f2
commit b0bedfb73c
7 changed files with 206 additions and 84 deletions
+123 -69
View File
@@ -504,23 +504,6 @@ export async function executeCredentialRotate(
});
}
const context = await lifecycleContext(options);
const old = await context.store.snapshot(identity, options.estate, options.host);
if (old === undefined) {
return localLifecycleResult('rotate', identity, options, {
outcome: 'refused',
code: 'no-token-for-identity',
message: 'No existing binding can be rotated.',
});
}
if (options.tokenName === old.binding.tokenName) {
old.secret.fill(0);
return localLifecycleResult('rotate', identity, options, {
outcome: 'refused',
code: 'replacement-token-name-conflict',
message: 'Replacement token name must differ from the active generation.',
});
}
const authority = await lifecycleAuthority(identity, options);
const journal = await CredentialAuditJournal.open(context.stateRoot, {
operation: 'rotate',
actor: options.actor,
@@ -530,6 +513,25 @@ export async function executeCredentialRotate(
repo: null,
});
await journal.recordIntent('rotate-requested');
const old = await context.store.snapshot(identity, options.estate, options.host);
if (old === undefined) {
await journal.seal('refused', 'no-token-for-identity');
return localLifecycleResult('rotate', identity, options, {
outcome: 'refused',
code: 'no-token-for-identity',
message: 'No existing binding can be rotated.',
});
}
if (options.tokenName === old.binding.tokenName) {
old.secret.fill(0);
await journal.seal('refused', 'replacement-token-name-conflict');
return localLifecycleResult('rotate', identity, options, {
outcome: 'refused',
code: 'replacement-token-name-conflict',
message: 'Replacement token name must differ from the active generation.',
});
}
const authority = await lifecycleAuthority(identity, options);
const provisioned = await provisionCredential(
{
identity,
@@ -655,16 +657,17 @@ export async function executeCredentialWire(
message: 'Declared estate and host could not be resolved before mutation.',
});
}
const journal = await CredentialAuditJournal.open(locations.stateRoot, {
operation: 'wire',
actor: options.actor,
identity,
estate: options.estate,
host: options.host,
repo: null,
});
await journal.recordIntent('wire-requested');
let journal: CredentialAuditJournal | undefined;
try {
journal = await CredentialAuditJournal.open(locations.stateRoot, {
operation: 'wire',
actor: options.actor,
identity,
estate: options.estate,
host: options.host,
repo: null,
});
await journal.recordIntent('wire-requested');
let existing = '';
try {
const snapshot = readRegularFileSecure(options.seatEnv, {
@@ -719,15 +722,32 @@ export async function executeCredentialWire(
message: 'Both fleet identity axes were written to the explicit seat environment.',
audit: { journalId: journal.journalId(), state: 'sealed' },
});
} catch {
await journal.seal('error', 'wire-failed');
return localLifecycleResult('wire', identity, options, {
outcome: 'error',
mutation: 'unknown',
code: 'wire-failed',
message: 'Seat environment wiring failed.',
audit: { journalId: journal.journalId(), state: 'sealed' },
});
} catch (error: unknown) {
if (journal === undefined) {
return localLifecycleResult('wire', identity, options, {
outcome: 'error',
code: error instanceof CredentialJournalError ? error.code : 'wire-failed',
message: 'Seat environment journal could not be opened durably.',
});
}
try {
await journal.seal('error', 'wire-failed');
return localLifecycleResult('wire', identity, options, {
outcome: 'error',
mutation: 'unknown',
code: 'wire-failed',
message: 'Seat environment wiring failed.',
audit: { journalId: journal.journalId(), state: 'sealed' },
});
} catch (sealError: unknown) {
return localLifecycleResult('wire', identity, options, {
outcome: 'error',
mutation: 'unknown',
code: sealError instanceof CredentialJournalError ? sealError.code : 'wire-failed',
message: 'Seat environment audit could not be sealed.',
audit: { journalId: journal.journalId(), state: 'open' },
});
}
}
}
@@ -736,16 +756,17 @@ export async function executeCredentialGet(
options: CredentialLifecycleCommandOptions,
): Promise<CredentialLifecycleResultDto> {
const locations = lifecycleLocations(options);
const journal = await CredentialAuditJournal.open(locations.stateRoot, {
operation: 'get',
actor: options.actor,
identity,
estate: options.estate,
host: options.host,
repo: null,
});
await journal.recordIntent('get-requested');
let journal: CredentialAuditJournal | undefined;
try {
journal = await CredentialAuditJournal.open(locations.stateRoot, {
operation: 'get',
actor: options.actor,
identity,
estate: options.estate,
host: options.host,
repo: null,
});
await journal.recordIntent('get-requested');
if (options.authorityFd === undefined || options.actor !== identity) {
await journal.seal('refused', 'provider-identity-mismatch');
return localLifecycleResult('get', identity, options, {
@@ -810,14 +831,30 @@ export async function executeCredentialGet(
message: 'Credential was emitted only to the protected output fd.',
audit: { journalId: journal.journalId(), state: 'sealed' },
});
} catch {
await journal.seal('error', 'insecure-credential-destination');
return localLifecycleResult('get', identity, options, {
outcome: 'error',
code: 'insecure-credential-destination',
message: 'Protected credential output fd was unavailable or unsafe.',
audit: { journalId: journal.journalId(), state: 'sealed' },
});
} catch (error: unknown) {
if (journal === undefined) {
return localLifecycleResult('get', identity, options, {
outcome: 'error',
code: error instanceof CredentialJournalError ? error.code : 'journal-unavailable',
message: 'Credential access journal could not be opened durably.',
});
}
try {
await journal.seal('error', 'insecure-credential-destination');
return localLifecycleResult('get', identity, options, {
outcome: 'error',
code: 'insecure-credential-destination',
message: 'Protected credential output fd was unavailable or unsafe.',
audit: { journalId: journal.journalId(), state: 'sealed' },
});
} catch (sealError: unknown) {
return localLifecycleResult('get', identity, options, {
outcome: 'error',
code: sealError instanceof CredentialJournalError ? sealError.code : 'journal-unavailable',
message: 'Credential access audit could not be sealed.',
audit: { journalId: journal.journalId(), state: 'open' },
});
}
}
}
@@ -826,16 +863,17 @@ async function executeAuthorizedInventoryRead(
options: CredentialLifecycleCommandOptions,
): Promise<CredentialLifecycleResultDto> {
const locations = lifecycleLocations(options);
const journal = await CredentialAuditJournal.open(locations.stateRoot, {
operation,
actor: options.actor,
identity: options.actor,
estate: options.estate,
host: options.host,
repo: null,
});
await journal.recordIntent(`${operation}-requested`);
let journal: CredentialAuditJournal | undefined;
try {
journal = await CredentialAuditJournal.open(locations.stateRoot, {
operation,
actor: options.actor,
identity: options.actor,
estate: options.estate,
host: options.host,
repo: null,
});
await journal.recordIntent(`${operation}-requested`);
if (options.authorityFd === undefined) {
await journal.seal('refused', 'authority-required');
return localLifecycleResult(operation, 'all', options, {
@@ -891,14 +929,30 @@ async function executeAuthorizedInventoryRead(
},
audit: { journalId: journal.journalId(), state: 'sealed' },
});
} catch {
await journal.seal('error', 'inventory-read-failed');
return localLifecycleResult(operation, 'all', options, {
outcome: 'error',
code: 'inventory-read-failed',
message: 'Authorized credential inventory read failed.',
audit: { journalId: journal.journalId(), state: 'sealed' },
});
} catch (error: unknown) {
if (journal === undefined) {
return localLifecycleResult(operation, 'all', options, {
outcome: 'error',
code: error instanceof CredentialJournalError ? error.code : 'journal-unavailable',
message: 'Inventory journal could not be opened durably.',
});
}
try {
await journal.seal('error', 'inventory-read-failed');
return localLifecycleResult(operation, 'all', options, {
outcome: 'error',
code: 'inventory-read-failed',
message: 'Authorized credential inventory read failed.',
audit: { journalId: journal.journalId(), state: 'sealed' },
});
} catch (sealError: unknown) {
return localLifecycleResult(operation, 'all', options, {
outcome: 'error',
code: sealError instanceof CredentialJournalError ? sealError.code : 'journal-unavailable',
message: 'Inventory audit could not be sealed.',
audit: { journalId: journal.journalId(), state: 'open' },
});
}
}
}
@@ -222,6 +222,25 @@ export class FileCredentialResolver implements CredentialResolver {
}
}
function assertPrivateTokenDirectory(path: string): {
readonly dev: number | bigint;
readonly ino: number | bigint;
} {
const stat = lstatSync(path);
if (
!stat.isDirectory() ||
stat.isSymbolicLink() ||
stat.uid !== process.getuid?.() ||
(stat.mode & 0o022) !== 0
) {
throw new CredentialStoreError(
'insecure-token-owner',
'token directory ownership or write permissions are unsafe',
);
}
return { dev: stat.dev, ino: stat.ino };
}
async function syncDirectory(path: string): Promise<void> {
const handle = await open(path, 'r');
try {
@@ -269,6 +288,7 @@ export class FileCredentialStore {
async put(metadata: CredentialBindingMetadataDto, secret: Uint8Array): Promise<void> {
const paths = this.paths(metadata.identity, metadata.estate, metadata.host);
ensureManagedDirectory(this.tokenDirectory, this.tokenDirectory);
const directoryIdentity = assertPrivateTokenDirectory(this.tokenDirectory);
const token = validateSecret(Buffer.from(secret));
const envelope = credentialEnvelopeSchema.parse({
...metadata,
@@ -296,6 +316,16 @@ export class FileCredentialStore {
} finally {
await handle.close();
}
const beforeCommit = assertPrivateTokenDirectory(this.tokenDirectory);
if (
beforeCommit.dev !== directoryIdentity.dev ||
beforeCommit.ino !== directoryIdentity.ino
) {
throw new CredentialStoreError(
'insecure-token-owner',
'token directory changed during credential commit',
);
}
await rename(envelopeTemp, paths.envelope);
await syncDirectory(this.tokenDirectory);
} finally {
@@ -424,6 +454,7 @@ export class FileCredentialStore {
expectedTokenDigest?: string,
): Promise<void> {
const paths = this.paths(identity, estate, host);
const directoryIdentity = assertPrivateTokenDirectory(this.tokenDirectory);
const lockPath = join(this.tokenDirectory, `${paths.prefix}.lock`);
let lock;
try {
@@ -444,6 +475,16 @@ export class FileCredentialStore {
);
}
}
const beforeRemoval = assertPrivateTokenDirectory(this.tokenDirectory);
if (
beforeRemoval.dev !== directoryIdentity.dev ||
beforeRemoval.ino !== directoryIdentity.ino
) {
throw new CredentialStoreError(
'insecure-token-owner',
'token directory changed during credential removal',
);
}
await unlink(paths.token).catch((error: unknown): void => {
if (!isMissingFile(error)) throw error;
});
@@ -389,6 +389,33 @@ export class GiteaGrantProviderAdapter
extends GiteaCredentialProviderAdapter
implements GiteaGrantProvider
{
async readBasicIdentity(authority: ResolvedCredential): Promise<ProviderIdentityEvidenceDto> {
const endpoint = 'GET /api/v1/user';
const response = await this.request(`${this.origin}/api/v1/user`, {
method: 'GET',
headers: {
Accept: JSON_CONTENT_TYPE,
Authorization: basicAuthorization(authority),
'User-Agent': USER_AGENT,
},
});
if (!response.ok) {
await boundedBody(response);
throw new CredentialProviderEvidenceError(
'credential-rejected',
'delegated Basic authority was rejected',
);
}
const parsed = userSchema.safeParse(await jsonObject(response));
if (!parsed.success) {
throw new CredentialProviderEvidenceError(
'unexpected-provider-shape',
'delegated Basic identity response was malformed',
);
}
return { login: parsed.data.login, endpoint, contentType: contentType(response) };
}
async grantCollaborator(
authority: ResolvedCredential,
identity: string,
@@ -402,7 +429,7 @@ export class GiteaGrantProviderAdapter
method: 'PUT',
headers: {
Accept: JSON_CONTENT_TYPE,
Authorization: apiAuthorization(authority),
Authorization: basicAuthorization(authority),
'Content-Type': JSON_CONTENT_TYPE,
'User-Agent': USER_AGENT,
},
@@ -433,7 +460,7 @@ export class GiteaGrantProviderAdapter
method: 'GET',
headers: {
Accept: JSON_CONTENT_TYPE,
Authorization: apiAuthorization(authority),
Authorization: basicAuthorization(authority),
'User-Agent': USER_AGENT,
},
},
@@ -518,7 +545,7 @@ export class GiteaTeamGrantProviderAdapter
method: 'GET',
headers: {
Accept: JSON_CONTENT_TYPE,
Authorization: apiAuthorization(authority),
Authorization: basicAuthorization(authority),
'User-Agent': USER_AGENT,
},
},
@@ -557,7 +584,7 @@ export class GiteaTeamGrantProviderAdapter
method: 'GET',
headers: {
Accept: JSON_CONTENT_TYPE,
Authorization: apiAuthorization(authority),
Authorization: basicAuthorization(authority),
'User-Agent': USER_AGENT,
},
},
@@ -613,7 +640,7 @@ export class GiteaTeamGrantProviderAdapter
method: 'PUT',
headers: {
Accept: JSON_CONTENT_TYPE,
Authorization: apiAuthorization(authority),
Authorization: basicAuthorization(authority),
'User-Agent': USER_AGENT,
},
});
@@ -666,7 +693,7 @@ export class GiteaTeamGrantProviderAdapter
method: 'GET',
headers: {
Accept: JSON_CONTENT_TYPE,
Authorization: apiAuthorization(authority),
Authorization: basicAuthorization(authority),
'User-Agent': USER_AGENT,
},
});
@@ -83,7 +83,7 @@ describe('direct repository grant', (): void => {
it('opens the journal before mutation and accepts only matching provider read-back', async (): Promise<void> => {
const root = await stateRoot();
const provider: GiteaGrantProvider = {
async readIdentity() {
async readBasicIdentity() {
return {
login: 'provisioner',
endpoint: 'GET /api/v1/user',
@@ -142,7 +142,7 @@ describe('direct repository grant', (): void => {
it('preserves applied mutation and journal context when post-grant read-back fails', async (): Promise<void> => {
const root = await stateRoot();
const provider: GiteaGrantProvider = {
async readIdentity() {
async readBasicIdentity() {
return {
login: 'provisioner',
endpoint: 'GET /api/v1/user',
@@ -183,7 +183,7 @@ describe('direct repository grant', (): void => {
it('is indeterminate when grant read-back disagrees with the requested permission', async (): Promise<void> => {
const root = await stateRoot();
const provider: GiteaGrantProvider = {
async readIdentity() {
async readBasicIdentity() {
return {
login: 'provisioner',
endpoint: 'GET /api/v1/user',
+2 -2
View File
@@ -13,7 +13,7 @@ import type {
import { evaluateGiteaReadValidation, evaluateGiteaWriteValidation } from './validate.js';
export interface GiteaGrantProvider {
readIdentity(authority: ResolvedCredential): Promise<{
readBasicIdentity(authority: ResolvedCredential): Promise<{
readonly login: string;
readonly endpoint: string;
readonly contentType: string;
@@ -76,7 +76,7 @@ export async function grantDirectRepositoryPermission(
await journal.recordIntent('provider-grant');
let mutation: 'none' | 'unknown' | 'applied' = 'none';
try {
const authorityIdentity = await grantProvider.readIdentity(authority);
const authorityIdentity = await grantProvider.readBasicIdentity(authority);
if (authorityIdentity.login !== options.actor) {
await journal.seal('refused', 'provider-identity-mismatch');
return {
@@ -74,7 +74,7 @@ describe('team repository grant', (): void => {
it('reads team permission, org membership, member attachment, repo attachment, and effective subject permission', async (): Promise<void> => {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
const provider: GiteaTeamGrantProvider = {
async readIdentity() {
async readBasicIdentity() {
return {
login: 'provisioner',
endpoint: 'GET /api/v1/user',
@@ -148,7 +148,7 @@ describe('team repository grant', (): void => {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
let mutated = false;
const provider: GiteaTeamGrantProvider = {
async readIdentity() {
async readBasicIdentity() {
return {
login: 'provisioner',
endpoint: 'GET /api/v1/user',
@@ -41,7 +41,7 @@ export interface TeamGrantResult extends CredentialGrantResultDto {
};
}
export interface GiteaTeamGrantProvider {
readIdentity(
readBasicIdentity(
authority: ResolvedCredential,
): Promise<{ readonly login: string; readonly endpoint: string; readonly contentType: string }>;
resolveTeam(
@@ -93,7 +93,7 @@ export async function grantTeamRepositoryPermission(
await journal.recordIntent('provider-grant');
let mutation: 'none' | 'unknown' | 'applied' = 'none';
try {
const authorityIdentity = await provider.readIdentity(authority);
const authorityIdentity = await provider.readBasicIdentity(authority);
const organization = request.repo.split('/')[0] ?? '';
const team = await provider.resolveTeam(authority, organization, request.team);
if (authorityIdentity.login !== options.actor || team.permission !== request.permission) {