fix(mosaic): close credential evidence gaps
This commit is contained in:
@@ -131,6 +131,11 @@ function errorResult(
|
||||
},
|
||||
evidence: {
|
||||
providerIdentity: null,
|
||||
tokenCapabilities: {
|
||||
state: 'not-measured',
|
||||
scopes: [],
|
||||
source: 'runtime-not-authorized',
|
||||
},
|
||||
repositoryPermission: null,
|
||||
writeDifferential: null,
|
||||
},
|
||||
@@ -220,6 +225,11 @@ function grantErrorResult(
|
||||
},
|
||||
evidence: {
|
||||
providerIdentity: null,
|
||||
tokenCapabilities: {
|
||||
state: 'not-measured',
|
||||
scopes: [],
|
||||
source: 'runtime-not-authorized',
|
||||
},
|
||||
repositoryPermission: null,
|
||||
writeDifferential: null,
|
||||
collaboratorPermission: null,
|
||||
@@ -586,6 +596,22 @@ export async function executeCredentialWire(
|
||||
});
|
||||
}
|
||||
const locations = lifecycleLocations(options);
|
||||
try {
|
||||
const context = await lifecycleContext(options);
|
||||
if (context.registry.resolve(options.estate, options.host) === undefined) {
|
||||
return localLifecycleResult('wire', identity, options, {
|
||||
outcome: 'refused',
|
||||
code: 'estate-host-mismatch',
|
||||
message: 'Declared estate does not map exactly to the declared host.',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
return localLifecycleResult('wire', identity, options, {
|
||||
outcome: 'refused',
|
||||
code: 'estate-host-mismatch',
|
||||
message: 'Declared estate and host could not be resolved before mutation.',
|
||||
});
|
||||
}
|
||||
const journal = await CredentialAuditJournal.open(locations.stateRoot, {
|
||||
operation: 'wire',
|
||||
actor: options.actor,
|
||||
@@ -613,9 +639,15 @@ export async function executeCredentialWire(
|
||||
.split(/\r?\n/)
|
||||
.filter(
|
||||
(line): boolean =>
|
||||
!line.startsWith('MOSAIC_GIT_IDENTITY=') && !line.startsWith('GITEA_LOGIN='),
|
||||
!line.startsWith('MOSAIC_GIT_IDENTITY=') &&
|
||||
!line.startsWith('MOSAIC_CREDENTIAL_ESTATE=') &&
|
||||
!line.startsWith('GITEA_LOGIN='),
|
||||
);
|
||||
lines.push(`MOSAIC_GIT_IDENTITY=${identity}`, `GITEA_LOGIN=${identity}`);
|
||||
lines.push(
|
||||
`MOSAIC_GIT_IDENTITY=${identity}`,
|
||||
`MOSAIC_CREDENTIAL_ESTATE=${options.estate}`,
|
||||
`GITEA_LOGIN=${identity}`,
|
||||
);
|
||||
const temp = `${options.seatEnv}.${process.pid.toString()}.tmp`;
|
||||
const handle = await open(temp, 'wx', 0o600);
|
||||
try {
|
||||
@@ -724,43 +756,88 @@ export async function executeCredentialGet(
|
||||
}
|
||||
}
|
||||
|
||||
async function executeAuthorizedInventoryRead(
|
||||
operation: 'list' | 'audit',
|
||||
options: CredentialLifecycleCommandOptions,
|
||||
): Promise<CredentialLifecycleResultDto> {
|
||||
const locations = lifecycleLocations(options);
|
||||
const journal = await CredentialAuditJournal.open(locations.stateRoot, {
|
||||
operation,
|
||||
actor: options.actor,
|
||||
identity: options.actor,
|
||||
estate: options.estate,
|
||||
host: options.host,
|
||||
repo: null,
|
||||
});
|
||||
await journal.recordIntent(`${operation}-requested`);
|
||||
try {
|
||||
if (options.authorityFd === undefined) {
|
||||
await journal.seal('refused', 'authority-required');
|
||||
return localLifecycleResult(operation, 'all', options, {
|
||||
outcome: 'refused',
|
||||
code: 'authority-required',
|
||||
message: 'A protected delegated inventory authority is required.',
|
||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||
});
|
||||
}
|
||||
const context = await lifecycleContext(options);
|
||||
const authority = await lifecycleAuthority(options.actor, options);
|
||||
const providerIdentity = await context.provider.readIdentity(authority);
|
||||
if (providerIdentity.login !== options.actor) {
|
||||
await journal.seal('refused', 'provider-identity-mismatch');
|
||||
return localLifecycleResult(operation, 'all', options, {
|
||||
outcome: 'refused',
|
||||
code: 'provider-identity-mismatch',
|
||||
message: 'Delegated inventory authority did not match the explicit actor.',
|
||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||
});
|
||||
}
|
||||
const identities =
|
||||
operation === 'list' ? await context.store.list(options.estate, options.host) : [];
|
||||
const journals = operation === 'audit' ? await listCredentialJournals(locations.stateRoot) : [];
|
||||
await journal.recordProviderEvidence({
|
||||
endpoint: providerIdentity.endpoint,
|
||||
contentType: providerIdentity.contentType,
|
||||
decision: 'inventory-authority-verified',
|
||||
});
|
||||
await journal.seal('ok', `${operation}-verified`);
|
||||
return localLifecycleResult(operation, 'all', options, {
|
||||
outcome: 'ok',
|
||||
code: `${operation}-verified`,
|
||||
message:
|
||||
operation === 'list'
|
||||
? 'Governed credential bindings were listed under delegated authority.'
|
||||
: 'Durable credential journal identifiers were read under delegated authority.',
|
||||
evidence: {
|
||||
providerIdentity: providerIdentity.login,
|
||||
token: null,
|
||||
teaLogin: null,
|
||||
identities,
|
||||
journalIds: journals.map((entry): string => entry.id),
|
||||
},
|
||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||
});
|
||||
} catch {
|
||||
await journal.seal('error', 'inventory-read-failed');
|
||||
return localLifecycleResult(operation, 'all', options, {
|
||||
outcome: 'error',
|
||||
code: 'inventory-read-failed',
|
||||
message: 'Authorized credential inventory read failed.',
|
||||
audit: { journalId: journal.journalId(), state: 'sealed' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeCredentialList(
|
||||
options: CredentialLifecycleCommandOptions,
|
||||
): Promise<CredentialLifecycleResultDto> {
|
||||
try {
|
||||
const context = await lifecycleContext(options);
|
||||
const identities = await context.store.list(options.estate, options.host);
|
||||
return localLifecycleResult('list', 'all', options, {
|
||||
outcome: 'ok',
|
||||
code: 'list-verified',
|
||||
message: 'Governed credential bindings were listed without secrets.',
|
||||
evidence: { providerIdentity: null, token: null, teaLogin: null, identities, journalIds: [] },
|
||||
});
|
||||
} catch {
|
||||
return localLifecycleResult('list', 'all', options, {
|
||||
outcome: 'error',
|
||||
code: 'internal-invariant',
|
||||
message: 'Credential listing failed.',
|
||||
});
|
||||
}
|
||||
return executeAuthorizedInventoryRead('list', options);
|
||||
}
|
||||
|
||||
export async function executeCredentialAudit(
|
||||
options: CredentialLifecycleCommandOptions,
|
||||
): Promise<CredentialLifecycleResultDto> {
|
||||
const journals = await listCredentialJournals(lifecycleLocations(options).stateRoot);
|
||||
return localLifecycleResult('audit', 'all', options, {
|
||||
outcome: 'ok',
|
||||
code: 'audit-verified',
|
||||
message: 'Durable journal index was read.',
|
||||
evidence: {
|
||||
providerIdentity: null,
|
||||
token: null,
|
||||
teaLogin: null,
|
||||
identities: [],
|
||||
journalIds: journals.map((journal): string => journal.id),
|
||||
},
|
||||
});
|
||||
return executeAuthorizedInventoryRead('audit', options);
|
||||
}
|
||||
|
||||
type PrintableCredentialResult = Pick<
|
||||
@@ -885,7 +962,8 @@ export function registerCredentialCommand(parent: Command): void {
|
||||
.description('List governed identities without reading or printing secret material')
|
||||
.requiredOption('--estate <estate>', 'Explicit target estate')
|
||||
.requiredOption('--host <host>', 'Explicit provider host')
|
||||
.requiredOption('--actor <identity>', 'Explicit audit actor')
|
||||
.requiredOption('--actor <identity>', 'Explicit delegated inventory authority identity')
|
||||
.requiredOption('--authority-fd <fd>', 'Inherited protected Basic credential fd')
|
||||
.option('--registry <path>', 'Strict non-secret estate registry')
|
||||
.option('--token-dir <path>', 'Governed token directory')
|
||||
.option('--state-dir <path>', 'Durable credential journal root')
|
||||
@@ -901,7 +979,9 @@ export function registerCredentialCommand(parent: Command): void {
|
||||
.description('List durable credential journals without secret-bearing payloads')
|
||||
.requiredOption('--estate <estate>', 'Explicit target estate')
|
||||
.requiredOption('--host <host>', 'Explicit provider host')
|
||||
.requiredOption('--actor <identity>', 'Explicit audit actor')
|
||||
.requiredOption('--actor <identity>', 'Explicit delegated inventory authority identity')
|
||||
.requiredOption('--authority-fd <fd>', 'Inherited protected Basic credential fd')
|
||||
.option('--registry <path>', 'Strict non-secret estate registry')
|
||||
.option('--state-dir <path>', 'Durable credential journal root')
|
||||
.option('--json', 'Emit one machine result object')
|
||||
.action(async (options: CredentialLifecycleCommandOptions): Promise<void> => {
|
||||
|
||||
@@ -25,6 +25,7 @@ const SAFE_DECISIONS = new Set<string>([
|
||||
'permission-write',
|
||||
'permission-admin',
|
||||
'identity-verified',
|
||||
'inventory-authority-verified',
|
||||
'scope-verified',
|
||||
'grant-verified',
|
||||
'revoke-verified',
|
||||
|
||||
@@ -52,8 +52,15 @@ export interface WriteDifferentialEvidenceDto {
|
||||
readonly doesNotProve: string;
|
||||
}
|
||||
|
||||
export interface TokenCapabilitiesEvidenceDto {
|
||||
readonly state: 'measured' | 'not-measured';
|
||||
readonly scopes: readonly string[];
|
||||
readonly source: 'provider-token-object' | 'runtime-not-authorized';
|
||||
}
|
||||
|
||||
export interface CredentialValidationEvidenceDto {
|
||||
readonly providerIdentity: ProviderIdentityEvidenceDto | null;
|
||||
readonly tokenCapabilities: TokenCapabilitiesEvidenceDto;
|
||||
readonly repositoryPermission: RepositoryPermissionEvidenceDto | null;
|
||||
readonly writeDifferential: WriteDifferentialEvidenceDto | null;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { chmod, mkdir, symlink, writeFile } from 'node:fs/promises';
|
||||
import { chmod, copyFile, mkdir, symlink, unlink, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { mkdtemp } from 'node:fs/promises';
|
||||
@@ -132,6 +132,18 @@ describe('phase-1 governed file credential resolver', (): void => {
|
||||
'git.example.invalid',
|
||||
),
|
||||
).resolves.toMatchObject({ identity: 'seat' });
|
||||
await copyFile(
|
||||
join(root, 'gitea-example-seat.credential.json'),
|
||||
join(root, 'gitea-example-other.credential.json'),
|
||||
);
|
||||
await expect(
|
||||
new FileCredentialResolver(root, registry()).resolve(
|
||||
'other',
|
||||
'homelab',
|
||||
'git.example.invalid',
|
||||
),
|
||||
).rejects.toThrow(/credential-binding-mismatch/);
|
||||
await unlink(join(root, 'gitea-example-other.credential.json'));
|
||||
await store.remove('seat', 'homelab', 'git.example.invalid');
|
||||
await expect(store.list('homelab', 'git.example.invalid')).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
@@ -127,6 +127,17 @@ export class FileCredentialResolver implements CredentialResolver {
|
||||
'credential envelope failed schema, owner, or mode validation',
|
||||
);
|
||||
}
|
||||
if (
|
||||
envelope.data.identity !== identity ||
|
||||
envelope.data.estate !== estate ||
|
||||
envelope.data.host !== host ||
|
||||
envelope.data.providerLogin !== identity
|
||||
) {
|
||||
throw new CredentialStoreError(
|
||||
'credential-binding-mismatch',
|
||||
'credential envelope does not match the requested identity, estate, host, and principal',
|
||||
);
|
||||
}
|
||||
const secret = validateSecret(Buffer.from(envelope.data.token, 'utf8'));
|
||||
const digest = createHash('sha256').update(secret).digest('hex');
|
||||
if (envelope.data.tokenDigest !== digest) {
|
||||
|
||||
@@ -98,6 +98,11 @@ export async function grantDirectRepositoryPermission(
|
||||
},
|
||||
evidence: {
|
||||
providerIdentity: authorityIdentity,
|
||||
tokenCapabilities: {
|
||||
state: 'not-measured',
|
||||
scopes: [],
|
||||
source: 'runtime-not-authorized',
|
||||
},
|
||||
repositoryPermission: null,
|
||||
writeDifferential: null,
|
||||
collaboratorPermission: null,
|
||||
@@ -241,6 +246,11 @@ export async function grantDirectRepositoryPermission(
|
||||
},
|
||||
evidence: {
|
||||
providerIdentity: null,
|
||||
tokenCapabilities: {
|
||||
state: 'not-measured',
|
||||
scopes: [],
|
||||
source: 'runtime-not-authorized',
|
||||
},
|
||||
repositoryPermission: null,
|
||||
writeDifferential: null,
|
||||
collaboratorPermission: null,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
@@ -32,4 +32,23 @@ describe('host-bound Tea login store', (): void => {
|
||||
host: 'git.two.invalid',
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
const store = new TeaLoginStore(configPath);
|
||||
await store.put('seat', 'git.one.invalid', new TextEncoder().encode('token-one'));
|
||||
const original = await readFile(configPath, 'utf8');
|
||||
await writeFile(configPath, `preferences:\n color: true\n${original}`, { mode: 0o600 });
|
||||
|
||||
await store.put('seat', 'git.two.invalid', new TextEncoder().encode('token-two'));
|
||||
await store.remove('seat', 'git.one.invalid');
|
||||
expect(await readFile(configPath, 'utf8')).toContain('color: true');
|
||||
|
||||
await chmod(configPath, 0o644);
|
||||
expect(() => store.resolve('seat', 'homelab', 'git.two.invalid')).toThrow(
|
||||
/tea-config-insecure/,
|
||||
);
|
||||
expect(() => store.readBack('seat', 'git.two.invalid')).toThrow(/tea-config-insecure/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ interface TeaLoginRecord {
|
||||
|
||||
interface TeaConfig {
|
||||
readonly logins: TeaLoginRecord[];
|
||||
readonly [key: string]: unknown;
|
||||
}
|
||||
|
||||
const loginSchema = z
|
||||
@@ -58,6 +59,12 @@ async function acquireLock(path: string): Promise<Awaited<ReturnType<typeof open
|
||||
);
|
||||
}
|
||||
|
||||
function assertPrivate(snapshot: { readonly mode: number; readonly uid: number }): void {
|
||||
if ((snapshot.mode & 0o077) !== 0 || snapshot.uid !== process.getuid?.()) {
|
||||
throw new TeaLoginStoreError('tea-config-insecure', 'Tea config is not private');
|
||||
}
|
||||
}
|
||||
|
||||
function missing(error: unknown): boolean {
|
||||
return error instanceof Error && 'code' in error && error.code === 'ENOENT';
|
||||
}
|
||||
@@ -80,14 +87,12 @@ export class TeaLoginStore {
|
||||
root: directory,
|
||||
maxBytes: 1024 * 1024,
|
||||
});
|
||||
if ((snapshot.mode & 0o077) !== 0 || snapshot.uid !== process.getuid?.()) {
|
||||
throw new TeaLoginStoreError('tea-config-insecure', 'Tea config is not private');
|
||||
}
|
||||
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');
|
||||
}
|
||||
current = { logins: decoded.data.logins };
|
||||
current = decoded.data;
|
||||
} catch (error: unknown) {
|
||||
if (!missing(error)) throw error;
|
||||
}
|
||||
@@ -130,6 +135,7 @@ export class TeaLoginStore {
|
||||
if (missing(error)) return undefined;
|
||||
throw error;
|
||||
}
|
||||
assertPrivate(snapshot);
|
||||
const decoded = configSchema.safeParse(parse(snapshot.content.toString('utf8')));
|
||||
if (!decoded.success) return undefined;
|
||||
const matches = decoded.data.logins.filter(
|
||||
@@ -161,6 +167,7 @@ export class TeaLoginStore {
|
||||
root: directory,
|
||||
maxBytes: 1024 * 1024,
|
||||
});
|
||||
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');
|
||||
@@ -171,7 +178,7 @@ export class TeaLoginStore {
|
||||
const temp = `${this.configPath}.${randomUUID()}.tmp`;
|
||||
const handle = await open(temp, 'wx', 0o600);
|
||||
try {
|
||||
await handle.writeFile(stringify({ logins }), 'utf8');
|
||||
await handle.writeFile(stringify({ ...decoded.data, logins }), 'utf8');
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
@@ -195,6 +202,7 @@ export class TeaLoginStore {
|
||||
if (missing(error)) return undefined;
|
||||
throw error;
|
||||
}
|
||||
assertPrivate(snapshot);
|
||||
const decoded: unknown = parse(snapshot.content.toString('utf8'));
|
||||
if (
|
||||
typeof decoded !== 'object' ||
|
||||
|
||||
@@ -289,6 +289,11 @@ function result(
|
||||
},
|
||||
evidence: {
|
||||
providerIdentity: validation?.evidence.providerIdentity ?? null,
|
||||
tokenCapabilities: validation?.evidence.tokenCapabilities ?? {
|
||||
state: 'not-measured',
|
||||
scopes: [],
|
||||
source: 'runtime-not-authorized',
|
||||
},
|
||||
repositoryPermission: validation?.evidence.repositoryPermission ?? null,
|
||||
writeDifferential: validation?.evidence.writeDifferential ?? null,
|
||||
collaboratorPermission: null,
|
||||
|
||||
@@ -32,6 +32,11 @@ export type {
|
||||
} from './credential-result.dto.js';
|
||||
|
||||
const JSON_CONTENT_TYPE = 'application/json';
|
||||
const RUNTIME_SCOPE_NOT_MEASURED = {
|
||||
state: 'not-measured' as const,
|
||||
scopes: [] as readonly string[],
|
||||
source: 'runtime-not-authorized' as const,
|
||||
};
|
||||
const RECEIVE_PACK_CONTENT_TYPE = 'application/x-git-receive-pack-advertisement';
|
||||
|
||||
interface ResultOptions {
|
||||
@@ -72,6 +77,7 @@ function result(
|
||||
reason: { code: options.code, message: options.message },
|
||||
evidence: options.evidence ?? {
|
||||
providerIdentity: null,
|
||||
tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED,
|
||||
repositoryPermission: null,
|
||||
writeDifferential: null,
|
||||
},
|
||||
@@ -186,6 +192,7 @@ function successfulEvidence(
|
||||
};
|
||||
return {
|
||||
providerIdentity: subjectIdentity,
|
||||
tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED,
|
||||
repositoryPermission: subjectPermission,
|
||||
writeDifferential,
|
||||
};
|
||||
@@ -226,6 +233,7 @@ async function evaluateGiteaReadValidationUnsafe(
|
||||
);
|
||||
const evidence: CredentialValidationEvidenceDto = {
|
||||
providerIdentity: null,
|
||||
tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED,
|
||||
repositoryPermission,
|
||||
writeDifferential: null,
|
||||
};
|
||||
@@ -267,6 +275,7 @@ async function evaluateGiteaReadValidationUnsafe(
|
||||
);
|
||||
const evidence: CredentialValidationEvidenceDto = {
|
||||
providerIdentity,
|
||||
tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED,
|
||||
repositoryPermission,
|
||||
writeDifferential: null,
|
||||
};
|
||||
@@ -359,6 +368,7 @@ async function evaluateGiteaWriteValidationUnsafe(
|
||||
const receivePack = await dependencies.provider.probeReceivePack(resolved, request.repo);
|
||||
const evidence: CredentialValidationEvidenceDto = {
|
||||
providerIdentity: null,
|
||||
tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED,
|
||||
repositoryPermission: permission,
|
||||
writeDifferential: null,
|
||||
};
|
||||
@@ -400,6 +410,7 @@ async function evaluateGiteaWriteValidationUnsafe(
|
||||
}
|
||||
const baseEvidence: CredentialValidationEvidenceDto = {
|
||||
providerIdentity: subjectEvidence.identity,
|
||||
tokenCapabilities: RUNTIME_SCOPE_NOT_MEASURED,
|
||||
repositoryPermission: subjectEvidence.permission,
|
||||
writeDifferential: null,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user