This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user