mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 19:29:13 +08:00
feat(ql3): add cluster tool result key authority
This commit is contained in:
@@ -1,7 +1,4 @@
|
||||
// Remote Execution owns mounted Secret resolution for authenticated delivery.
|
||||
import { constants } from 'node:fs';
|
||||
import { lstat, open, realpath } from 'node:fs/promises';
|
||||
import { isAbsolute, join, normalize, parse, relative } from 'node:path';
|
||||
import {
|
||||
MAX_REMOTE_SECRET_DELIVERY_TOTAL_VALUE_BYTES,
|
||||
MAX_REMOTE_SECRET_VALUE_BYTES,
|
||||
@@ -12,7 +9,7 @@ import {
|
||||
} from '@qinglong/runtime-core/remote-secret-delivery';
|
||||
import { secretProjectionFileName } from '@qinglong/runtime-core/secret-projection';
|
||||
|
||||
const MAX_SECRET_ROOT_BYTES = 4096;
|
||||
import { PrivateProjectedFileReader } from '../security/privateProjectedFile';
|
||||
|
||||
export interface ClusterMountedSecretProviderOptions {
|
||||
/**
|
||||
@@ -38,20 +35,6 @@ export class ClusterMountedSecretProviderError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function rootDirectory(value: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!isAbsolute(value) ||
|
||||
parse(value).root === value ||
|
||||
normalize(value) !== value ||
|
||||
value.includes('\0') ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_SECRET_ROOT_BYTES
|
||||
) {
|
||||
throw new ClusterMountedSecretProviderError('invalid_configuration');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kubernetes Secret keys cannot contain a SecretRef directly. This stable,
|
||||
* non-reversible name also prevents Project/name input from becoming a path.
|
||||
@@ -66,72 +49,6 @@ export function clusterMountedSecretFileName(secretRef: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function remainsBelow(root: string, candidate: string): boolean {
|
||||
const suffix = relative(root, candidate);
|
||||
return (
|
||||
suffix.length > 0 &&
|
||||
!isAbsolute(suffix) &&
|
||||
suffix !== '..' &&
|
||||
!suffix.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)
|
||||
);
|
||||
}
|
||||
|
||||
async function resolvedRoot(path: string): Promise<string> {
|
||||
try {
|
||||
const configured = await lstat(path);
|
||||
if (!configured.isDirectory() || configured.isSymbolicLink()) {
|
||||
throw new Error('root is not a direct directory');
|
||||
}
|
||||
return await realpath(path);
|
||||
} catch (error) {
|
||||
throw new ClusterMountedSecretProviderError('root_unavailable', {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function readMaterial(root: string, secretRef: string): Promise<Buffer> {
|
||||
const candidate = join(root, clusterMountedSecretFileName(secretRef));
|
||||
let handle;
|
||||
try {
|
||||
const target = await realpath(candidate);
|
||||
if (!remainsBelow(root, target)) {
|
||||
throw new Error('material escaped its root');
|
||||
}
|
||||
handle = await open(
|
||||
target,
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
const stat = await handle.stat();
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.nlink !== 1 ||
|
||||
stat.size < 0 ||
|
||||
stat.size > MAX_REMOTE_SECRET_VALUE_BYTES ||
|
||||
(stat.mode & 0o111) !== 0 ||
|
||||
(stat.mode & 0o027) !== 0
|
||||
) {
|
||||
throw new Error('material metadata is unsafe');
|
||||
}
|
||||
const bytes = await handle.readFile();
|
||||
if (
|
||||
bytes.byteLength !== stat.size ||
|
||||
bytes.byteLength > MAX_REMOTE_SECRET_VALUE_BYTES ||
|
||||
(await realpath(candidate)) !== target
|
||||
) {
|
||||
bytes.fill(0);
|
||||
throw new Error('material changed while reading');
|
||||
}
|
||||
return bytes;
|
||||
} catch (error) {
|
||||
throw new ClusterMountedSecretProviderError('material_unavailable', {
|
||||
cause: error,
|
||||
});
|
||||
} finally {
|
||||
await handle?.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function secretValue(bytes: Buffer): string {
|
||||
try {
|
||||
const value = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
||||
@@ -155,17 +72,34 @@ function secretValue(bytes: Buffer): string {
|
||||
export class ClusterMountedSecretProvider
|
||||
implements RemoteWorkerSecretValueProvider
|
||||
{
|
||||
private readonly rootDirectory: string;
|
||||
private readonly reader: PrivateProjectedFileReader;
|
||||
|
||||
constructor(options: ClusterMountedSecretProviderOptions) {
|
||||
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
||||
throw new ClusterMountedSecretProviderError('invalid_configuration');
|
||||
}
|
||||
this.rootDirectory = rootDirectory(options.rootDirectory);
|
||||
try {
|
||||
this.reader = new PrivateProjectedFileReader({
|
||||
rootDirectory: options.rootDirectory,
|
||||
minimumBytes: 0,
|
||||
maximumBytes: MAX_REMOTE_SECRET_VALUE_BYTES,
|
||||
access: 'private_material',
|
||||
});
|
||||
} catch (error) {
|
||||
throw new ClusterMountedSecretProviderError('invalid_configuration', {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async verify(): Promise<void> {
|
||||
await resolvedRoot(this.rootDirectory);
|
||||
try {
|
||||
await this.reader.verify();
|
||||
} catch (error) {
|
||||
throw new ClusterMountedSecretProviderError('root_unavailable', {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async resolve(
|
||||
@@ -179,13 +113,19 @@ export class ClusterMountedSecretProvider
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
const root = await resolvedRoot(this.rootDirectory);
|
||||
const buffers: Buffer[] = [];
|
||||
try {
|
||||
const values = [];
|
||||
let totalBytes = 0;
|
||||
for (const secretRef of normalized.secretRefs) {
|
||||
const bytes = await readMaterial(root, secretRef);
|
||||
const bytes = await this.reader
|
||||
.read(clusterMountedSecretFileName(secretRef))
|
||||
.catch((error) => {
|
||||
throw new ClusterMountedSecretProviderError(
|
||||
'material_unavailable',
|
||||
{ cause: error },
|
||||
);
|
||||
});
|
||||
buffers.push(bytes);
|
||||
totalBytes += bytes.byteLength;
|
||||
if (totalBytes > MAX_REMOTE_SECRET_DELIVERY_TOTAL_VALUE_BYTES) {
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { constants } from 'node:fs';
|
||||
import { lstat, open, realpath } from 'node:fs/promises';
|
||||
import { isAbsolute, join, normalize, parse, relative } from 'node:path';
|
||||
|
||||
const MAX_ROOT_DIRECTORY_BYTES = 4096;
|
||||
const FILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,252}$/;
|
||||
|
||||
export interface PrivateProjectedFileReaderOptions {
|
||||
readonly rootDirectory: string;
|
||||
readonly minimumBytes: number;
|
||||
readonly maximumBytes: number;
|
||||
readonly access: 'private_material' | 'read_only_keyring';
|
||||
}
|
||||
|
||||
export class PrivateProjectedFileError extends Error {
|
||||
constructor(options?: ErrorOptions) {
|
||||
super('Private projected file is unavailable', options);
|
||||
this.name = 'PrivateProjectedFileError';
|
||||
}
|
||||
}
|
||||
|
||||
function unavailable(cause?: unknown): never {
|
||||
throw new PrivateProjectedFileError({
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function rootDirectory(value: unknown): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!isAbsolute(value) ||
|
||||
parse(value).root === value ||
|
||||
normalize(value) !== value ||
|
||||
value.includes('\0') ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_ROOT_DIRECTORY_BYTES
|
||||
) {
|
||||
return unavailable();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function boundedInteger(value: unknown, minimum: number): number {
|
||||
if (
|
||||
typeof value !== 'number' ||
|
||||
!Number.isSafeInteger(value) ||
|
||||
value < minimum
|
||||
) {
|
||||
return unavailable();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function fileName(value: unknown): string {
|
||||
if (typeof value !== 'string' || !FILE_NAME_PATTERN.test(value)) {
|
||||
return unavailable();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function remainsBelow(root: string, candidate: string): boolean {
|
||||
const suffix = relative(root, candidate);
|
||||
return (
|
||||
suffix.length > 0 &&
|
||||
!isAbsolute(suffix) &&
|
||||
suffix !== '..' &&
|
||||
!suffix.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)
|
||||
);
|
||||
}
|
||||
|
||||
function safeMode(
|
||||
mode: number,
|
||||
access: PrivateProjectedFileReaderOptions['access'],
|
||||
): boolean {
|
||||
if ((mode & 0o111) !== 0 || (mode & 0o007) !== 0) return false;
|
||||
return access === 'private_material'
|
||||
? (mode & 0o027) === 0
|
||||
: (mode & 0o222) === 0 && (mode & 0o440) !== 0;
|
||||
}
|
||||
|
||||
/** Internal, no-cache reader for Kubernetes atomic-writer style projections. */
|
||||
export class PrivateProjectedFileReader {
|
||||
readonly #rootDirectory: string;
|
||||
readonly #minimumBytes: number;
|
||||
readonly #maximumBytes: number;
|
||||
readonly #access: PrivateProjectedFileReaderOptions['access'];
|
||||
|
||||
constructor(options: PrivateProjectedFileReaderOptions) {
|
||||
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
||||
unavailable();
|
||||
}
|
||||
const normalizedRoot = rootDirectory(options.rootDirectory);
|
||||
const minimumBytes = boundedInteger(options.minimumBytes, 0);
|
||||
const maximumBytes = boundedInteger(options.maximumBytes, 1);
|
||||
if (
|
||||
maximumBytes < minimumBytes ||
|
||||
(options.access !== 'private_material' &&
|
||||
options.access !== 'read_only_keyring')
|
||||
) {
|
||||
unavailable();
|
||||
}
|
||||
this.#rootDirectory = normalizedRoot;
|
||||
this.#minimumBytes = minimumBytes;
|
||||
this.#maximumBytes = maximumBytes;
|
||||
this.#access = options.access;
|
||||
}
|
||||
|
||||
async #resolvedRoot(): Promise<string> {
|
||||
try {
|
||||
const stat = await lstat(this.#rootDirectory);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) return unavailable();
|
||||
return await realpath(this.#rootDirectory);
|
||||
} catch (cause) {
|
||||
return unavailable(cause);
|
||||
}
|
||||
}
|
||||
|
||||
async verify(): Promise<void> {
|
||||
await this.#resolvedRoot();
|
||||
}
|
||||
|
||||
async read(name: string): Promise<Buffer> {
|
||||
const normalizedName = fileName(name);
|
||||
let handle: Awaited<ReturnType<typeof open>> | undefined;
|
||||
let bytes: Buffer | undefined;
|
||||
try {
|
||||
const root = await this.#resolvedRoot();
|
||||
const candidate = join(root, normalizedName);
|
||||
const target = await realpath(candidate);
|
||||
if (!remainsBelow(root, target)) return unavailable();
|
||||
handle = await open(
|
||||
target,
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
const before = await handle.stat();
|
||||
if (
|
||||
!before.isFile() ||
|
||||
before.nlink !== 1 ||
|
||||
before.size < this.#minimumBytes ||
|
||||
before.size > this.#maximumBytes ||
|
||||
!safeMode(before.mode, this.#access)
|
||||
) {
|
||||
return unavailable();
|
||||
}
|
||||
bytes = await handle.readFile();
|
||||
const after = await handle.stat();
|
||||
if (
|
||||
bytes.byteLength !== before.size ||
|
||||
after.dev !== before.dev ||
|
||||
after.ino !== before.ino ||
|
||||
after.size !== before.size ||
|
||||
after.mtimeMs !== before.mtimeMs ||
|
||||
(await realpath(candidate)) !== target ||
|
||||
(await realpath(this.#rootDirectory)) !== root
|
||||
) {
|
||||
return unavailable();
|
||||
}
|
||||
const owned = bytes;
|
||||
bytes = undefined;
|
||||
return owned;
|
||||
} catch (cause) {
|
||||
return unavailable(cause);
|
||||
} finally {
|
||||
bytes?.fill(0);
|
||||
await handle?.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { toolResultKeyMaterialProof } from '@qinglong/runtime-core/tool-result-key-catalog';
|
||||
|
||||
export const CLUSTER_TOOL_RESULT_KEYRING_MANIFEST_SCHEMA =
|
||||
'qinglong/cluster-tool-result-projected-keyring@v1' as const;
|
||||
export const MAX_CLUSTER_TOOL_RESULT_KEYRING_BYTES = 64 * 1024;
|
||||
export const MAX_CLUSTER_TOOL_RESULT_PROJECTED_KEYS = 16;
|
||||
|
||||
const KEY_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
||||
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/;
|
||||
const PROJECTION_DIGEST_DOMAIN = Buffer.from(
|
||||
'qinglong/cluster-tool-result-projected-keyring-digest@v1\0',
|
||||
'utf8',
|
||||
);
|
||||
|
||||
export interface ClusterToolResultKeyringManifest {
|
||||
readonly schema: typeof CLUSTER_TOOL_RESULT_KEYRING_MANIFEST_SCHEMA;
|
||||
readonly keys: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
export interface ClusterToolResultKeyringSummary {
|
||||
readonly schemaVersion: 1;
|
||||
readonly keyIds: readonly string[];
|
||||
readonly materialProofs: Readonly<Record<string, string>>;
|
||||
readonly projectionDigest: string;
|
||||
}
|
||||
|
||||
export class InvalidClusterToolResultKeyringManifestError extends TypeError {
|
||||
readonly code = 'QL3_CLUSTER_TOOL_RESULT_KEYRING_MANIFEST_INVALID';
|
||||
|
||||
constructor() {
|
||||
super('Cluster Tool result keyring manifest is invalid');
|
||||
this.name = 'InvalidClusterToolResultKeyringManifestError';
|
||||
}
|
||||
}
|
||||
|
||||
function invalid(): never {
|
||||
throw new InvalidClusterToolResultKeyringManifestError();
|
||||
}
|
||||
|
||||
function exactKeys(value: object, expected: readonly string[]): boolean {
|
||||
const actual = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
return (
|
||||
actual.length === canonical.length &&
|
||||
actual.every((key, index) => key === canonical[index])
|
||||
);
|
||||
}
|
||||
|
||||
function dataRecord(value: unknown): Record<string, unknown> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
Object.getPrototypeOf(value) !== Object.prototype
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function normalizedEntries(
|
||||
value: unknown,
|
||||
): readonly (readonly [string, string])[] {
|
||||
const keys = dataRecord(value);
|
||||
const entries = Object.entries(keys).sort(([left], [right]) =>
|
||||
left < right ? -1 : left > right ? 1 : 0,
|
||||
);
|
||||
if (
|
||||
entries.length < 1 ||
|
||||
entries.length > MAX_CLUSTER_TOOL_RESULT_PROJECTED_KEYS
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
const normalized: (readonly [string, string])[] = [];
|
||||
for (const [keyId, encoded] of entries) {
|
||||
let material: Buffer | undefined;
|
||||
try {
|
||||
if (
|
||||
!KEY_ID_PATTERN.test(keyId) ||
|
||||
typeof encoded !== 'string' ||
|
||||
!BASE64URL_PATTERN.test(encoded)
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
material = Buffer.from(encoded, 'base64url');
|
||||
if (
|
||||
material.byteLength !== 32 ||
|
||||
material.toString('base64url') !== encoded
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
normalized.push(Object.freeze([keyId, encoded] as const));
|
||||
} finally {
|
||||
material?.fill(0);
|
||||
}
|
||||
}
|
||||
return Object.freeze(normalized);
|
||||
}
|
||||
|
||||
export function normalizeClusterToolResultKeyringManifest(
|
||||
value: unknown,
|
||||
): Readonly<ClusterToolResultKeyringManifest> {
|
||||
const manifest = dataRecord(value);
|
||||
if (
|
||||
!exactKeys(manifest, ['keys', 'schema']) ||
|
||||
manifest.schema !== CLUSTER_TOOL_RESULT_KEYRING_MANIFEST_SCHEMA
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
return Object.freeze({
|
||||
schema: CLUSTER_TOOL_RESULT_KEYRING_MANIFEST_SCHEMA,
|
||||
keys: Object.freeze(Object.fromEntries(normalizedEntries(manifest.keys))),
|
||||
});
|
||||
}
|
||||
|
||||
export function parseClusterToolResultKeyringManifest(
|
||||
bytes: Buffer,
|
||||
): Readonly<ClusterToolResultKeyringManifest> {
|
||||
try {
|
||||
if (
|
||||
!Buffer.isBuffer(bytes) ||
|
||||
bytes.byteLength < 1 ||
|
||||
bytes.byteLength > MAX_CLUSTER_TOOL_RESULT_KEYRING_BYTES
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
return normalizeClusterToolResultKeyringManifest(
|
||||
JSON.parse(bytes.toString('utf8')),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidClusterToolResultKeyringManifestError) {
|
||||
throw error;
|
||||
}
|
||||
return invalid();
|
||||
}
|
||||
}
|
||||
|
||||
export function canonicalClusterToolResultKeyringManifest(
|
||||
value: ClusterToolResultKeyringManifest,
|
||||
): Buffer {
|
||||
const manifest = normalizeClusterToolResultKeyringManifest(value);
|
||||
return Buffer.from(`${JSON.stringify(manifest)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
export function resolveClusterToolResultKeyringMaterial(
|
||||
value: ClusterToolResultKeyringManifest,
|
||||
keyId: string,
|
||||
): Readonly<{ keyId: string; key: Uint8Array }> | null {
|
||||
if (typeof keyId !== 'string' || !KEY_ID_PATTERN.test(keyId))
|
||||
return invalid();
|
||||
const manifest = normalizeClusterToolResultKeyringManifest(value);
|
||||
const encoded = manifest.keys[keyId];
|
||||
if (encoded === undefined) return null;
|
||||
const key = Buffer.from(encoded, 'base64url');
|
||||
if (key.byteLength !== 32 || key.toString('base64url') !== encoded) {
|
||||
key.fill(0);
|
||||
return invalid();
|
||||
}
|
||||
return Object.freeze({ keyId, key });
|
||||
}
|
||||
|
||||
export function summarizeClusterToolResultKeyringManifest(
|
||||
value: ClusterToolResultKeyringManifest,
|
||||
): Readonly<ClusterToolResultKeyringSummary> {
|
||||
const manifest = normalizeClusterToolResultKeyringManifest(value);
|
||||
const keyIds = Object.keys(manifest.keys).sort();
|
||||
const materialProofs: Record<string, string> = Object.create(null);
|
||||
for (const keyId of keyIds) {
|
||||
const material = resolveClusterToolResultKeyringMaterial(manifest, keyId)!;
|
||||
try {
|
||||
materialProofs[keyId] = toolResultKeyMaterialProof(keyId, material.key);
|
||||
} finally {
|
||||
material.key.fill(0);
|
||||
}
|
||||
}
|
||||
const canonicalProofs = Object.freeze({ ...materialProofs });
|
||||
return Object.freeze({
|
||||
schemaVersion: 1 as const,
|
||||
keyIds: Object.freeze(keyIds),
|
||||
materialProofs: canonicalProofs,
|
||||
projectionDigest: createHash('sha256')
|
||||
.update(PROJECTION_DIGEST_DOMAIN)
|
||||
.update(
|
||||
JSON.stringify({
|
||||
schema: manifest.schema,
|
||||
materialProofs: canonicalProofs,
|
||||
}),
|
||||
)
|
||||
.digest('hex'),
|
||||
});
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
|
||||
import type { ToolInvocationArtifactKeyProvider } from '@qinglong/runtime-core/tool-invocation-artifact';
|
||||
|
||||
import { PrivateProjectedFileReader } from '../../security/privateProjectedFile';
|
||||
|
||||
import {
|
||||
MAX_CLUSTER_TOOL_RESULT_KEYRING_BYTES,
|
||||
canonicalClusterToolResultKeyringManifest,
|
||||
parseClusterToolResultKeyringManifest,
|
||||
resolveClusterToolResultKeyringMaterial,
|
||||
summarizeClusterToolResultKeyringManifest,
|
||||
type ClusterToolResultKeyringManifest,
|
||||
type ClusterToolResultKeyringSummary,
|
||||
} from './toolResultKeyringManifest';
|
||||
|
||||
const DATA_FILE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,252}$/;
|
||||
|
||||
export {
|
||||
CLUSTER_TOOL_RESULT_KEYRING_MANIFEST_SCHEMA,
|
||||
MAX_CLUSTER_TOOL_RESULT_KEYRING_BYTES,
|
||||
MAX_CLUSTER_TOOL_RESULT_PROJECTED_KEYS,
|
||||
InvalidClusterToolResultKeyringManifestError,
|
||||
canonicalClusterToolResultKeyringManifest,
|
||||
normalizeClusterToolResultKeyringManifest,
|
||||
parseClusterToolResultKeyringManifest,
|
||||
resolveClusterToolResultKeyringMaterial,
|
||||
summarizeClusterToolResultKeyringManifest,
|
||||
type ClusterToolResultKeyringManifest,
|
||||
type ClusterToolResultKeyringSummary,
|
||||
} from './toolResultKeyringManifest';
|
||||
|
||||
export interface ClusterToolResultProjectedKeyringOptions {
|
||||
readonly rootDirectory: string;
|
||||
readonly dataFileName?: string;
|
||||
}
|
||||
|
||||
export class ClusterToolResultProjectedKeyringUnavailableError extends Error {
|
||||
readonly code = 'QL3_CLUSTER_TOOL_RESULT_PROJECTED_KEYRING_UNAVAILABLE';
|
||||
|
||||
constructor(options?: ErrorOptions) {
|
||||
super('Projected Cluster Tool result keyring is unavailable', options);
|
||||
this.name = 'ClusterToolResultProjectedKeyringUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
function unavailable(
|
||||
cause?: unknown,
|
||||
): ClusterToolResultProjectedKeyringUnavailableError {
|
||||
return new ClusterToolResultProjectedKeyringUnavailableError({
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function dataFileName(value: unknown): string {
|
||||
if (typeof value !== 'string' || !DATA_FILE_NAME.test(value)) {
|
||||
throw unavailable();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function readManifest(
|
||||
reader: PrivateProjectedFileReader,
|
||||
fileName: string,
|
||||
): Promise<Readonly<ClusterToolResultKeyringManifest>> {
|
||||
let bytes: Buffer | undefined;
|
||||
let canonical: Buffer | undefined;
|
||||
try {
|
||||
bytes = await reader.read(fileName);
|
||||
const manifest = parseClusterToolResultKeyringManifest(bytes);
|
||||
canonical = canonicalClusterToolResultKeyringManifest(manifest);
|
||||
if (!canonical.equals(bytes)) throw unavailable();
|
||||
return manifest;
|
||||
} catch (cause) {
|
||||
throw cause instanceof ClusterToolResultProjectedKeyringUnavailableError
|
||||
? cause
|
||||
: unavailable(cause);
|
||||
} finally {
|
||||
bytes?.fill(0);
|
||||
canonical?.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only result-key material authority for Kubernetes/CSI projections.
|
||||
* PostgreSQL remains the sole active/decryptable state authority: this class
|
||||
* only resolves key material and deliberately has no active() method.
|
||||
*/
|
||||
export class ClusterToolResultProjectedKeyring
|
||||
implements Pick<ToolInvocationArtifactKeyProvider, 'resolve'>
|
||||
{
|
||||
readonly #reader: PrivateProjectedFileReader;
|
||||
readonly #dataFileName: string;
|
||||
|
||||
constructor(options: ClusterToolResultProjectedKeyringOptions) {
|
||||
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
||||
throw unavailable();
|
||||
}
|
||||
try {
|
||||
this.#reader = new PrivateProjectedFileReader({
|
||||
rootDirectory: options.rootDirectory,
|
||||
minimumBytes: 1,
|
||||
maximumBytes: MAX_CLUSTER_TOOL_RESULT_KEYRING_BYTES,
|
||||
access: 'read_only_keyring',
|
||||
});
|
||||
this.#dataFileName = dataFileName(options.dataFileName ?? 'keyring.json');
|
||||
} catch (cause) {
|
||||
throw unavailable(cause);
|
||||
}
|
||||
}
|
||||
|
||||
async verify(): Promise<Readonly<ClusterToolResultKeyringSummary>> {
|
||||
return summarizeClusterToolResultKeyringManifest(
|
||||
await readManifest(this.#reader, this.#dataFileName),
|
||||
);
|
||||
}
|
||||
|
||||
async resolve(
|
||||
keyId: string,
|
||||
): ReturnType<Pick<ToolInvocationArtifactKeyProvider, 'resolve'>['resolve']> {
|
||||
try {
|
||||
return resolveClusterToolResultKeyringMaterial(
|
||||
await readManifest(this.#reader, this.#dataFileName),
|
||||
keyId,
|
||||
);
|
||||
} catch (cause) {
|
||||
throw cause instanceof ClusterToolResultProjectedKeyringUnavailableError
|
||||
? cause
|
||||
: unavailable(cause);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function createClusterToolResultProjectedKeyring(
|
||||
options: ClusterToolResultProjectedKeyringOptions,
|
||||
): Promise<Readonly<ClusterToolResultProjectedKeyring>> {
|
||||
const provider = new ClusterToolResultProjectedKeyring(options);
|
||||
await provider.verify();
|
||||
return provider;
|
||||
}
|
||||
Reference in New Issue
Block a user