419 lines
13 KiB
TypeScript
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);
|
|
}
|
|
}
|