Files
stack/packages/mosaic/src/credentials/file-credential-store.ts
T

419 lines
13 KiB
TypeScript

import { createHash, randomUUID } from 'node:crypto';
import { lstatSync } from 'node:fs';
import { open, readdir, rename, unlink } from 'node:fs/promises';
import { join } from 'node:path';
import { z } from 'zod';
import {
ensureManagedDirectory,
readRegularFileSecure,
type SecureFileSnapshot,
} from '../fleet/secure-file.js';
import type { CredentialBindingMetadataDto } from './credential-binding.dto.js';
import type { CredentialResolver, ResolvedCredential } from './credential-provider.dto.js';
import type { ParsedCredentialEstateRegistry } from './estate-registry.js';
const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
const MAX_TOKEN_BYTES = 16 * 1024;
const bindingSchema = z
.object({
schemaVersion: z.literal(1),
identity: z.string().regex(IDENTITY),
estate: z.string().min(1),
host: z.string().min(1),
providerLogin: z.string().regex(IDENTITY),
tokenName: z.string().regex(IDENTITY),
scopes: z.array(z.string().regex(/^[a-z]+(?::[a-z]+)?$/)).max(32),
createdAt: z.string().datetime(),
tokenDigest: z
.string()
.regex(/^[a-f0-9]{64}$/)
.optional(),
})
.strict();
const credentialEnvelopeSchema = bindingSchema.extend({
token: z.string().min(1).max(MAX_TOKEN_BYTES).regex(/^\S+$/),
});
export class CredentialStoreError extends Error {
constructor(
public readonly code: string,
message: string,
) {
super(`Credential store rejected: code=${code} ${message}`);
this.name = 'CredentialStoreError';
}
}
function isMissingFile(error: unknown): boolean {
return (
error instanceof Error &&
'code' in error &&
typeof error.code === 'string' &&
error.code === 'ENOENT'
);
}
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');
}
for (const byte of content) {
if (byte <= 0x20 || byte === 0x7f) {
throw new CredentialStoreError(
'invalid-token-bytes',
'token file contains whitespace or control bytes',
);
}
}
return new Uint8Array(content);
}
export class FileCredentialResolver implements CredentialResolver {
constructor(
private readonly tokenDirectory: string,
private readonly estateRegistry: ParsedCredentialEstateRegistry,
) {}
async resolve(
identity: string,
estate: string,
host: string,
): Promise<ResolvedCredential | undefined> {
if (!IDENTITY.test(identity)) {
throw new CredentialStoreError(
'invalid-identity',
'identity is outside the allowlist grammar',
);
}
const hostConfig = this.estateRegistry.resolve(estate, host);
if (hostConfig === undefined) return undefined;
const currentUid = process.getuid?.();
if (currentUid === undefined) {
throw new CredentialStoreError('insecure-token-owner', 'runtime uid is unavailable');
}
const directory = lstatSync(this.tokenDirectory);
if (
!directory.isDirectory() ||
directory.isSymbolicLink() ||
directory.uid !== currentUid ||
(directory.mode & 0o022) !== 0
) {
throw new CredentialStoreError(
'insecure-token-owner',
'token directory ownership or write permissions are unsafe',
);
}
const envelopePath = join(
this.tokenDirectory,
`${hostConfig.tokenPrefix}-${identity}.credential.json`,
);
try {
const envelopeSnapshot = readRegularFileSecure(envelopePath, {
root: this.tokenDirectory,
maxBytes: 64 * 1024,
});
const envelope = credentialEnvelopeSchema.safeParse(
JSON.parse(envelopeSnapshot.content.toString('utf8')),
);
if (
!envelope.success ||
envelopeSnapshot.uid !== process.getuid?.() ||
(envelopeSnapshot.mode & 0o077) !== 0
) {
throw new CredentialStoreError(
'invalid-binding',
'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) {
throw new CredentialStoreError(
'credential-generation-mismatch',
'credential envelope digest does not match its token',
);
}
return Object.freeze({
identity,
estate,
host,
resolutionId: randomUUID(),
secret,
});
} catch (error: unknown) {
if (!isMissingFile(error)) throw error;
}
const path = join(this.tokenDirectory, `${hostConfig.tokenPrefix}-${identity}.token`);
let snapshot: SecureFileSnapshot;
try {
snapshot = readRegularFileSecure(path, {
root: this.tokenDirectory,
maxBytes: MAX_TOKEN_BYTES,
});
} catch (error: unknown) {
if (isMissingFile(error)) return undefined;
throw error;
}
const bindingPath = join(
this.tokenDirectory,
`${hostConfig.tokenPrefix}-${identity}.binding.json`,
);
try {
const bindingSnapshot = readRegularFileSecure(bindingPath, {
root: this.tokenDirectory,
maxBytes: 64 * 1024,
});
const binding = bindingSchema.safeParse(JSON.parse(bindingSnapshot.content.toString('utf8')));
const digest = createHash('sha256').update(snapshot.content).digest('hex');
if (!binding.success || binding.data.tokenDigest !== digest) {
throw new CredentialStoreError(
'credential-generation-mismatch',
'token and binding metadata are not one committed generation',
);
}
} catch (error: unknown) {
if (!isMissingFile(error)) throw error;
// Legacy token files predate binding metadata and remain readable until rotated.
}
const permissions = snapshot.mode & 0o777;
if (snapshot.uid !== currentUid) {
throw new CredentialStoreError(
'insecure-token-owner',
'token file is not owned by the runtime uid',
);
}
if ((permissions & 0o077) !== 0) {
throw new CredentialStoreError(
'insecure-token-mode',
'token file grants group or other access',
);
}
return Object.freeze({
identity,
estate,
host,
resolutionId: randomUUID(),
secret: validateSecret(snapshot.content),
});
}
}
async function syncDirectory(path: string): Promise<void> {
const handle = await open(path, 'r');
try {
await handle.sync();
} finally {
await handle.close();
}
}
export class FileCredentialStore {
constructor(
private readonly tokenDirectory: string,
private readonly estateRegistry: ParsedCredentialEstateRegistry,
) {}
private paths(
identity: string,
estate: string,
host: string,
): {
readonly token: string;
readonly binding: string;
readonly envelope: string;
readonly prefix: string;
} {
if (!IDENTITY.test(identity)) {
throw new CredentialStoreError(
'invalid-identity',
'identity is outside the allowlist grammar',
);
}
const config = this.estateRegistry.resolve(estate, host);
if (config === undefined) {
throw new CredentialStoreError('estate-host-mismatch', 'estate and host do not match');
}
const prefix = `${config.tokenPrefix}-${identity}`;
return {
token: join(this.tokenDirectory, `${prefix}.token`),
binding: join(this.tokenDirectory, `${prefix}.binding.json`),
envelope: join(this.tokenDirectory, `${prefix}.credential.json`),
prefix,
};
}
async put(metadata: CredentialBindingMetadataDto, secret: Uint8Array): Promise<void> {
const paths = this.paths(metadata.identity, metadata.estate, metadata.host);
ensureManagedDirectory(this.tokenDirectory, this.tokenDirectory);
const token = validateSecret(Buffer.from(secret));
const envelope = credentialEnvelopeSchema.parse({
...metadata,
schemaVersion: 1,
tokenDigest: createHash('sha256').update(token).digest('hex'),
token: Buffer.from(token).toString('utf8'),
});
const suffix = randomUUID();
const envelopeTemp = `${paths.envelope}.${suffix}.tmp`;
const lockPath = join(this.tokenDirectory, `${paths.prefix}.lock`);
let lock;
try {
lock = await open(lockPath, 'wx', 0o600);
} catch {
throw new CredentialStoreError(
'conflicting-credential-mutation',
'another mutation owns the identity lock',
);
}
try {
const handle = await open(envelopeTemp, 'wx', 0o600);
try {
await handle.writeFile(`${JSON.stringify(envelope)}\n`, 'utf8');
await handle.sync();
} finally {
await handle.close();
}
await rename(envelopeTemp, paths.envelope);
await syncDirectory(this.tokenDirectory);
} finally {
await lock.close();
await unlink(envelopeTemp).catch((): void => undefined);
await unlink(lockPath).catch((): void => undefined);
await syncDirectory(this.tokenDirectory);
}
}
async snapshot(
identity: string,
estate: string,
host: string,
): Promise<
| {
readonly binding: CredentialBindingMetadataDto;
readonly secret: Uint8Array;
}
| undefined
> {
const binding = await this.readBinding(identity, estate, host);
if (binding === undefined) return undefined;
const resolved = await new FileCredentialResolver(
this.tokenDirectory,
this.estateRegistry,
).resolve(identity, estate, host);
if (resolved === undefined) {
throw new CredentialStoreError(
'invalid-binding',
'binding metadata exists without its token generation',
);
}
return { binding, secret: new Uint8Array(resolved.secret) };
}
async readBinding(
identity: string,
estate: string,
host: string,
): Promise<CredentialBindingMetadataDto | undefined> {
const paths = this.paths(identity, estate, host);
let snapshot: SecureFileSnapshot;
try {
const envelope = readRegularFileSecure(paths.envelope, {
root: this.tokenDirectory,
maxBytes: 64 * 1024,
});
const parsed = credentialEnvelopeSchema.safeParse(
JSON.parse(envelope.content.toString('utf8')),
);
if (!parsed.success) {
throw new CredentialStoreError('invalid-binding', 'credential envelope is malformed');
}
return {
schemaVersion: 1,
identity: parsed.data.identity,
estate: parsed.data.estate,
host: parsed.data.host,
providerLogin: parsed.data.providerLogin,
tokenName: parsed.data.tokenName,
scopes: parsed.data.scopes,
createdAt: parsed.data.createdAt,
tokenDigest: parsed.data.tokenDigest,
};
} catch (error: unknown) {
if (!isMissingFile(error)) throw error;
}
try {
snapshot = readRegularFileSecure(paths.binding, {
root: this.tokenDirectory,
maxBytes: 64 * 1024,
});
} catch (error: unknown) {
if (isMissingFile(error)) return undefined;
throw error;
}
if ((snapshot.mode & 0o077) !== 0 || snapshot.uid !== process.getuid?.()) {
throw new CredentialStoreError('insecure-token-owner', 'binding metadata is not private');
}
const parsed = bindingSchema.safeParse(JSON.parse(snapshot.content.toString('utf8')));
if (!parsed.success) {
throw new CredentialStoreError(
'invalid-binding',
'binding metadata failed schema validation',
);
}
return parsed.data;
}
async list(estate: string, host: string): Promise<readonly string[]> {
const config = this.estateRegistry.resolve(estate, host);
if (config === undefined) return [];
const names = await readdir(this.tokenDirectory);
const prefix = `${config.tokenPrefix}-`;
return [
...new Set(
names.flatMap((name): string[] => {
if (!name.startsWith(prefix)) return [];
if (name.endsWith('.token')) {
return [name.slice(prefix.length, -'.token'.length)];
}
if (name.endsWith('.credential.json')) {
return [name.slice(prefix.length, -'.credential.json'.length)];
}
return [];
}),
),
]
.filter((identity): boolean => IDENTITY.test(identity))
.sort();
}
async remove(identity: string, estate: string, host: string): Promise<void> {
const paths = this.paths(identity, estate, host);
await unlink(paths.token).catch((error: unknown): void => {
if (!isMissingFile(error)) throw error;
});
await unlink(paths.binding).catch((error: unknown): void => {
if (!isMissingFile(error)) throw error;
});
await unlink(paths.envelope).catch((error: unknown): void => {
if (!isMissingFile(error)) throw error;
});
await syncDirectory(this.tokenDirectory);
}
}