123 lines
3.6 KiB
TypeScript
123 lines
3.6 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
import { createReadStream, fstatSync } from 'node:fs';
|
|
import { z } from 'zod';
|
|
import type { ResolvedCredential } from './credential-provider.dto.js';
|
|
|
|
const authoritySchema = z
|
|
.object({
|
|
identity: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_.-]*$/),
|
|
estate: z.string().regex(/^[a-z0-9][a-z0-9-]*$/),
|
|
host: z.string().regex(/^[a-z0-9][a-z0-9.-]*$/),
|
|
secret: z
|
|
.string()
|
|
.min(1)
|
|
.max(16 * 1024)
|
|
.regex(/^\S+$/),
|
|
})
|
|
.strict();
|
|
|
|
export class DelegatedCredentialError extends Error {
|
|
constructor(
|
|
public readonly code: string,
|
|
message: string,
|
|
) {
|
|
super(`Delegated credential rejected: code=${code} ${message}`);
|
|
this.name = 'DelegatedCredentialError';
|
|
}
|
|
}
|
|
|
|
async function readProtectedFd(fd: number): Promise<Buffer> {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout((): void => controller.abort(), 5_000);
|
|
const chunks: Buffer[] = [];
|
|
let total = 0;
|
|
try {
|
|
const stream = createReadStream(`/proc/self/fd/${fd}`, {
|
|
highWaterMark: 4 * 1024,
|
|
signal: controller.signal,
|
|
});
|
|
for await (const chunk of stream) {
|
|
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
total += bytes.byteLength;
|
|
if (total > 32 * 1024) {
|
|
stream.destroy();
|
|
throw new Error('protected credential payload exceeded the bound');
|
|
}
|
|
chunks.push(bytes);
|
|
}
|
|
return Buffer.concat(chunks, total);
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
export async function readDelegatedCredentialFromFd(
|
|
fd: number,
|
|
expectedIdentity: string,
|
|
expectedEstate: string,
|
|
expectedHost: string,
|
|
): Promise<ResolvedCredential> {
|
|
if (!Number.isSafeInteger(fd) || fd < 3 || fd > 1024) {
|
|
throw new DelegatedCredentialError('delegated-authority-unavailable', 'invalid inherited fd');
|
|
}
|
|
let bytes: Buffer;
|
|
try {
|
|
const stat = fstatSync(fd);
|
|
if (!stat.isFile() && !stat.isFIFO()) {
|
|
throw new Error('fd is not a regular file or pipe');
|
|
}
|
|
const currentUid = process.getuid?.();
|
|
if (currentUid === undefined || stat.uid !== currentUid || (stat.mode & 0o077) !== 0) {
|
|
throw new Error('fd owner or permissions are unsafe');
|
|
}
|
|
bytes = await readProtectedFd(fd);
|
|
} catch {
|
|
throw new DelegatedCredentialError(
|
|
'delegated-authority-unavailable',
|
|
'protected inherited credential fd could not be read',
|
|
);
|
|
}
|
|
if (bytes.byteLength > 32 * 1024) {
|
|
bytes.fill(0);
|
|
throw new DelegatedCredentialError(
|
|
'delegated-authority-unavailable',
|
|
'protected credential payload exceeded the bound',
|
|
);
|
|
}
|
|
let raw: unknown;
|
|
try {
|
|
raw = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
|
|
} catch {
|
|
bytes.fill(0);
|
|
throw new DelegatedCredentialError(
|
|
'delegated-authority-unavailable',
|
|
'protected credential payload was invalid',
|
|
);
|
|
}
|
|
bytes.fill(0);
|
|
const parsed = authoritySchema.safeParse(raw);
|
|
if (!parsed.success) {
|
|
throw new DelegatedCredentialError(
|
|
'delegated-authority-unavailable',
|
|
'protected credential payload did not match the schema',
|
|
);
|
|
}
|
|
if (
|
|
parsed.data.identity !== expectedIdentity ||
|
|
parsed.data.estate !== expectedEstate ||
|
|
parsed.data.host !== expectedHost
|
|
) {
|
|
throw new DelegatedCredentialError(
|
|
'delegated-authority-mismatch',
|
|
'protected credential does not match the explicit actor, estate, and host',
|
|
);
|
|
}
|
|
return Object.freeze({
|
|
identity: parsed.data.identity,
|
|
estate: parsed.data.estate,
|
|
host: parsed.data.host,
|
|
resolutionId: randomUUID(),
|
|
secret: new TextEncoder().encode(parsed.data.secret),
|
|
});
|
|
}
|