fix(cred): make rollback and audit outcomes truthful
ci/woodpecker/pr/ci Pipeline failed

This commit is contained in:
2026-08-05 18:01:41 -05:00
parent 593b5a1f45
commit 12958610cb
15 changed files with 2041 additions and 117 deletions
@@ -42,7 +42,7 @@ resolution_path=unresolved
case "$host" in
git.uscllc.com) idpfx=gitea-usc;;
git.mosaicstack.dev) idpfx=gitea-mosaicstack;;
*) idpfx="";;
*) exit 0;;
esac
ident="$MOSAIC_GIT_IDENTITY"
[ -z "$ident" ] && ident=$(git config --get mosaic.gitIdentity 2>/dev/null)
@@ -213,6 +213,8 @@ fi
# ---------------------------------------------------------------------------
out=$(run_helper "github.com" "agentA")
assert_eq "unknown host: no output" "" "$out"
out=$(run_helper "github.com" "[email protected]")
assert_eq "unknown host with non-Mosaic username: no output" "" "$out"
out=$(run_helper "github.com" "github-user" MOSAIC_AGENT_NAME=agentA)
assert_eq "unknown host in fleet context: no output" "" "$out"
+377 -1
View File
@@ -7,8 +7,11 @@ import {
readdir,
rename,
rm,
symlink,
unlink,
writeFile,
} from 'node:fs/promises';
import { writeSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
@@ -19,7 +22,9 @@ import {
} from '../credentials/audit-journal.js';
import { parseCredentialEstateRegistry } from '../credentials/estate-registry.js';
import { FileCredentialStore } from '../credentials/file-credential-store.js';
import { executeCredentialRotate, executeCredentialWire } from './cred.js';
import { credentialLifecycleLocksDirectory } from '../credentials/lifecycle.js';
import { TeaLoginStore } from '../credentials/tea-login-store.js';
import { executeCredentialGet, executeCredentialRotate, executeCredentialWire } from './cred.js';
let cleanup: string | undefined;
afterEach(async (): Promise<void> => {
@@ -109,6 +114,377 @@ describe('credential lifecycle command controls', (): void => {
);
});
it.each(['partialWrite', 'shortWrite', 'recordMutation', 'seal'] as const)(
'handles %s without overstating credential disclosure',
async (method): Promise<void> => {
const paths = await fixture();
const registry = parseCredentialEstateRegistry(await readFile(paths.registryPath, 'utf8'));
await mkdir(join(paths.mosaicHome, 'secrets'), { mode: 0o700 });
const store = new FileCredentialStore(paths.tokenDirectory, registry);
await store.put(
{
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
providerLogin: 'seat-name',
tokenName: 'active-generation',
scopes: ['write:repository'],
createdAt: '2026-08-05T00:00:00.000Z',
},
new TextEncoder().encode('seat-token-canary'),
);
const authorityPath = join(cleanup!, 'authority.json');
const outputPath = join(cleanup!, 'credential.out');
await writeFile(
authorityPath,
JSON.stringify({
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
secret: 'seat-token-canary',
}),
{ mode: 0o600 },
);
vi.stubGlobal(
'fetch',
async (): Promise<Response> =>
new Response(JSON.stringify({ id: 7, login: 'seat-name' }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
if (method === 'recordMutation') {
const original = CredentialAuditJournal.prototype.recordMutation;
vi.spyOn(CredentialAuditJournal.prototype, 'recordMutation').mockImplementation(
async function (this: CredentialAuditJournal, decision): Promise<void> {
if (decision === 'credential-issued') {
throw new CredentialJournalError('journal-unavailable', 'injected append failure');
}
await original.call(this, decision);
},
);
} else if (method === 'seal') {
const original = CredentialAuditJournal.prototype.seal;
vi.spyOn(CredentialAuditJournal.prototype, 'seal').mockImplementation(async function (
this: CredentialAuditJournal,
outcome,
reason,
): Promise<string> {
if (outcome === 'ok') {
throw new CredentialJournalError('journal-unavailable', 'injected seal failure');
}
return original.call(this, outcome, reason);
});
}
let writes = 0;
const authority = await open(authorityPath, 'r');
const output = await open(outputPath, 'w+', 0o600);
try {
const result = await executeCredentialGet('seat-name', {
estate: 'homelab',
host: 'git.example.invalid',
actor: 'seat-name',
authorityFd: authority.fd.toString(),
outputFd: output.fd.toString(),
mosaicHome: paths.mosaicHome,
registry: paths.registryPath,
tokenDir: paths.tokenDirectory,
stateDir: paths.stateRoot,
credentialWrite:
method === 'partialWrite'
? (fd, data): number => {
writes += 1;
if (writes === 2) throw new Error('injected partial write failure');
return writeSync(fd, data);
}
: method === 'shortWrite'
? (fd, data): number =>
writeSync(fd, data.subarray(0, Math.max(1, Math.floor(data.byteLength / 2))))
: undefined,
});
if (method === 'shortWrite') {
expect(result).toMatchObject({
outcome: 'ok',
mutation: 'none',
reason: { code: 'get-verified' },
});
} else {
expect(result).toMatchObject({
outcome: 'indeterminate',
mutation: method === 'partialWrite' ? 'unknown' : 'applied',
reason: { code: 'credential-issuance-indeterminate' },
});
}
const emitted = await readFile(outputPath, 'utf8');
if (method === 'partialWrite') {
expect(emitted).toContain('username=seat-name');
expect(emitted).not.toContain('seat-token-canary');
} else {
expect(emitted).toContain('password=seat-token-canary');
}
} finally {
await output.close();
await authority.close();
}
},
);
it.each(['absent', 'divergent'] as const)(
'restores an independently %s Tea pre-state after rotation journal failure',
async (teaState): Promise<void> => {
const paths = await fixture();
const registry = parseCredentialEstateRegistry(await readFile(paths.registryPath, 'utf8'));
await mkdir(join(paths.mosaicHome, 'secrets'), { mode: 0o700 });
const store = new FileCredentialStore(paths.tokenDirectory, registry);
await store.put(
{
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
providerLogin: 'seat-name',
tokenName: 'old-generation',
scopes: ['write:repository'],
createdAt: '2026-08-05T00:00:00.000Z',
},
new TextEncoder().encode('old-token'),
);
const teaConfig = join(cleanup!, 'tea', 'config.yml');
const teaStore = new TeaLoginStore(teaConfig);
if (teaState === 'divergent') {
await teaStore.put(
'seat-name',
'git.example.invalid',
new TextEncoder().encode('divergent-tea-token'),
);
}
const authorityPath = join(cleanup!, 'authority.json');
await writeFile(
authorityPath,
JSON.stringify({
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
secret: 'authority-canary',
}),
{ mode: 0o600 },
);
let replacementRevoked = false;
vi.stubGlobal('fetch', async (input: string | URL | Request, init?: RequestInit) => {
const url = new URL(typeof input === 'string' || input instanceof URL ? input : input.url);
const method = init?.method ?? 'GET';
if (url.pathname === '/api/v1/user') {
return new Response(JSON.stringify({ id: 7, login: 'seat-name' }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.pathname.endsWith('/tokens') && method === 'POST') {
return new Response(
JSON.stringify({
name: 'new-generation',
sha1: 'replacement-token',
scopes: ['write:repository'],
}),
{ status: 201, headers: { 'content-type': 'application/json' } },
);
}
if (url.pathname.endsWith('/tokens') && method === 'GET') {
return new Response(
JSON.stringify(
replacementRevoked ? [] : [{ name: 'new-generation', scopes: ['write:repository'] }],
),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (url.pathname.endsWith('/tokens/new-generation') && method === 'DELETE') {
replacementRevoked = true;
return new Response(null, { status: 204 });
}
throw new Error(`unexpected provider request: ${method} ${url.pathname}`);
});
let mintRecords = 0;
const recordMutation = CredentialAuditJournal.prototype.recordMutation;
vi.spyOn(CredentialAuditJournal.prototype, 'recordMutation').mockImplementation(
async function (this: CredentialAuditJournal, decision): Promise<void> {
if (decision === 'token-mint-applied') {
mintRecords += 1;
if (mintRecords === 2) {
throw new CredentialJournalError('journal-unavailable', 'injected rotation failure');
}
}
await recordMutation.call(this, decision);
},
);
const authority = await open(authorityPath, 'r');
try {
const result = await executeCredentialRotate('seat-name', {
estate: 'homelab',
host: 'git.example.invalid',
actor: 'seat-name',
authorityFd: authority.fd.toString(),
tokenName: 'new-generation',
mosaicHome: paths.mosaicHome,
registry: paths.registryPath,
tokenDir: paths.tokenDirectory,
stateDir: paths.stateRoot,
teaConfig,
});
expect(result).toMatchObject({ outcome: 'error', mutation: 'none' });
if (teaState === 'absent') {
expect(teaStore.snapshot('seat-name', 'git.example.invalid')).toBeUndefined();
} else {
expect(
teaStore.matchesSecret(
'seat-name',
'git.example.invalid',
new TextEncoder().encode('divergent-tea-token'),
),
).toBe(true);
}
} finally {
await authority.close();
}
},
);
it('preserves an open nested provision journal in the rotate result', async (): Promise<void> => {
const paths = await fixture();
const registry = parseCredentialEstateRegistry(await readFile(paths.registryPath, 'utf8'));
await mkdir(paths.tokenDirectory, { recursive: true, mode: 0o700 });
const store = new FileCredentialStore(paths.tokenDirectory, registry);
await store.put(
{
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
providerLogin: 'seat-name',
tokenName: 'old-generation',
scopes: ['write:repository'],
createdAt: '2026-08-05T00:00:00.000Z',
},
new TextEncoder().encode('old-token'),
);
const authorityPath = join(cleanup!, 'authority.json');
await writeFile(
authorityPath,
JSON.stringify({
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
secret: 'authority-canary',
}),
{ mode: 0o600 },
);
const snapshot = TeaLoginStore.prototype.snapshot;
let snapshotCalls = 0;
vi.spyOn(TeaLoginStore.prototype, 'snapshot').mockImplementation(function (
this: TeaLoginStore,
identity,
host,
) {
snapshotCalls += 1;
if (snapshotCalls === 2) throw new Error('injected nested snapshot failure');
return snapshot.call(this, identity, host);
});
const seal = CredentialAuditJournal.prototype.seal;
vi.spyOn(CredentialAuditJournal.prototype, 'seal').mockImplementation(async function (
this: CredentialAuditJournal,
outcome,
reasonCode,
): Promise<string> {
if (reasonCode === 'credential-snapshot-unavailable') {
throw new CredentialJournalError('journal-unavailable', 'injected nested seal failure');
}
return seal.call(this, outcome, reasonCode);
});
const authority = await open(authorityPath, 'r');
try {
const result = await executeCredentialRotate('seat-name', {
estate: 'homelab',
host: 'git.example.invalid',
actor: 'seat-name',
authorityFd: authority.fd.toString(),
tokenName: 'new-generation',
mosaicHome: paths.mosaicHome,
registry: paths.registryPath,
tokenDir: paths.tokenDirectory,
stateDir: paths.stateRoot,
teaConfig: join(cleanup!, 'tea', 'config.yml'),
});
expect(result).toMatchObject({
operation: 'rotate',
outcome: 'error',
mutation: 'none',
audit: { state: 'open' },
});
} finally {
await authority.close();
}
});
it('preserves the journal failure diagnosis when rotate lock failure cannot be sealed', async (): Promise<void> => {
const paths = await fixture();
const registry = parseCredentialEstateRegistry(await readFile(paths.registryPath, 'utf8'));
await mkdir(paths.tokenDirectory, { recursive: true, mode: 0o700 });
const store = new FileCredentialStore(paths.tokenDirectory, registry);
await store.put(
{
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
providerLogin: 'seat-name',
tokenName: 'old-generation',
scopes: ['write:repository'],
createdAt: '2026-08-05T00:00:00.000Z',
},
new TextEncoder().encode('old-token'),
);
const authorityPath = join(cleanup!, 'authority.json');
await writeFile(
authorityPath,
JSON.stringify({
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
secret: 'authority-canary',
}),
{ mode: 0o600 },
);
const locksDirectory = credentialLifecycleLocksDirectory();
await mkdir(locksDirectory, { recursive: true, mode: 0o700 });
const lockPath = join(locksDirectory, 'homelab--git.example.invalid--seat-name.lock');
await unlink(lockPath).catch((): void => undefined);
await symlink('/dev/null', lockPath);
vi.spyOn(CredentialAuditJournal.prototype, 'seal').mockRejectedValue(
new CredentialJournalError('journal-recovery-required', 'injected final seal failure'),
);
const authority = await open(authorityPath, 'r');
try {
const result = await executeCredentialRotate('seat-name', {
estate: 'homelab',
host: 'git.example.invalid',
actor: 'seat-name',
authorityFd: authority.fd.toString(),
tokenName: 'new-generation',
mosaicHome: paths.mosaicHome,
registry: paths.registryPath,
tokenDir: paths.tokenDirectory,
stateDir: paths.stateRoot,
teaConfig: join(cleanup!, 'tea', 'config.yml'),
});
expect(result).toMatchObject({
operation: 'rotate',
outcome: 'error',
mutation: 'none',
reason: { code: 'journal-recovery-required' },
audit: { state: 'open' },
});
} finally {
await authority.close();
await unlink(lockPath).catch((): void => undefined);
}
});
it('refuses an unauthenticated actor before rewriting another seat environment', async (): Promise<void> => {
const paths = await fixture();
const agents = join(paths.mosaicHome, 'fleet', 'agents');
+130 -15
View File
@@ -27,7 +27,13 @@ import {
import type { CredentialGrantResultDto } from '../credentials/grant.dto.js';
import { grantTeamRepositoryPermission } from '../credentials/team-grant.js';
import type { TeamGrantResult } from '../credentials/team-grant.js';
import { provisionCredential, revokeCredential } from '../credentials/lifecycle.js';
import {
acquireCredentialLifecycleLock,
CredentialLifecycleLockError,
type CredentialLifecycleLock,
provisionCredential,
revokeCredential,
} from '../credentials/lifecycle.js';
import type { CredentialLifecycleResultDto } from '../credentials/lifecycle.dto.js';
import { TeaLoginStore } from '../credentials/tea-login-store.js';
import {
@@ -94,6 +100,7 @@ interface CredentialLifecycleCommandOptions {
readonly json?: boolean;
readonly wireBeforeRename?: () => Promise<void>;
readonly wireDirectorySync?: (path: string) => Promise<void>;
readonly credentialWrite?: (fd: number, data: Uint8Array) => number;
}
function defaultMosaicHome(options: { readonly mosaicHome?: string }): string {
@@ -499,7 +506,11 @@ export async function executeCredentialRotate(
): Promise<CredentialLifecycleResultDto> {
let journal: CredentialAuditJournal | undefined;
let old: Awaited<ReturnType<FileCredentialStore['snapshot']>> = undefined;
let oldTea: ReturnType<TeaLoginStore['snapshot']> = undefined;
let replacement: Awaited<ReturnType<FileCredentialStore['snapshot']>> = undefined;
let replacementTea: ReturnType<TeaLoginStore['snapshot']> = undefined;
let authority: Awaited<ReturnType<typeof readDelegatedCredentialFromFd>> | undefined;
let lifecycleLock: CredentialLifecycleLock | undefined;
let mutation: CredentialLifecycleResultDto['mutation'] = 'none';
try {
if (options.authorityFd === undefined || options.tokenName === undefined) {
@@ -519,6 +530,32 @@ export async function executeCredentialRotate(
repo: null,
});
await journal.recordIntent('rotate-requested');
try {
lifecycleLock = await acquireCredentialLifecycleLock(identity, options.estate, options.host);
} catch (error: unknown) {
const code =
error instanceof CredentialLifecycleLockError ? error.code : 'mutation-lock-unavailable';
try {
await journal.seal(code === 'concurrent-mutation' ? 'refused' : 'error', code);
return localLifecycleResult('rotate', identity, options, {
outcome: code === 'concurrent-mutation' ? 'refused' : 'error',
mutation: 'none',
code,
message: 'Credential lifecycle mutation lock could not be acquired.',
audit: { journalId: journal.journalId(), state: 'sealed' },
});
} catch (sealError: unknown) {
await journal.closeIncomplete().catch((): void => undefined);
return localLifecycleResult('rotate', identity, options, {
outcome: 'error',
mutation: 'none',
code:
sealError instanceof CredentialJournalError ? sealError.code : 'journal-unavailable',
message: 'Mutation lock failure could not be sealed durably.',
audit: { journalId: journal.journalId(), state: 'open' },
});
}
}
old = await context.store.snapshot(identity, options.estate, options.host);
if (old === undefined) {
await journal.seal('refused', 'no-token-for-identity');
@@ -529,6 +566,7 @@ export async function executeCredentialRotate(
audit: { journalId: journal.journalId(), state: 'sealed' },
});
}
oldTea = context.teaStore.snapshot(identity, options.host);
if (options.tokenName === old.binding.tokenName) {
await journal.seal('refused', 'replacement-token-name-conflict');
return localLifecycleResult('rotate', identity, options, {
@@ -558,16 +596,24 @@ export async function executeCredentialRotate(
allowReplace: true,
journal,
deferSuccessSeal: true,
lifecycleLock,
expectedStoreGeneration: old.generation,
expectedTeaGeneration: oldTea?.generation ?? null,
},
);
if (provisioned.outcome !== 'ok') {
return {
...provisioned,
operation: 'rotate',
audit: { journalId: journal.journalId(), state: 'sealed' },
};
return { ...provisioned, operation: 'rotate' };
}
mutation = 'applied';
replacement = await context.store.snapshot(identity, options.estate, options.host);
replacementTea = context.teaStore.snapshot(identity, options.host);
if (
replacement === undefined ||
replacement.binding.tokenName !== options.tokenName ||
replacementTea === undefined
) {
throw new Error('replacement generation could not be snapshotted exactly');
}
try {
await journal.recordMutation('token-mint-applied');
} catch (error: unknown) {
@@ -575,8 +621,22 @@ export async function executeCredentialRotate(
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);
await context.store.put(old.binding, old.secret, replacement.generation);
await context.teaStore.restore(identity, options.host, oldTea, replacementTea.generation);
const restored = await context.store.snapshot(identity, options.estate, options.host);
try {
if (
restored === undefined ||
JSON.stringify(restored.binding) !== JSON.stringify(old.binding) ||
restored.secret.byteLength !== old.secret.byteLength ||
!timingSafeEqual(Buffer.from(restored.secret), Buffer.from(old.secret)) ||
!context.teaStore.matchesSnapshot(identity, options.host, oldTea)
) {
throw new Error('rotation pre-operation state was not restored exactly');
}
} finally {
restored?.secret.fill(0);
}
mutation = 'none';
throw error;
}
@@ -630,6 +690,10 @@ export async function executeCredentialRotate(
} finally {
authority?.secret.fill(0);
old?.secret.fill(0);
oldTea?.secret.fill(0);
replacement?.secret.fill(0);
replacementTea?.secret.fill(0);
await lifecycleLock?.release();
}
}
@@ -861,6 +925,8 @@ export async function executeCredentialGet(
): Promise<CredentialLifecycleResultDto> {
const locations = lifecycleLocations(options);
let journal: CredentialAuditJournal | undefined;
let disclosureStarted = false;
let disclosureCompleted = false;
try {
journal = await CredentialAuditJournal.open(locations.stateRoot, {
operation: 'get',
@@ -924,9 +990,26 @@ export async function executeCredentialGet(
`protocol=https\nhost=${options.host}\nusername=${identity}\npassword=`,
);
await journal.recordMutation('credential-issuance-authorized');
writeSync(fd, prefix);
writeSync(fd, resolved.secret);
writeSync(fd, new TextEncoder().encode('\n\n'));
await journal.recordMutation('credential-issuance-started');
disclosureStarted = true;
const writeCredential =
options.credentialWrite ??
((targetFd: number, data: Uint8Array): number => writeSync(targetFd, data));
const writeAll = (data: Uint8Array): void => {
let offset = 0;
while (offset < data.byteLength) {
const remaining = data.subarray(offset);
const written = writeCredential(fd, remaining);
if (!Number.isSafeInteger(written) || written <= 0 || written > remaining.byteLength) {
throw new Error('credential output made invalid write progress');
}
offset += written;
}
};
writeAll(prefix);
writeAll(resolved.secret);
writeAll(new TextEncoder().encode('\n\n'));
disclosureCompleted = true;
await journal.recordMutation('credential-issued');
await journal.seal('ok', 'get-verified');
return localLifecycleResult('get', identity, options, {
@@ -943,19 +1026,51 @@ export async function executeCredentialGet(
message: 'Credential access journal could not be opened durably.',
});
}
if (disclosureStarted) {
try {
await journal.recordMutation(
disclosureCompleted ? 'credential-issued' : 'credential-issuance-possibly-issued',
);
await journal.seal('indeterminate', 'credential-issuance-indeterminate');
return localLifecycleResult('get', identity, options, {
outcome: 'indeterminate',
mutation: disclosureCompleted ? 'applied' : 'unknown',
code: 'credential-issuance-indeterminate',
message: disclosureCompleted
? 'Credential disclosure completed, but its final audit transition failed.'
: 'Credential disclosure began but could not be proven complete.',
audit: { journalId: journal.journalId(), state: 'sealed' },
});
} catch {
await journal.closeIncomplete().catch((): void => undefined);
return localLifecycleResult('get', identity, options, {
outcome: 'indeterminate',
mutation: disclosureCompleted ? 'applied' : 'unknown',
code: 'credential-issuance-indeterminate',
message: 'Credential disclosure may have occurred; inspect the durable open journal.',
audit: { journalId: journal.journalId(), state: 'open' },
});
}
}
const code =
error instanceof CredentialJournalError ? error.code : 'insecure-credential-destination';
try {
await journal.seal('error', 'insecure-credential-destination');
await journal.seal('error', code);
return localLifecycleResult('get', identity, options, {
outcome: 'error',
code: 'insecure-credential-destination',
message: 'Protected credential output fd was unavailable or unsafe.',
code,
message:
error instanceof CredentialJournalError
? 'Credential issuance audit failed before disclosure.'
: 'Protected credential output fd was unavailable or unsafe.',
audit: { journalId: journal.journalId(), state: 'sealed' },
});
} catch (sealError: unknown) {
await journal.closeIncomplete().catch((): void => undefined);
return localLifecycleResult('get', identity, options, {
outcome: 'error',
code: sealError instanceof CredentialJournalError ? sealError.code : 'journal-unavailable',
message: 'Credential access audit could not be sealed.',
message: 'Credential access audit could not be sealed before disclosure.',
audit: { journalId: journal.journalId(), state: 'open' },
});
}
@@ -48,6 +48,8 @@ export interface CredentialPopulationCorrectionDto {
export interface CredentialJournalRuntimeOptionsDto {
readonly id?: string;
readonly now?: () => string;
readonly syncDirectory?: (path: string) => Promise<void>;
readonly rename?: (source: string, destination: string) => Promise<void>;
}
export interface CredentialJournalSummaryDto {
@@ -1,4 +1,15 @@
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import {
chmod,
mkdir,
mkdtemp,
open,
readFile,
rename,
rm,
symlink,
truncate,
writeFile,
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
@@ -49,6 +60,164 @@ describe('credential durable audit journal', (): void => {
expect(records[3]).toContain('"phase":"sealed"');
});
it('keeps a final seal non-accepting while directory durability is pending', async (): Promise<void> => {
const root = await stateRoot();
let directorySyncs = 0;
let signalFinalSync: (() => void) | undefined;
let releaseFinalSync: (() => void) | undefined;
const finalSyncEntered = new Promise<void>((resolve): void => {
signalFinalSync = resolve;
});
const finalSyncRelease = new Promise<void>((resolve): void => {
releaseFinalSync = resolve;
});
const journal = await CredentialAuditJournal.open(
root,
{
operation: 'get',
actor: 'seat-name',
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
repo: null,
},
{
id: 'seal-pending',
syncDirectory: async (path): Promise<void> => {
directorySyncs += 1;
if (directorySyncs === 3) {
signalFinalSync?.();
await finalSyncRelease;
}
const handle = await open(path, 'r');
try {
await handle.sync();
} finally {
await handle.close();
}
},
},
);
const sealing = journal.seal('ok', 'get-verified');
await finalSyncEntered;
await expect(listCredentialJournals(root)).resolves.toContainEqual(
expect.objectContaining({ id: 'seal-pending', state: 'open' }),
);
releaseFinalSync?.();
await sealing;
await expect(listCredentialJournals(root)).resolves.toContainEqual(
expect.objectContaining({ id: 'seal-pending', state: 'sealed' }),
);
});
it('reverts a failed final seal commit to visible open state', async (): Promise<void> => {
const root = await stateRoot();
let directorySyncs = 0;
const journal = await CredentialAuditJournal.open(
root,
{
operation: 'get',
actor: 'seat-name',
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
repo: null,
},
{
id: 'seal-fault',
syncDirectory: async (path): Promise<void> => {
directorySyncs += 1;
if (directorySyncs === 3) throw new Error('injected final directory sync failure');
const handle = await open(path, 'r');
try {
await handle.sync();
} finally {
await handle.close();
}
},
},
);
await expect(journal.seal('ok', 'get-verified')).rejects.toThrow(/journal-unavailable/);
const [entry] = await listCredentialJournals(root);
expect(entry).toMatchObject({ id: 'seal-fault', state: 'open' });
expect(await readFile(entry?.path ?? '', 'utf8')).not.toContain('"phase":"sealed"');
});
it('uses a non-accepting recovery path when the compensating rename fails', async (): Promise<void> => {
const root = await stateRoot();
let directorySyncs = 0;
let renames = 0;
const journal = await CredentialAuditJournal.open(
root,
{
operation: 'get',
actor: 'seat-name',
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
repo: null,
},
{
id: 'seal-recovery-fault',
syncDirectory: async (path): Promise<void> => {
directorySyncs += 1;
if (directorySyncs === 3) throw new Error('injected final directory sync failure');
const handle = await open(path, 'r');
try {
await handle.sync();
} finally {
await handle.close();
}
},
rename: async (source, destination): Promise<void> => {
renames += 1;
if (renames === 3) throw new Error('injected compensating rename failure');
await rename(source, destination);
},
},
);
await expect(journal.seal('ok', 'get-verified')).rejects.toThrow(/journal-recovery-required/);
const [entry] = await listCredentialJournals(root);
expect(entry).toMatchObject({ id: 'seal-recovery-fault', state: 'open' });
expect(entry?.path).toMatch(/\.recovery\.jsonl$/);
expect(await readFile(entry?.path ?? '', 'utf8')).not.toContain('"phase":"sealed"');
});
it.each(['symlink', 'oversized'] as const)(
'classifies an unsafe %s sealed-looking journal as open without consuming it',
async (kind): Promise<void> => {
const root = await stateRoot();
const journal = await CredentialAuditJournal.open(root, {
operation: 'audit',
actor: 'seat-name',
identity: 'seat-name',
estate: 'homelab',
host: 'git.example.invalid',
repo: null,
});
await journal.closeIncomplete();
const journalsDirectory = join(root, 'journals');
const candidate = join(journalsDirectory, `unsafe-${kind}.sealed.jsonl`);
if (kind === 'symlink') {
if (cleanup === undefined) throw new Error('test fixture root is unavailable');
const outside = join(cleanup, 'outside-journal');
await writeFile(outside, '{"phase":"sealed","outcome":"ok"}\n', { mode: 0o600 });
await symlink(outside, candidate);
} else {
await writeFile(candidate, '', { mode: 0o600 });
await truncate(candidate, 4 * 1024 * 1024 + 1);
}
const entry = (await listCredentialJournals(root)).find(
(value): boolean => value.id === `unsafe-${kind}`,
);
expect(entry).toMatchObject({ state: 'open' });
},
);
it('leaves an unsealed journal visible for recovery', async (): Promise<void> => {
const root = await stateRoot();
const journal = await CredentialAuditJournal.open(root, {
+148 -21
View File
@@ -1,9 +1,10 @@
import { spawnSync } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { lstatSync } from 'node:fs';
import { constants, lstatSync } from 'node:fs';
import { open, readdir, rename } from 'node:fs/promises';
import type { FileHandle } from 'node:fs/promises';
import { join } from 'node:path';
import { ensureManagedDirectory } from '../fleet/secure-file.js';
import { ensureManagedDirectory, readRegularFileSecure } from '../fleet/secure-file.js';
import type {
CredentialJournalContextDto,
CredentialJournalCorrectionDto,
@@ -19,6 +20,7 @@ const SAFE_HOST = /^[a-z0-9][a-z0-9.-]*$/;
const SAFE_REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
const SAFE_ENDPOINT = /^(?:GET|PUT|POST|DELETE) \/[A-Za-z0-9_./{}:-]+$/;
const SAFE_CONTENT_TYPE = /^[A-Za-z0-9!#$&^_.+/-]+(?:;[A-Za-z0-9=._+-]+)*$/;
const MAX_JOURNAL_BYTES = 4 * 1024 * 1024;
const SAFE_DECISIONS = new Set<string>([
'provider-grant',
'permission-none',
@@ -61,6 +63,8 @@ const SAFE_DECISIONS = new Set<string>([
'token-revoke-applied',
'wire-applied',
'credential-issuance-authorized',
'credential-issuance-started',
'credential-issuance-possibly-issued',
'credential-issued',
'classification-correction',
]);
@@ -127,15 +131,43 @@ async function syncDirectory(path: string): Promise<void> {
}
}
async function acquireJournalLock(
path: string,
mode: 'exclusive' | 'shared',
): Promise<FileHandle | undefined> {
let handle: FileHandle | undefined;
try {
handle = await open(path, constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW, 0o600);
const stat = await handle.stat();
if (!stat.isFile() || stat.uid !== process.getuid?.() || (stat.mode & 0o077) !== 0) {
throw new Error('journal lock file is unsafe');
}
const acquired = spawnSync('/usr/bin/flock', ['-n', mode === 'exclusive' ? '-x' : '-s', '3'], {
stdio: ['ignore', 'ignore', 'ignore', handle.fd],
});
if (acquired.error !== undefined || acquired.status !== 0) {
await handle.close();
return undefined;
}
return handle;
} catch {
await handle?.close().catch((): void => undefined);
return undefined;
}
}
export class CredentialAuditJournal {
private closed = false;
private constructor(
private readonly handle: FileHandle,
private readonly lockHandle: FileHandle,
private readonly openPath: string,
private readonly journalsDirectory: string,
private readonly id: string,
private readonly now: () => string,
private readonly syncJournalDirectory: (path: string) => Promise<void>,
private readonly renameJournal: (source: string, destination: string) => Promise<void>,
) {}
static async open(
@@ -150,19 +182,43 @@ export class CredentialAuditJournal {
}
const now = runtime.now ?? ((): string => new Date().toISOString());
const journalsDirectory = join(stateRoot, 'journals');
const locksDirectory = join(stateRoot, 'journal-locks');
let handle: FileHandle | undefined;
let lockHandle: FileHandle | undefined;
try {
ensureManagedDirectory(stateRoot, journalsDirectory);
ensureManagedDirectory(stateRoot, locksDirectory);
assertPrivateDirectory(stateRoot);
assertPrivateDirectory(journalsDirectory);
assertPrivateDirectory(locksDirectory);
const openPath = join(journalsDirectory, `${id}.open.jsonl`);
const lockPath = join(locksDirectory, `${id}.lock`);
handle = await open(openPath, 'wx', 0o600);
const journal = new CredentialAuditJournal(handle, openPath, journalsDirectory, id, now);
lockHandle = await acquireJournalLock(lockPath, 'exclusive');
if (lockHandle === undefined) {
throw new CredentialJournalError(
'journal-unavailable',
'journal lock could not be acquired',
);
}
const syncJournalDirectory = runtime.syncDirectory ?? syncDirectory;
const renameJournal = runtime.rename ?? rename;
const journal = new CredentialAuditJournal(
handle,
lockHandle,
openPath,
journalsDirectory,
id,
now,
syncJournalDirectory,
renameJournal,
);
await journal.append({ phase: 'opened', at: now(), context });
await syncDirectory(journalsDirectory);
await syncJournalDirectory(journalsDirectory);
return journal;
} catch (error: unknown) {
if (handle !== undefined) await handle.close().catch((): void => undefined);
if (lockHandle !== undefined) await lockHandle.close().catch((): void => undefined);
if (error instanceof CredentialJournalError) throw error;
throw new CredentialJournalError(
'journal-unavailable',
@@ -264,17 +320,55 @@ export class CredentialAuditJournal {
'reason code is outside the non-secret grammar',
);
}
await this.append({ phase: 'sealed', at: this.now(), outcome, reasonCode });
await this.handle.close();
this.closed = true;
const preSealSize = (await this.handle.stat()).size;
const sealingPath = join(this.journalsDirectory, `${this.id}.sealing.jsonl`);
const sealedPath = join(this.journalsDirectory, `${this.id}.sealed.jsonl`);
const recoveryPath = join(this.journalsDirectory, `${this.id}.recovery.jsonl`);
let currentPath = this.openPath;
try {
await rename(this.openPath, sealedPath);
await syncDirectory(this.journalsDirectory);
await this.renameJournal(currentPath, sealingPath);
currentPath = sealingPath;
await this.syncJournalDirectory(this.journalsDirectory);
await this.append({ phase: 'sealed', at: this.now(), outcome, reasonCode });
await this.renameJournal(currentPath, sealedPath);
currentPath = sealedPath;
await this.syncJournalDirectory(this.journalsDirectory);
await this.handle.close();
await this.lockHandle.close();
this.closed = true;
return sealedPath;
} catch {
let recovered = true;
try {
await this.handle.truncate(preSealSize);
await this.handle.sync();
} catch {
recovered = false;
}
if (currentPath !== this.openPath) {
try {
await this.renameJournal(currentPath, this.openPath);
currentPath = this.openPath;
} catch {
recovered = false;
try {
await this.renameJournal(currentPath, recoveryPath);
currentPath = recoveryPath;
} catch {
// The non-success return below remains authoritative; the path is reported by audit scan.
}
}
}
try {
await this.syncJournalDirectory(this.journalsDirectory);
} catch {
recovered = false;
}
await this.handle.close().catch((): void => undefined);
await this.lockHandle.close().catch((): void => undefined);
this.closed = true;
throw new CredentialJournalError(
'journal-unavailable',
recovered ? 'journal-unavailable' : 'journal-recovery-required',
'sealed journal could not be committed durably',
);
}
@@ -283,6 +377,7 @@ export class CredentialAuditJournal {
async closeIncomplete(): Promise<void> {
if (this.closed) return;
await this.handle.close();
await this.lockHandle.close();
this.closed = true;
}
}
@@ -291,24 +386,56 @@ export async function listCredentialJournals(
stateRoot: string,
): Promise<readonly CredentialJournalSummaryDto[]> {
const journalsDirectory = join(stateRoot, 'journals');
const locksDirectory = join(stateRoot, 'journal-locks');
let names: string[];
try {
assertPrivateDirectory(stateRoot);
assertPrivateDirectory(journalsDirectory);
ensureManagedDirectory(stateRoot, locksDirectory);
assertPrivateDirectory(locksDirectory);
names = await readdir(journalsDirectory);
} catch (error: unknown) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return [];
throw new CredentialJournalError('journal-unavailable', 'journal directory could not be read');
}
return names
.filter((name: string): boolean => /\.(?:open|sealed)\.jsonl$/.test(name))
.sort()
.map((name: string): CredentialJournalSummaryDto => {
const state = name.endsWith('.open.jsonl') ? 'open' : 'sealed';
return {
id: name.replace(/\.(?:open|sealed)\.jsonl$/, ''),
state,
path: join(journalsDirectory, name),
};
});
return Promise.all(
names
.filter((name: string): boolean => /\.(?:open|sealing|recovery|sealed)\.jsonl$/.test(name))
.sort()
.map(async (name: string): Promise<CredentialJournalSummaryDto> => {
const path = join(journalsDirectory, name);
const id = name.replace(/\.(?:open|sealing|recovery|sealed)\.jsonl$/, '');
let committedSeal = false;
let scanLock: FileHandle | undefined;
if (name.endsWith('.sealed.jsonl') && SAFE_NAME.test(id)) {
try {
scanLock = await acquireJournalLock(join(locksDirectory, `${id}.lock`), 'shared');
if (scanLock === undefined) throw new Error('journal seal is still in progress');
const snapshot = readRegularFileSecure(path, {
root: journalsDirectory,
maxBytes: MAX_JOURNAL_BYTES,
});
if (snapshot.uid !== process.getuid?.() || (snapshot.mode & 0o077) !== 0) {
throw new Error('journal owner or mode is unsafe');
}
const records = snapshot.content.toString('utf8').trim().split('\n');
const finalRecord: unknown = JSON.parse(records.at(-1) ?? 'null');
committedSeal =
typeof finalRecord === 'object' &&
finalRecord !== null &&
'phase' in finalRecord &&
finalRecord.phase === 'sealed';
} catch {
committedSeal = false;
} finally {
await scanLock?.close().catch((): void => undefined);
}
}
return {
id,
state: committedSeal ? 'sealed' : 'open',
path,
};
}),
);
}
@@ -148,6 +148,57 @@ describe('phase-1 governed file credential resolver', (): void => {
await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual([]);
});
it('changes generation for metadata-only rebinding and rejects stale replacement or removal', async (): Promise<void> => {
const root = await fixtureRoot();
const store = new FileCredentialStore(root, registry());
const secret = new TextEncoder().encode('same-private-token');
const originalBinding = {
identity: 'seat',
estate: 'homelab',
host: 'git.example.invalid',
providerLogin: 'seat',
tokenName: 'mosaic-seat-original',
scopes: ['write:repository'],
createdAt: '2026-08-05T00:00:00.000Z',
};
await store.put(originalBinding, secret);
const original = await store.snapshot('seat', 'homelab', 'git.example.invalid');
if (original === undefined) throw new Error('original generation was not stored');
try {
await store.put(
{
...originalBinding,
tokenName: 'mosaic-seat-rebound',
createdAt: '2026-08-05T00:00:01.000Z',
},
secret,
original.generation,
);
const rebound = await store.snapshot('seat', 'homelab', 'git.example.invalid');
try {
expect(rebound?.generation).not.toBe(original.generation);
await expect(
store.put(
{ ...originalBinding, tokenName: 'mosaic-seat-stale' },
secret,
original.generation,
),
).rejects.toThrow(/credential-generation-mismatch/);
await expect(
store.remove('seat', 'homelab', 'git.example.invalid', original.generation),
).rejects.toThrow(/credential-generation-mismatch/);
await expect(
store.readBinding('seat', 'homelab', 'git.example.invalid'),
).resolves.toMatchObject({ tokenName: 'mosaic-seat-rebound' });
} finally {
rebound?.secret.fill(0);
}
} finally {
original.secret.fill(0);
secret.fill(0);
}
});
it('returns undefined for an absent token without borrowing another identity', async (): Promise<void> => {
const root = await fixtureRoot();
await writeFile(join(root, 'gitea-example-shared.token'), 'shared-canary', { mode: 0o600 });
@@ -53,6 +53,25 @@ function isMissingFile(error: unknown): boolean {
);
}
export function credentialBindingGeneration(
metadata: CredentialBindingMetadataDto,
secret: Uint8Array,
): string {
const tokenDigest = createHash('sha256').update(secret).digest('hex');
const canonicalState = JSON.stringify({
schemaVersion: 1,
identity: metadata.identity,
estate: metadata.estate,
host: metadata.host,
providerLogin: metadata.providerLogin,
tokenName: metadata.tokenName,
scopes: [...metadata.scopes].sort(),
createdAt: metadata.createdAt,
tokenDigest,
});
return createHash('sha256').update(canonicalState).digest('hex');
}
function validateSecret(content: Buffer): Uint8Array {
if (content.byteLength === 0 || content.byteLength > MAX_TOKEN_BYTES) {
throw new CredentialStoreError('invalid-token-size', 'token file size is outside bounds');
@@ -285,7 +304,11 @@ export class FileCredentialStore {
};
}
async put(metadata: CredentialBindingMetadataDto, secret: Uint8Array): Promise<void> {
async put(
metadata: CredentialBindingMetadataDto,
secret: Uint8Array,
expectedGeneration?: string | null,
): Promise<void> {
const paths = this.paths(metadata.identity, metadata.estate, metadata.host);
ensureManagedDirectory(this.tokenDirectory, this.tokenDirectory);
const directoryIdentity = assertPrivateTokenDirectory(this.tokenDirectory);
@@ -309,6 +332,20 @@ export class FileCredentialStore {
);
}
try {
if (expectedGeneration !== undefined) {
const current = await this.snapshot(metadata.identity, metadata.estate, metadata.host);
try {
const actualGeneration = current?.generation ?? null;
if (actualGeneration !== expectedGeneration) {
throw new CredentialStoreError(
'credential-generation-mismatch',
'credential generation changed before replacement',
);
}
} finally {
current?.secret.fill(0);
}
}
const handle = await open(envelopeTemp, 'wx', 0o600);
try {
await handle.writeFile(`${JSON.stringify(envelope)}\n`, 'utf8');
@@ -344,6 +381,7 @@ export class FileCredentialStore {
| {
readonly binding: CredentialBindingMetadataDto;
readonly secret: Uint8Array;
readonly generation: string;
}
| undefined
> {
@@ -359,7 +397,10 @@ export class FileCredentialStore {
'binding metadata exists without its token generation',
);
}
return { binding, secret: new Uint8Array(resolved.secret) };
const secret = new Uint8Array(resolved.secret);
const generation = credentialBindingGeneration(binding, resolved.secret);
resolved.secret.fill(0);
return { binding, secret, generation };
}
async readBinding(
@@ -467,12 +508,16 @@ export class FileCredentialStore {
}
try {
if (expectedTokenDigest !== undefined) {
const current = await this.readBinding(identity, estate, host);
if (current === undefined || current.tokenDigest !== expectedTokenDigest) {
throw new CredentialStoreError(
'credential-generation-mismatch',
'credential generation changed before removal',
);
const current = await this.snapshot(identity, estate, host);
try {
if (current === undefined || current.generation !== expectedTokenDigest) {
throw new CredentialStoreError(
'credential-generation-mismatch',
'credential generation changed before removal',
);
}
} finally {
current?.secret.fill(0);
}
}
const beforeRemoval = assertPrivateTokenDirectory(this.tokenDirectory);
@@ -1,15 +1,25 @@
import { mkdtemp, mkdir, rm } from 'node:fs/promises';
import { spawnSync } from 'node:child_process';
import { mkdtemp, mkdir, rm, symlink, unlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ResolvedCredential } from './credential-provider.dto.js';
import { CredentialAuditJournal, CredentialJournalError } from './audit-journal.js';
import { parseCredentialEstateRegistry } from './estate-registry.js';
import { FileCredentialStore } from './file-credential-store.js';
import { provisionCredential, revokeCredential, type GiteaLifecycleProvider } from './lifecycle.js';
import {
acquireCredentialLifecycleLock,
CREDENTIAL_LIFECYCLE_LOCK_SECURITY_MODEL,
credentialLifecycleLocksDirectory,
provisionCredential,
revokeCredential,
type GiteaLifecycleProvider,
} from './lifecycle.js';
import { TeaLoginStore } from './tea-login-store.js';
let cleanup: string | undefined;
afterEach(async (): Promise<void> => {
vi.restoreAllMocks();
if (cleanup !== undefined) await rm(cleanup, { recursive: true, force: true });
cleanup = undefined;
});
@@ -88,6 +98,181 @@ function provider(): GiteaLifecycleProvider {
}
describe('credential lifecycle', (): void => {
it('pins lifecycle flock as cooperative serialization rather than authorization', (): void => {
expect(CREDENTIAL_LIFECYCLE_LOCK_SECURITY_MODEL).toStrictEqual({
purpose: 'cooperative-serialization',
authorizationBoundary: 'provider-authority',
generationPreconditions: 'optimistic-cooperating-mutators',
hostileSameUidFilesystem: 'out-of-scope-deferred',
});
});
it('refuses a concurrent same-identity lifecycle mutation across state roots', async (): Promise<void> => {
const { root, store, teaStore } = await fixture();
const lock = await acquireCredentialLifecycleLock('seat', 'homelab', 'git.example.invalid');
try {
const result = await provisionCredential(
{
identity: 'seat',
estate: 'homelab',
host: 'git.example.invalid',
tokenName: 'mosaic-seat-contended',
scopes: ['write:repository'],
},
authority,
provider(),
store,
teaStore,
{
stateRoot: `${root}-other`,
actor: 'seat',
lifecycleLock: {
identity: 'seat',
estate: 'homelab',
host: 'git.example.invalid',
async release(): Promise<void> {},
},
},
);
expect(result).toMatchObject({
outcome: 'refused',
mutation: 'none',
reason: { code: 'concurrent-mutation' },
});
} finally {
await lock.release();
}
});
it('keeps cooperative lifecycle locks below the validated user home, not shared tmp', (): void => {
const locksDirectory = credentialLifecycleLocksDirectory();
expect(locksDirectory).not.toMatch(/^\/tmp(?:\/|$)/);
expect(locksDirectory).toMatch(/\/\.local\/state\/mosaic\/credential-lifecycle-locks$/);
});
it.each([
['../seat', 'homelab', 'git.example.invalid'],
['seat', '../homelab', 'git.example.invalid'],
['seat', 'homelab', '../git.example.invalid'],
])(
'rejects traversal before constructing a lifecycle lock path',
async (identity, estate, host): Promise<void> => {
await expect(acquireCredentialLifecycleLock(identity, estate, host)).rejects.toMatchObject({
code: 'mutation-lock-unavailable',
});
},
);
it('returns a structured error when the lifecycle lock path cannot be opened', async (): Promise<void> => {
const { root, store, teaStore } = await fixture();
const locksDirectory = credentialLifecycleLocksDirectory();
await mkdir(locksDirectory, { recursive: true, mode: 0o700 });
const lockPath = join(locksDirectory, 'homelab--git.example.invalid--open-failure-seat.lock');
await unlink(lockPath).catch((): void => undefined);
await symlink('/dev/null', lockPath);
try {
const result = await provisionCredential(
{
identity: 'open-failure-seat',
estate: 'homelab',
host: 'git.example.invalid',
tokenName: 'mosaic-open-failure-seat',
scopes: ['write:repository'],
},
authority,
provider(),
store,
teaStore,
{ stateRoot: root, actor: 'open-failure-seat' },
);
expect(result).toMatchObject({
outcome: 'error',
mutation: 'none',
reason: { code: 'mutation-lock-unavailable' },
});
} finally {
await unlink(lockPath).catch((): void => undefined);
}
});
it.each(['provision', 'revoke'] as const)(
'returns an open structured %s result when lock failure cannot be sealed',
async (operation): Promise<void> => {
const { root, store, teaStore } = await fixture();
const identity = `seal-failure-${operation}`;
const locksDirectory = credentialLifecycleLocksDirectory();
await mkdir(locksDirectory, { recursive: true, mode: 0o700 });
const lockPath = join(locksDirectory, `homelab--git.example.invalid--${identity}.lock`);
await unlink(lockPath).catch((): void => undefined);
await symlink('/dev/null', lockPath);
vi.spyOn(CredentialAuditJournal.prototype, 'seal').mockRejectedValue(
new CredentialJournalError('journal-recovery-required', 'injected final seal failure'),
);
try {
const result =
operation === 'provision'
? await provisionCredential(
{
identity,
estate: 'homelab',
host: 'git.example.invalid',
tokenName: `mosaic-${identity}`,
scopes: ['write:repository'],
},
{ ...authority, identity },
provider(),
store,
teaStore,
{ stateRoot: root, actor: identity },
)
: await revokeCredential(
{ identity, estate: 'homelab', host: 'git.example.invalid' },
{ ...authority, identity },
provider(),
store,
teaStore,
{ stateRoot: root, actor: identity },
);
expect(result).toMatchObject({
outcome: 'error',
mutation: 'none',
reason: { code: 'journal-recovery-required' },
audit: { state: 'open' },
});
} finally {
await unlink(lockPath).catch((): void => undefined);
}
},
);
it('returns a sealed structured error when the Tea pre-state cannot be snapshotted', async (): Promise<void> => {
const { root, store, teaStore } = await fixture();
await teaStore.put('seat', 'git.example.invalid', new TextEncoder().encode('prior-tea-token'));
await writeFile(join(cleanup!, 'tea', 'config.yml'), 'logins: not-an-array\n', { mode: 0o600 });
const result = await provisionCredential(
{
identity: 'seat',
estate: 'homelab',
host: 'git.example.invalid',
tokenName: 'mosaic-seat-snapshot-failure',
scopes: ['write:repository'],
},
authority,
provider(),
store,
teaStore,
{ stateRoot: root, actor: 'seat' },
);
expect(result).toMatchObject({
outcome: 'error',
mutation: 'none',
reason: { code: 'credential-snapshot-unavailable' },
audit: { state: 'sealed' },
});
});
it('accepts provision only after exact principal and scope read-back', async (): Promise<void> => {
const { root, store, teaStore } = await fixture();
const result = await provisionCredential(
@@ -147,6 +332,209 @@ describe('credential lifecycle', (): void => {
await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual([]);
});
it.each(['store', 'tea'] as const)(
'removes %s state committed before a post-commit storage failure',
async (target): Promise<void> => {
const { root, store, teaStore } = await fixture();
if (target === 'store') {
const put = store.put.bind(store);
store.put = async (binding, secret): Promise<void> => {
await put(binding, secret);
throw new Error('injected store post-commit failure');
};
} else {
const put = teaStore.put.bind(teaStore);
teaStore.put = async (identity, host, secret): Promise<void> => {
await put(identity, host, secret);
throw new Error('injected Tea post-commit failure');
};
}
const result = await provisionCredential(
{
identity: 'seat',
estate: 'homelab',
host: 'git.example.invalid',
tokenName: `mosaic-seat-${target}-fault`,
scopes: ['write:repository'],
},
authority,
provider(),
store,
teaStore,
{ stateRoot: root, actor: 'seat' },
);
expect(result).toMatchObject({ outcome: 'error', mutation: 'none' });
await expect(
store.snapshot('seat', 'homelab', 'git.example.invalid'),
).resolves.toBeUndefined();
expect(teaStore.readBack('seat', 'git.example.invalid')).toBeUndefined();
},
);
it('rejects a stale lifecycle write when a competing generation bypasses serialization', async (): Promise<void> => {
const { root, store, teaStore } = await fixture();
const put = store.put.bind(store);
let injected = false;
store.put = async (binding, secret, expectedGeneration): Promise<void> => {
if (!injected) {
injected = true;
await put(
{
...binding,
tokenName: 'competing-generation',
createdAt: '2026-08-05T00:00:01.000Z',
},
new TextEncoder().encode('competing-token'),
expectedGeneration,
);
}
await put(binding, secret, expectedGeneration);
};
const result = await provisionCredential(
{
identity: 'seat',
estate: 'homelab',
host: 'git.example.invalid',
tokenName: 'stale-generation',
scopes: ['write:repository'],
},
authority,
provider(),
store,
teaStore,
{ stateRoot: root, actor: 'seat' },
);
expect(result).toMatchObject({
outcome: 'indeterminate',
mutation: 'applied',
reason: { code: 'rollback-incomplete' },
});
const current = await store.snapshot('seat', 'homelab', 'git.example.invalid');
try {
expect(current?.binding.tokenName).toBe('competing-generation');
expect(Buffer.from(current?.secret ?? []).toString('utf8')).toBe('competing-token');
} finally {
current?.secret.fill(0);
}
});
it.each(['absent', 'divergent'] as const)(
'restores an independently %s pre-operation Tea state exactly',
async (teaState): Promise<void> => {
const { root, store, teaStore } = await fixture();
await store.put(
{
identity: 'seat',
estate: 'homelab',
host: 'git.example.invalid',
providerLogin: 'seat',
tokenName: 'old-generation',
scopes: ['write:repository'],
createdAt: '2026-08-05T00:00:00.000Z',
},
new TextEncoder().encode('old-store-token'),
);
if (teaState === 'divergent') {
await teaStore.put(
'seat',
'git.example.invalid',
new TextEncoder().encode('divergent-tea-token'),
);
}
const journal = await CredentialAuditJournal.open(root, {
operation: 'provision',
actor: 'seat',
identity: 'seat',
estate: 'homelab',
host: 'git.example.invalid',
repo: null,
});
let evidenceWrites = 0;
const recordProviderEvidence = journal.recordProviderEvidence.bind(journal);
journal.recordProviderEvidence = async (evidence): Promise<void> => {
evidenceWrites += 1;
if (evidenceWrites === 2) throw new Error('injected post-registration failure');
await recordProviderEvidence(evidence);
};
const result = await provisionCredential(
{
identity: 'seat',
estate: 'homelab',
host: 'git.example.invalid',
tokenName: 'replacement-generation',
scopes: ['write:repository'],
},
authority,
provider(),
store,
teaStore,
{ stateRoot: root, actor: 'seat', journal, allowReplace: true },
);
expect(result).toMatchObject({ outcome: 'error', mutation: 'none' });
if (teaState === 'absent') {
expect(teaStore.readBack('seat', 'git.example.invalid')).toBeUndefined();
} else {
expect(
teaStore.matchesSecret(
'seat',
'git.example.invalid',
new TextEncoder().encode('divergent-tea-token'),
),
).toBe(true);
}
},
);
it('reports an incomplete rollback when Tea removal cannot be verified', async (): Promise<void> => {
const { root, store, teaStore } = await fixture();
const journal = await CredentialAuditJournal.open(root, {
operation: 'provision',
actor: 'seat',
identity: 'seat',
estate: 'homelab',
host: 'git.example.invalid',
repo: null,
});
let evidenceWrites = 0;
const recordProviderEvidence = journal.recordProviderEvidence.bind(journal);
journal.recordProviderEvidence = async (evidence): Promise<void> => {
evidenceWrites += 1;
if (evidenceWrites === 2) throw new Error('injected post-registration failure');
await recordProviderEvidence(evidence);
};
teaStore.restore = async (): Promise<void> => {
throw new Error('injected Tea restoration failure');
};
const result = await provisionCredential(
{
identity: 'seat',
estate: 'homelab',
host: 'git.example.invalid',
tokenName: 'mosaic-seat-rollback',
scopes: ['write:repository'],
},
authority,
provider(),
store,
teaStore,
{ stateRoot: root, actor: 'seat', journal },
);
expect(result).toMatchObject({
outcome: 'indeterminate',
mutation: 'applied',
reason: { code: 'rollback-incomplete' },
});
expect(teaStore.readBack('seat', 'git.example.invalid')).toBeDefined();
});
it('revokes at provider before removing the local binding', async (): Promise<void> => {
const { root, store, teaStore } = await fixture();
await provisionCredential(
@@ -181,6 +569,49 @@ describe('credential lifecycle', (): void => {
await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual([]);
});
it('releases an internally owned journal lock after an append failure', async (): Promise<void> => {
const { root, store, teaStore } = await fixture();
await provisionCredential(
{
identity: 'seat',
estate: 'homelab',
host: 'git.example.invalid',
tokenName: 'mosaic-seat-lock-release',
scopes: ['write:repository'],
},
authority,
provider(),
store,
teaStore,
{ stateRoot: root, actor: 'seat' },
);
const recordProviderEvidence = CredentialAuditJournal.prototype.recordProviderEvidence;
CredentialAuditJournal.prototype.recordProviderEvidence = async (): Promise<void> => {
throw new CredentialJournalError('journal-unavailable', 'injected append failure');
};
try {
const result = await revokeCredential(
{ identity: 'seat', estate: 'homelab', host: 'git.example.invalid' },
authority,
provider(),
store,
teaStore,
{ stateRoot: root, actor: 'seat' },
);
expect(result).toMatchObject({
outcome: 'indeterminate',
mutation: 'none',
audit: { state: 'open' },
});
if (result.audit.journalId === null) throw new Error('journal id was not returned');
const lockPath = join(root, 'journal-locks', `${result.audit.journalId}.lock`);
const lockProbe = spawnSync('/usr/bin/flock', ['-n', lockPath, '/usr/bin/true']);
expect(lockProbe.status).toBe(0);
} finally {
CredentialAuditJournal.prototype.recordProviderEvidence = recordProviderEvidence;
}
});
it('preserves the local recovery binding when provider revocation read-back still finds the token', async (): Promise<void> => {
const { root, store, teaStore } = await fixture();
await provisionCredential(
+404 -32
View File
@@ -1,6 +1,13 @@
import { spawnSync } from 'node:child_process';
import { timingSafeEqual } from 'node:crypto';
import { constants, lstatSync } from 'node:fs';
import { open } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { ensureManagedDirectory } from '../fleet/secure-file.js';
import { CredentialAuditJournal, CredentialJournalError } from './audit-journal.js';
import type { ResolvedCredential } from './credential-provider.dto.js';
import type { FileCredentialStore } from './file-credential-store.js';
import { credentialBindingGeneration, type FileCredentialStore } from './file-credential-store.js';
import type { TeaLoginStore } from './tea-login-store.js';
import type {
CredentialLifecycleOperation,
@@ -52,6 +59,137 @@ export interface LifecycleOptions {
readonly allowReplace?: boolean;
readonly journal?: CredentialAuditJournal;
readonly deferSuccessSeal?: boolean;
readonly lifecycleLock?: CredentialLifecycleLock;
readonly expectedStoreGeneration?: string | null;
readonly expectedTeaGeneration?: string | null;
}
export interface CredentialLifecycleLock {
readonly identity: string;
readonly estate: string;
readonly host: string;
release(): Promise<void>;
}
const activeLifecycleLocks = new WeakSet<CredentialLifecycleLock>();
/**
* This flock coordinates cooperating `mosaic cred` processes only. A process
* sharing the Unix uid can replace the pathname/inode, so the lock is never
* authorization evidence. Provider authority is the authorization boundary;
* generation preconditions are optimistic concurrency for cooperating store
* mutators, not atomic CAS against a hostile same-uid filesystem writer.
*/
export const CREDENTIAL_LIFECYCLE_LOCK_SECURITY_MODEL = Object.freeze({
purpose: 'cooperative-serialization',
authorizationBoundary: 'provider-authority',
generationPreconditions: 'optimistic-cooperating-mutators',
hostileSameUidFilesystem: 'out-of-scope-deferred',
} as const);
export class CredentialLifecycleLockError extends Error {
constructor(public readonly code: 'concurrent-mutation' | 'mutation-lock-unavailable') {
super(code);
this.name = 'CredentialLifecycleLockError';
}
}
// Do not promote this advisory lock into a same-uid authorization boundary;
// CREDENTIAL_LIFECYCLE_LOCK_SECURITY_MODEL is a tested public invariant.
export function credentialLifecycleLocksDirectory(): string {
return join(homedir(), '.local', 'state', 'mosaic', 'credential-lifecycle-locks');
}
export async function acquireCredentialLifecycleLock(
identity: string,
estate: string,
host: string,
): Promise<CredentialLifecycleLock> {
if (
!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(identity) ||
!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(estate) ||
!/^[a-z0-9][a-z0-9.-]*$/.test(host)
) {
throw new CredentialLifecycleLockError('mutation-lock-unavailable');
}
const uid = process.getuid?.();
if (uid === undefined) throw new CredentialLifecycleLockError('mutation-lock-unavailable');
const home = homedir();
const homeDirectory = lstatSync(home);
if (
!homeDirectory.isDirectory() ||
homeDirectory.isSymbolicLink() ||
homeDirectory.uid !== uid ||
(homeDirectory.mode & 0o022) !== 0
) {
throw new CredentialLifecycleLockError('mutation-lock-unavailable');
}
const locksDirectory = credentialLifecycleLocksDirectory();
ensureManagedDirectory(home, locksDirectory);
const directory = lstatSync(locksDirectory);
if (
!directory.isDirectory() ||
directory.isSymbolicLink() ||
directory.uid !== uid ||
(directory.mode & 0o077) !== 0
) {
throw new CredentialLifecycleLockError('mutation-lock-unavailable');
}
const lockPath = join(locksDirectory, `${estate}--${host}--${identity}.lock`);
let handle: Awaited<ReturnType<typeof open>> | undefined;
try {
handle = await open(
lockPath,
constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW,
0o600,
);
const file = await handle.stat();
if (!file.isFile() || file.uid !== uid || (file.mode & 0o077) !== 0) {
throw new Error('credential lifecycle lock file is unsafe');
}
} catch {
await handle?.close().catch((): void => undefined);
throw new CredentialLifecycleLockError('mutation-lock-unavailable');
}
if (handle === undefined) throw new CredentialLifecycleLockError('mutation-lock-unavailable');
const acquired = spawnSync('/usr/bin/flock', ['-n', '3'], {
stdio: ['ignore', 'ignore', 'ignore', handle.fd],
});
if (acquired.error !== undefined || acquired.status !== 0) {
await handle.close().catch((): void => undefined);
throw new CredentialLifecycleLockError(
acquired.status === 1 ? 'concurrent-mutation' : 'mutation-lock-unavailable',
);
}
let released = false;
const lock: CredentialLifecycleLock = {
identity,
estate,
host,
async release(): Promise<void> {
if (released) return;
released = true;
activeLifecycleLocks.delete(lock);
await handle.close();
},
};
activeLifecycleLocks.add(lock);
return lock;
}
function holdsCredentialLifecycleLock(
lock: CredentialLifecycleLock | undefined,
identity: string,
estate: string,
host: string,
): boolean {
return (
lock !== undefined &&
activeLifecycleLocks.has(lock) &&
lock.identity === identity &&
lock.estate === estate &&
lock.host === host
);
}
function lifecycleResult(
@@ -90,6 +228,35 @@ function lifecycleResult(
};
}
async function lifecycleLockFailureResult(
operation: 'provision' | 'revoke',
request: LifecycleRequest,
journal: CredentialAuditJournal,
code: 'concurrent-mutation' | 'mutation-lock-unavailable',
): Promise<CredentialLifecycleResultDto> {
try {
await journal.seal(code === 'concurrent-mutation' ? 'refused' : 'error', code);
return lifecycleResult(operation, request, {
outcome: code === 'concurrent-mutation' ? 'refused' : 'error',
mutation: 'none',
code,
message: 'Credential lifecycle mutation lock could not be acquired.',
journalId: journal.journalId(),
auditState: 'sealed',
});
} catch (sealError: unknown) {
await journal.closeIncomplete().catch((): void => undefined);
return lifecycleResult(operation, request, {
outcome: 'error',
mutation: 'none',
code: sealError instanceof CredentialJournalError ? sealError.code : 'journal-unavailable',
message: 'Mutation lock failure could not be sealed durably.',
journalId: journal.journalId(),
auditState: 'open',
});
}
}
async function openLifecycleJournal(
operation: 'provision' | 'rotate' | 'revoke',
request: LifecycleRequest,
@@ -103,8 +270,13 @@ async function openLifecycleJournal(
host: request.host,
repo: null,
});
await journal.recordIntent(`${operation}-requested`);
return journal;
try {
await journal.recordIntent(`${operation}-requested`);
return journal;
} catch (error: unknown) {
await journal.closeIncomplete().catch((): void => undefined);
throw error;
}
}
export async function provisionCredential(
@@ -115,22 +287,124 @@ export async function provisionCredential(
teaStore: TeaLoginStore,
options: LifecycleOptions,
): Promise<CredentialLifecycleResultDto> {
const ownsJournal = options.journal === undefined;
const journal = options.journal ?? (await openLifecycleJournal('provision', request, options));
let ownedLock: CredentialLifecycleLock | undefined;
if (
!holdsCredentialLifecycleLock(
options.lifecycleLock,
request.identity,
request.estate,
request.host,
)
) {
try {
ownedLock = await acquireCredentialLifecycleLock(
request.identity,
request.estate,
request.host,
);
} catch (error: unknown) {
const code =
error instanceof CredentialLifecycleLockError ? error.code : 'mutation-lock-unavailable';
return lifecycleLockFailureResult('provision', request, journal, code);
}
}
try {
return await provisionCredentialLocked(
request,
authority,
provider,
store,
teaStore,
options,
journal,
);
} catch (error: unknown) {
if (ownsJournal) await journal.closeIncomplete().catch((): void => undefined);
throw error;
} finally {
await ownedLock?.release();
}
}
async function provisionCredentialLocked(
request: ProvisionRequest,
authority: ResolvedCredential,
provider: GiteaLifecycleProvider,
store: FileCredentialStore,
teaStore: TeaLoginStore,
options: LifecycleOptions,
journal: CredentialAuditJournal,
): Promise<CredentialLifecycleResultDto> {
let mutation: 'none' | 'unknown' | 'applied' = 'none';
let minted: MintedToken | undefined;
let failureCode = 'mutation-state-unknown';
const prior = await store.snapshot(request.identity, request.estate, request.host);
if (prior !== undefined && options.allowReplace !== true) {
await journal.seal('refused', 'credential-already-exists');
let mintedStoreGeneration: string | undefined;
let prior: Awaited<ReturnType<FileCredentialStore['snapshot']>> = undefined;
let priorTea: ReturnType<TeaLoginStore['snapshot']> = undefined;
try {
prior = await store.snapshot(request.identity, request.estate, request.host);
priorTea = teaStore.snapshot(request.identity, request.host);
} catch {
prior?.secret.fill(0);
priorTea?.secret.fill(0);
try {
await journal.seal('error', 'credential-snapshot-unavailable');
return lifecycleResult('provision', request, {
outcome: 'error',
mutation: 'none',
code: 'credential-snapshot-unavailable',
message: 'Pre-operation credential state could not be snapshotted safely.',
journalId: journal.journalId(),
auditState: 'sealed',
});
} catch (sealError: unknown) {
await journal.closeIncomplete().catch((): void => undefined);
return lifecycleResult('provision', request, {
outcome: 'error',
mutation: 'none',
code: sealError instanceof CredentialJournalError ? sealError.code : 'journal-unavailable',
message: 'Credential snapshot failure could not be sealed durably.',
journalId: journal.journalId(),
auditState: 'open',
});
}
}
if (
(options.expectedStoreGeneration !== undefined &&
(prior?.generation ?? null) !== options.expectedStoreGeneration) ||
(options.expectedTeaGeneration !== undefined &&
(priorTea?.generation ?? null) !== options.expectedTeaGeneration)
) {
prior?.secret.fill(0);
priorTea?.secret.fill(0);
await journal.seal('refused', 'concurrent-mutation');
return lifecycleResult('provision', request, {
outcome: 'refused',
mutation: 'none',
code: 'credential-already-exists',
message: 'A governed credential already exists; use rotate.',
code: 'concurrent-mutation',
message: 'Credential generation changed before the lifecycle transaction began.',
journalId: journal.journalId(),
auditState: 'sealed',
});
}
if (prior !== undefined && options.allowReplace !== true) {
try {
await journal.seal('refused', 'credential-already-exists');
return lifecycleResult('provision', request, {
outcome: 'refused',
mutation: 'none',
code: 'credential-already-exists',
message: 'A governed credential already exists; use rotate.',
journalId: journal.journalId(),
auditState: 'sealed',
});
} finally {
prior.secret.fill(0);
priorTea?.secret.fill(0);
}
}
try {
const identity = await provider.readBasicIdentity(authority);
if (identity.login !== request.identity || authority.identity !== request.identity) {
@@ -166,20 +440,19 @@ export async function provisionCredential(
failureCode = 'scope-not-evaluable';
throw new Error('scope read-back disagreed');
}
await store.put(
{
identity: request.identity,
estate: request.estate,
host: request.host,
providerLogin: identity.login,
tokenName: request.tokenName,
scopes: readBack.scopes,
createdAt: options.now?.() ?? new Date().toISOString(),
},
minted.secret,
);
const mintedBinding = {
identity: request.identity,
estate: request.estate,
host: request.host,
providerLogin: identity.login,
tokenName: request.tokenName,
scopes: readBack.scopes,
createdAt: options.now?.() ?? new Date().toISOString(),
};
mintedStoreGeneration = credentialBindingGeneration(mintedBinding, minted.secret);
await store.put(mintedBinding, minted.secret, prior?.generation ?? null);
await journal.recordMutation('token-binding-stored');
await teaStore.put(request.identity, request.host, minted.secret);
await teaStore.put(request.identity, request.host, minted.secret, priorTea?.generation ?? null);
const teaLogin = teaStore.readBack(request.identity, request.host);
if (
teaLogin === undefined ||
@@ -231,18 +504,67 @@ export async function provisionCredential(
if (await provider.tokenExists(authority, request.identity, request.tokenName)) {
throw new Error('minted token still exists after rollback');
}
if (prior === undefined) {
await store.remove(request.identity, request.estate, request.host);
await teaStore.remove(request.identity, request.host).catch((): void => undefined);
} else {
await store.put(prior.binding, prior.secret);
await teaStore.put(request.identity, request.host, prior.secret);
const expectedStoreGeneration = prior?.generation ?? null;
const current = await store.snapshot(request.identity, request.estate, request.host);
try {
if ((current?.generation ?? null) !== expectedStoreGeneration) {
if (
mintedStoreGeneration === undefined ||
current?.generation !== mintedStoreGeneration
) {
throw new Error('credential generation changed during rollback');
}
if (prior === undefined) {
await store.remove(
request.identity,
request.estate,
request.host,
mintedStoreGeneration,
);
} else {
await store.put(prior.binding, prior.secret, mintedStoreGeneration);
}
}
} finally {
current?.secret.fill(0);
}
const expectedTeaGeneration = priorTea?.generation ?? null;
const currentTea = teaStore.snapshot(request.identity, request.host);
try {
if ((currentTea?.generation ?? null) !== expectedTeaGeneration) {
if (
currentTea === undefined ||
currentTea.secret.byteLength !== minted.secret.byteLength ||
!timingSafeEqual(Buffer.from(currentTea.secret), Buffer.from(minted.secret))
) {
throw new Error('Tea login generation changed during rollback');
}
await teaStore.restore(request.identity, request.host, priorTea, currentTea.generation);
}
} finally {
currentTea?.secret.fill(0);
}
const restored = await store.snapshot(request.identity, request.estate, request.host);
try {
const storeRestored =
prior === undefined
? restored === undefined
: restored !== undefined &&
JSON.stringify(restored.binding) === JSON.stringify(prior.binding) &&
restored.secret.byteLength === prior.secret.byteLength &&
timingSafeEqual(Buffer.from(restored.secret), Buffer.from(prior.secret));
if (!storeRestored || !teaStore.matchesSnapshot(request.identity, request.host, priorTea)) {
throw new Error('pre-operation credential state was not restored exactly');
}
} finally {
restored?.secret.fill(0);
}
rollbackComplete = true;
} catch {
rollbackComplete = false;
} finally {
prior?.secret.fill(0);
priorTea?.secret.fill(0);
}
if (journalFailure) {
if (rollbackComplete) {
@@ -266,6 +588,7 @@ export async function provisionCredential(
} finally {
minted?.secret.fill(0);
prior?.secret.fill(0);
priorTea?.secret.fill(0);
}
}
@@ -278,10 +601,48 @@ export async function revokeCredential(
options: LifecycleOptions,
): Promise<CredentialLifecycleResultDto> {
const journal = await openLifecycleJournal('revoke', request, options);
let mutation: 'none' | 'unknown' | 'applied' = 'none';
let ownedLock: CredentialLifecycleLock | undefined;
if (
!holdsCredentialLifecycleLock(
options.lifecycleLock,
request.identity,
request.estate,
request.host,
)
) {
try {
ownedLock = await acquireCredentialLifecycleLock(
request.identity,
request.estate,
request.host,
);
} catch (error: unknown) {
const code =
error instanceof CredentialLifecycleLockError ? error.code : 'mutation-lock-unavailable';
return lifecycleLockFailureResult('revoke', request, journal, code);
}
}
try {
const binding = await store.readBinding(request.identity, request.estate, request.host);
if (binding === undefined) {
return await revokeCredentialLocked(request, authority, provider, store, teaStore, journal);
} finally {
await ownedLock?.release();
}
}
async function revokeCredentialLocked(
request: LifecycleRequest,
authority: ResolvedCredential,
provider: GiteaLifecycleProvider,
store: FileCredentialStore,
teaStore: TeaLoginStore,
journal: CredentialAuditJournal,
): Promise<CredentialLifecycleResultDto> {
let mutation: 'none' | 'unknown' | 'applied' = 'none';
let credentialSnapshot: Awaited<ReturnType<FileCredentialStore['snapshot']>> = undefined;
let teaSnapshot: ReturnType<TeaLoginStore['snapshot']> = undefined;
try {
credentialSnapshot = await store.snapshot(request.identity, request.estate, request.host);
if (credentialSnapshot === undefined) {
await journal.seal('refused', 'no-token-for-identity');
return lifecycleResult('revoke', request, {
outcome: 'refused',
@@ -292,6 +653,8 @@ export async function revokeCredential(
auditState: 'sealed',
});
}
const binding = credentialSnapshot.binding;
teaSnapshot = teaStore.snapshot(request.identity, request.host);
const identity = await provider.readBasicIdentity(authority);
if (identity.login !== request.identity || authority.identity !== request.identity) {
await journal.seal('refused', 'provider-identity-mismatch');
@@ -325,12 +688,17 @@ export async function revokeCredential(
auditState: 'sealed',
});
}
await teaStore.remove(request.identity, request.host);
await teaStore.remove(request.identity, request.host, teaSnapshot?.generation ?? null);
if (teaStore.readBack(request.identity, request.host) !== undefined) {
throw new Error('Tea login still exists after revocation');
}
await journal.recordMutation('tea-login-removed');
await store.remove(request.identity, request.estate, request.host, binding.tokenDigest);
await store.remove(
request.identity,
request.estate,
request.host,
credentialSnapshot.generation,
);
await journal.seal('ok', 'revoke-verified');
return lifecycleResult('revoke', request, {
outcome: 'ok',
@@ -342,6 +710,7 @@ export async function revokeCredential(
});
} catch (error: unknown) {
if (error instanceof CredentialJournalError) {
await journal.closeIncomplete().catch((): void => undefined);
return lifecycleResult('revoke', request, {
outcome: 'indeterminate',
mutation,
@@ -360,5 +729,8 @@ export async function revokeCredential(
journalId: journal.journalId(),
auditState: 'sealed',
});
} finally {
credentialSnapshot?.secret.fill(0);
teaSnapshot?.secret.fill(0);
}
}
@@ -1,4 +1,4 @@
import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { chmod, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
@@ -33,6 +33,58 @@ describe('host-bound Tea login store', (): void => {
});
});
it('snapshots and restores the exact host-bound Tea record fields', async (): Promise<void> => {
root = await mkdtemp(join(tmpdir(), 'mosaic-tea-store-'));
const configPath = join(root, 'tea', 'config.yml');
const store = new TeaLoginStore(configPath);
await store.put('seat', 'git.one.invalid', new TextEncoder().encode('original-token'));
const customized = (await readFile(configPath, 'utf8')).replace(
'default: false',
'default: true\n extension-field: preserved',
);
await writeFile(configPath, customized, { mode: 0o600 });
const snapshot = store.snapshot('seat', 'git.one.invalid');
expect(snapshot).toBeDefined();
await store.put('seat', 'git.one.invalid', new TextEncoder().encode('replacement-token'));
await store.restore('seat', 'git.one.invalid', snapshot);
expect(store.matchesSnapshot('seat', 'git.one.invalid', snapshot)).toBe(true);
expect(await readFile(configPath, 'utf8')).toContain('extension-field: preserved');
snapshot?.secret.fill(0);
});
it.each(['put', 'remove'] as const)(
'removes secret-bearing temporary files when %s fails before rename',
async (operation): Promise<void> => {
root = await mkdtemp(join(tmpdir(), 'mosaic-tea-store-'));
const configPath = join(root, 'tea', 'config.yml');
const baseline = new TeaLoginStore(configPath);
if (operation === 'remove') {
await baseline.put('seat', 'git.one.invalid', new TextEncoder().encode('token-one'));
}
const store = new TeaLoginStore(configPath, {
beforeRename: async (candidate): Promise<void> => {
if (candidate === operation) throw new Error('injected pre-rename failure');
},
});
if (operation === 'put') {
await expect(
store.put('seat', 'git.one.invalid', new TextEncoder().encode('token-one')),
).rejects.toThrow('injected pre-rename failure');
} else {
await expect(store.remove('seat', 'git.one.invalid')).rejects.toThrow(
'injected pre-rename failure',
);
}
expect((await readdir(join(root, 'tea'))).filter((name) => name.endsWith('.tmp'))).toEqual(
[],
);
},
);
it('preserves unrelated Tea configuration and rejects permissive secret reads', async (): Promise<void> => {
root = await mkdtemp(join(tmpdir(), 'mosaic-tea-store-'));
const configPath = join(root, 'tea', 'config.yml');
@@ -1,6 +1,7 @@
import { randomUUID, timingSafeEqual } from 'node:crypto';
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
import { open, rename, unlink } from 'node:fs/promises';
import { dirname } from 'node:path';
import { isDeepStrictEqual } from 'node:util';
import { parse, stringify } from 'yaml';
import { z } from 'zod';
import { ensureManagedDirectory, readRegularFileSecure } from '../fleet/secure-file.js';
@@ -24,6 +25,7 @@ interface TeaLoginRecord {
readonly token: string;
readonly user: string;
readonly default: boolean;
readonly [key: string]: unknown;
}
interface TeaConfig {
@@ -82,10 +84,95 @@ function missing(error: unknown): boolean {
return error instanceof Error && 'code' in error && error.code === 'ENOENT';
}
export class TeaLoginStore {
constructor(private readonly configPath: string) {}
export interface TeaLoginSnapshot {
readonly fields: Readonly<Record<string, unknown>>;
readonly secret: Uint8Array;
readonly generation: string;
}
async put(identity: string, host: string, secret: Uint8Array): Promise<void> {
function snapshotFromConfig(
config: TeaConfig,
identity: string,
host: string,
): TeaLoginSnapshot | undefined {
const matches = config.logins.filter(
(login): boolean => login.name === loginName(identity, host) && login.url === `https://${host}`,
);
if (matches.length === 0) return undefined;
if (matches.length !== 1 || matches[0] === undefined) {
throw new TeaLoginStoreError('tea-config-invalid', 'Tea login binding is ambiguous');
}
const { token, ...fields } = matches[0];
const secret = new TextEncoder().encode(token);
const generation = createHash('sha256')
.update(JSON.stringify(fields))
.update('\0')
.update(secret)
.digest('hex');
return { fields: structuredClone(fields), secret, generation };
}
function assertExpectedGeneration(
config: TeaConfig,
identity: string,
host: string,
expectedGeneration: string | null | undefined,
): void {
if (expectedGeneration === undefined) return;
const current = snapshotFromConfig(config, identity, host);
try {
if ((current?.generation ?? null) !== expectedGeneration) {
throw new TeaLoginStoreError(
'tea-generation-mismatch',
'Tea login generation changed before mutation',
);
}
} finally {
current?.secret.fill(0);
}
}
export interface TeaLoginStoreRuntimeOptions {
readonly beforeRename?: (
operation: 'put' | 'remove' | 'restore',
tempPath: string,
) => Promise<void>;
}
export class TeaLoginStore {
constructor(
private readonly configPath: string,
private readonly runtime: TeaLoginStoreRuntimeOptions = {},
) {}
private async commit(
operation: 'put' | 'remove' | 'restore',
config: TeaConfig,
directory: string,
): Promise<void> {
const temp = `${this.configPath}.${randomUUID()}.tmp`;
try {
const handle = await open(temp, 'wx', 0o600);
try {
await handle.writeFile(stringify(config), 'utf8');
await handle.sync();
} finally {
await handle.close();
}
await this.runtime.beforeRename?.(operation, temp);
await rename(temp, this.configPath);
await syncDirectory(directory);
} finally {
await unlink(temp).catch((): void => undefined);
}
}
async put(
identity: string,
host: string,
secret: Uint8Array,
expectedGeneration?: string | null,
): Promise<void> {
if (!SAFE_NAME.test(identity) || !/^[a-z0-9][a-z0-9.-]*$/.test(host)) {
throw new TeaLoginStoreError('invalid-input', 'identity or host is outside the grammar');
}
@@ -109,6 +196,7 @@ export class TeaLoginStore {
} catch (error: unknown) {
if (!missing(error)) throw error;
}
assertExpectedGeneration(current, identity, host, expectedGeneration);
const token = Buffer.from(secret).toString('utf8');
const record: TeaLoginRecord = {
name: loginName(identity, host),
@@ -122,16 +210,100 @@ export class TeaLoginStore {
!(login.name === loginName(identity, host) && login.url === `https://${host}`),
);
logins.push(record);
const temp = `${this.configPath}.${randomUUID()}.tmp`;
const handle = await open(temp, 'wx', 0o600);
await this.commit('put', { ...current, logins }, directory);
} finally {
await lock.close();
await unlink(lockPath).catch((): void => undefined);
}
}
snapshot(identity: string, host: string): TeaLoginSnapshot | undefined {
const directory = dirname(this.configPath);
let snapshot;
try {
snapshot = readRegularFileSecure(this.configPath, {
root: directory,
maxBytes: 1024 * 1024,
});
} catch (error: unknown) {
if (missing(error)) return undefined;
throw error;
}
assertPrivate(snapshot);
const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8')));
if (!decoded.success) {
throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation');
}
return snapshotFromConfig(decoded.data, identity, host);
}
matchesSnapshot(identity: string, host: string, expected: TeaLoginSnapshot | undefined): boolean {
const actual = this.snapshot(identity, host);
try {
if (actual === undefined || expected === undefined) return actual === expected;
return (
isDeepStrictEqual(actual.fields, expected.fields) &&
actual.secret.byteLength === expected.secret.byteLength &&
timingSafeEqual(Buffer.from(actual.secret), Buffer.from(expected.secret))
);
} finally {
actual?.secret.fill(0);
}
}
async restore(
identity: string,
host: string,
snapshot: TeaLoginSnapshot | undefined,
expectedGeneration?: string | null,
): Promise<void> {
if (!SAFE_NAME.test(identity) || !/^[a-z0-9][a-z0-9.-]*$/.test(host)) {
throw new TeaLoginStoreError('invalid-input', 'identity or host is outside the grammar');
}
const directory = dirname(this.configPath);
ensureManagedDirectory(directory, directory);
const lockPath = `${this.configPath}.lock`;
const lock = await acquireLock(lockPath);
try {
let current: TeaConfig = { logins: [] };
try {
await handle.writeFile(stringify({ ...current, logins }), 'utf8');
await handle.sync();
} finally {
await handle.close();
const currentSnapshot = readRegularFileSecure(this.configPath, {
root: directory,
maxBytes: 1024 * 1024,
});
assertPrivate(currentSnapshot);
const decoded = configSchema.safeParse(parse(currentSnapshot.content.toString('utf8')));
if (!decoded.success) {
throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation');
}
current = decoded.data;
} catch (error: unknown) {
if (!missing(error)) throw error;
if (
snapshot === undefined &&
(expectedGeneration === undefined || expectedGeneration === null)
)
return;
}
await rename(temp, this.configPath);
await syncDirectory(directory);
assertExpectedGeneration(current, identity, host, expectedGeneration);
const logins = current.logins.filter(
(login): boolean =>
!(login.name === loginName(identity, host) && login.url === `https://${host}`),
);
if (snapshot !== undefined) {
const restored = loginSchema.parse({
...structuredClone(snapshot.fields),
token: Buffer.from(snapshot.secret).toString('utf8'),
});
if (restored.name !== loginName(identity, host) || restored.url !== `https://${host}`) {
throw new TeaLoginStoreError(
'tea-config-invalid',
'Tea snapshot does not match the requested identity and host',
);
}
logins.push(restored);
}
await this.commit('restore', { ...current, logins }, directory);
} finally {
await lock.close();
await unlink(lockPath).catch((): void => undefined);
@@ -175,34 +347,40 @@ export class TeaLoginStore {
return timingSafeEqual(Buffer.from(resolved.secret), Buffer.from(secret));
}
async remove(identity: string, host: string): Promise<void> {
async remove(identity: string, host: string, expectedGeneration?: string | null): Promise<void> {
const directory = dirname(this.configPath);
const lockPath = `${this.configPath}.lock`;
const lock = await acquireLock(lockPath);
try {
const snapshot = readRegularFileSecure(this.configPath, {
root: directory,
maxBytes: 1024 * 1024,
});
let snapshot;
try {
snapshot = readRegularFileSecure(this.configPath, {
root: directory,
maxBytes: 1024 * 1024,
});
} catch (error: unknown) {
if (missing(error)) {
if (expectedGeneration !== undefined && expectedGeneration !== null) {
throw new TeaLoginStoreError(
'tea-generation-mismatch',
'Tea login generation changed before removal',
);
}
return;
}
throw error;
}
assertPrivate(snapshot);
const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8')));
if (!decoded.success) {
throw new TeaLoginStoreError('tea-config-invalid', 'Tea config failed schema validation');
}
assertExpectedGeneration(decoded.data, identity, host, expectedGeneration);
const logins = decoded.data.logins.filter(
(login): boolean =>
!(login.name === loginName(identity, host) && login.url === `https://${host}`),
);
const temp = `${this.configPath}.${randomUUID()}.tmp`;
const handle = await open(temp, 'wx', 0o600);
try {
await handle.writeFile(stringify({ ...decoded.data, logins }), 'utf8');
await handle.sync();
} finally {
await handle.close();
}
await rename(temp, this.configPath);
await syncDirectory(directory);
await this.commit('remove', { ...decoded.data, logins }, directory);
} finally {
await lock.close();
await unlink(lockPath).catch((): void => undefined);