diff --git a/packages/mosaic/src/commands/cred.ts b/packages/mosaic/src/commands/cred.ts index fe20c2de..95cff93c 100644 --- a/packages/mosaic/src/commands/cred.ts +++ b/packages/mosaic/src/commands/cred.ts @@ -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 { 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 { 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' }, + }); + } } } diff --git a/packages/mosaic/src/credentials/file-credential-store.ts b/packages/mosaic/src/credentials/file-credential-store.ts index 9b5f04e3..6ab27af2 100644 --- a/packages/mosaic/src/credentials/file-credential-store.ts +++ b/packages/mosaic/src/credentials/file-credential-store.ts @@ -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 { const handle = await open(path, 'r'); try { @@ -269,6 +288,7 @@ export class FileCredentialStore { async put(metadata: CredentialBindingMetadataDto, secret: Uint8Array): Promise { 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 { 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; }); diff --git a/packages/mosaic/src/credentials/gitea-provider.ts b/packages/mosaic/src/credentials/gitea-provider.ts index db0bef6e..18738b5b 100644 --- a/packages/mosaic/src/credentials/gitea-provider.ts +++ b/packages/mosaic/src/credentials/gitea-provider.ts @@ -389,6 +389,33 @@ export class GiteaGrantProviderAdapter extends GiteaCredentialProviderAdapter implements GiteaGrantProvider { + async readBasicIdentity(authority: ResolvedCredential): Promise { + 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, }, }); diff --git a/packages/mosaic/src/credentials/grant.spec.ts b/packages/mosaic/src/credentials/grant.spec.ts index 8eb62b70..3a3b6c58 100644 --- a/packages/mosaic/src/credentials/grant.spec.ts +++ b/packages/mosaic/src/credentials/grant.spec.ts @@ -83,7 +83,7 @@ describe('direct repository grant', (): void => { it('opens the journal before mutation and accepts only matching provider read-back', async (): Promise => { 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 => { 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 => { const root = await stateRoot(); const provider: GiteaGrantProvider = { - async readIdentity() { + async readBasicIdentity() { return { login: 'provisioner', endpoint: 'GET /api/v1/user', diff --git a/packages/mosaic/src/credentials/grant.ts b/packages/mosaic/src/credentials/grant.ts index 195e3052..d694a672 100644 --- a/packages/mosaic/src/credentials/grant.ts +++ b/packages/mosaic/src/credentials/grant.ts @@ -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 { diff --git a/packages/mosaic/src/credentials/team-grant.spec.ts b/packages/mosaic/src/credentials/team-grant.spec.ts index b5da30ae..7ad141b8 100644 --- a/packages/mosaic/src/credentials/team-grant.spec.ts +++ b/packages/mosaic/src/credentials/team-grant.spec.ts @@ -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 => { 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', diff --git a/packages/mosaic/src/credentials/team-grant.ts b/packages/mosaic/src/credentials/team-grant.ts index 39f104a6..d7ad4d39 100644 --- a/packages/mosaic/src/credentials/team-grant.ts +++ b/packages/mosaic/src/credentials/team-grant.ts @@ -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) {