mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 09:58:46 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,652 @@
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
PluginPackageActivationConflictError,
|
||||
PluginPackageActivationUnavailableError,
|
||||
normalizePluginPackageActivationIntent,
|
||||
type PluginPackageActivationIntent,
|
||||
type PluginPackageActivationObservation,
|
||||
type PluginPackageActivationPublisher,
|
||||
} from '@qinglong/runtime-core/plugin-package-activation';
|
||||
import type {
|
||||
PluginPackageResourceGeneration,
|
||||
PluginPackageResourceGenerationSource,
|
||||
} from '@qinglong/runtime-core/plugin-package-resource-generation';
|
||||
import {
|
||||
createPluginPackageActivationReceipt,
|
||||
normalizePluginPackageActivationReceipt,
|
||||
type PluginPackageActivationReceipt,
|
||||
} from '@qinglong/runtime-core/plugin-package-install';
|
||||
|
||||
const ACTIVE_POINTER_SCHEMA = 'qinglong/plugin-package-active-pointer@v2';
|
||||
const STAGE_RECEIPT_SCHEMA = 'qinglong/plugin-package-stage-receipt@v1';
|
||||
const STAGE_REFERENCE_PREFIX = 'local-stage:';
|
||||
const MAX_PATH_BYTES = 4096;
|
||||
const MAX_STAGE_RECEIPT_BYTES = 64 * 1024;
|
||||
const MAX_ACTIVE_POINTER_BYTES = 512 * 1024;
|
||||
const MAX_STAGE_ENTRIES = 256;
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const BLOB_NAME_PATTERN = /^[0-9]{4}-[0-9a-f]{64}\.blob$/;
|
||||
const ACTIVE_POINTER_NAME_PATTERN = /^[0-9a-f]{64}\.active\.json$/;
|
||||
|
||||
export interface LocalPluginPackageActivationPublisherOptions {
|
||||
/** Existing private 0700 directory created for Package staging. */
|
||||
readonly stagingRoot: string;
|
||||
/** Existing private 0700 directory containing active pointer files. */
|
||||
readonly activationRoot: string;
|
||||
/** Explicit clock used only when a new pointer wins publication. */
|
||||
readonly now: () => number;
|
||||
}
|
||||
|
||||
interface DirectoryAuthority {
|
||||
readonly path: string;
|
||||
readonly uid: number;
|
||||
readonly device: bigint;
|
||||
readonly inode: bigint;
|
||||
}
|
||||
|
||||
interface ActivePointer {
|
||||
readonly schema: typeof ACTIVE_POINTER_SCHEMA;
|
||||
readonly intent: Readonly<PluginPackageActivationIntent>;
|
||||
readonly receipt: Readonly<PluginPackageActivationReceipt>;
|
||||
}
|
||||
|
||||
interface OwnedLock {
|
||||
readonly device: bigint;
|
||||
readonly inode: bigint;
|
||||
}
|
||||
|
||||
function isCode(error: unknown, code: string): boolean {
|
||||
return Boolean(
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
error.code === code,
|
||||
);
|
||||
}
|
||||
|
||||
function boundedAbsolutePath(value: unknown, label: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!path.isAbsolute(value) ||
|
||||
path.parse(value).root === value ||
|
||||
path.normalize(value) !== value ||
|
||||
value.includes('\0') ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES
|
||||
) {
|
||||
throw new TypeError(`${label} must be a bounded canonical absolute path`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function directoryAuthority(value: unknown, label: string): DirectoryAuthority {
|
||||
const directory = boundedAbsolutePath(value, label);
|
||||
if (typeof process.getuid !== 'function') {
|
||||
throw new TypeError(`${label} requires a POSIX process identity`);
|
||||
}
|
||||
const uid = process.getuid();
|
||||
const stat = fs.lstatSync(directory, { bigint: true });
|
||||
if (
|
||||
!stat.isDirectory() ||
|
||||
stat.isSymbolicLink() ||
|
||||
Number(stat.uid) !== uid ||
|
||||
(Number(stat.mode) & 0o777) !== 0o700
|
||||
) {
|
||||
throw new TypeError(`${label} must be a private owned real directory`);
|
||||
}
|
||||
return Object.freeze({
|
||||
path: directory,
|
||||
uid,
|
||||
device: stat.dev,
|
||||
inode: stat.ino,
|
||||
});
|
||||
}
|
||||
|
||||
function verifyDirectory(authority: DirectoryAuthority): void {
|
||||
const stat = fs.lstatSync(authority.path, { bigint: true });
|
||||
if (
|
||||
!stat.isDirectory() ||
|
||||
stat.isSymbolicLink() ||
|
||||
Number(stat.uid) !== authority.uid ||
|
||||
(Number(stat.mode) & 0o777) !== 0o700 ||
|
||||
stat.dev !== authority.device ||
|
||||
stat.ino !== authority.inode
|
||||
) {
|
||||
throw new PluginPackageActivationUnavailableError();
|
||||
}
|
||||
}
|
||||
|
||||
function dataRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
(Object.getPrototypeOf(value) !== Object.prototype &&
|
||||
Object.getPrototypeOf(value) !== null)
|
||||
) {
|
||||
throw new PluginPackageActivationConflictError();
|
||||
}
|
||||
const descriptors = Object.getOwnPropertyDescriptors(value);
|
||||
if (
|
||||
Object.values(descriptors).some(
|
||||
(descriptor) =>
|
||||
descriptor.get !== undefined ||
|
||||
descriptor.set !== undefined ||
|
||||
descriptor.enumerable !== true,
|
||||
)
|
||||
) {
|
||||
throw new PluginPackageActivationConflictError();
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactKeys(value: object, expected: readonly string[]): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
if (
|
||||
actual.length !== canonical.length ||
|
||||
actual.some((key, index) => key !== canonical[index])
|
||||
) {
|
||||
throw new PluginPackageActivationConflictError();
|
||||
}
|
||||
}
|
||||
|
||||
function digest(value: unknown): string {
|
||||
if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) {
|
||||
throw new PluginPackageActivationConflictError();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function boundedInteger(value: unknown): number {
|
||||
if (
|
||||
!Number.isSafeInteger(value) ||
|
||||
(value as number) < 0 ||
|
||||
(value as number) > 256 * 1024 * 1024
|
||||
) {
|
||||
throw new PluginPackageActivationConflictError();
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function readPrivateFile(
|
||||
authority: DirectoryAuthority,
|
||||
filePath: string,
|
||||
maximumBytes: number,
|
||||
allowEmpty = false,
|
||||
): Buffer {
|
||||
verifyDirectory(authority);
|
||||
const descriptor = fs.openSync(
|
||||
filePath,
|
||||
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
try {
|
||||
const stat = fs.fstatSync(descriptor, { bigint: true });
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
Number(stat.uid) !== authority.uid ||
|
||||
(Number(stat.mode) & 0o777) !== 0o600 ||
|
||||
(!allowEmpty && stat.size < 1n) ||
|
||||
stat.size > BigInt(maximumBytes)
|
||||
) {
|
||||
throw new PluginPackageActivationUnavailableError();
|
||||
}
|
||||
const material = Buffer.alloc(Number(stat.size));
|
||||
const bytesRead = fs.readSync(
|
||||
descriptor,
|
||||
material,
|
||||
0,
|
||||
material.byteLength,
|
||||
0,
|
||||
);
|
||||
if (bytesRead !== material.byteLength) {
|
||||
throw new PluginPackageActivationUnavailableError();
|
||||
}
|
||||
return material;
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function same(left: unknown, right: unknown): boolean {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
function syncDirectory(directory: string): void {
|
||||
const descriptor = fs.openSync(directory, fs.constants.O_RDONLY);
|
||||
try {
|
||||
fs.fsyncSync(descriptor);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function preserveDomainError(error: unknown): never {
|
||||
if (
|
||||
error instanceof PluginPackageActivationConflictError ||
|
||||
error instanceof PluginPackageActivationUnavailableError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new PluginPackageActivationUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export class LocalPluginPackageActivationPublisher
|
||||
implements
|
||||
PluginPackageActivationPublisher,
|
||||
PluginPackageResourceGenerationSource
|
||||
{
|
||||
readonly #staging: DirectoryAuthority;
|
||||
readonly #activation: DirectoryAuthority;
|
||||
readonly #now: () => number;
|
||||
|
||||
constructor(options: LocalPluginPackageActivationPublisherOptions) {
|
||||
const value = dataRecord(options, 'activation publisher options');
|
||||
exactKeys(value, ['stagingRoot', 'activationRoot', 'now']);
|
||||
if (typeof options.now !== 'function') {
|
||||
throw new TypeError('Plugin Package activation clock is invalid');
|
||||
}
|
||||
this.#staging = directoryAuthority(
|
||||
options.stagingRoot,
|
||||
'Plugin Package staging root',
|
||||
);
|
||||
this.#activation = directoryAuthority(
|
||||
options.activationRoot,
|
||||
'Plugin Package activation root',
|
||||
);
|
||||
if (
|
||||
this.#staging.device === this.#activation.device &&
|
||||
this.#staging.inode === this.#activation.inode
|
||||
) {
|
||||
throw new TypeError(
|
||||
'Plugin Package staging and activation roots must differ',
|
||||
);
|
||||
}
|
||||
this.#now = options.now;
|
||||
}
|
||||
|
||||
#pointerKey(
|
||||
intent: Readonly<
|
||||
Pick<PluginPackageActivationIntent, 'projectId' | 'packageName'>
|
||||
>,
|
||||
): string {
|
||||
return createHash('sha256')
|
||||
.update('qinglong/plugin-package-active-pointer-key@v1\0', 'utf8')
|
||||
.update(intent.projectId, 'utf8')
|
||||
.update('\0', 'utf8')
|
||||
.update(intent.packageName, 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
#pointerPath(
|
||||
intent: Readonly<
|
||||
Pick<PluginPackageActivationIntent, 'projectId' | 'packageName'>
|
||||
>,
|
||||
): string {
|
||||
return path.join(
|
||||
this.#activation.path,
|
||||
`${this.#pointerKey(intent)}.active.json`,
|
||||
);
|
||||
}
|
||||
|
||||
#lockPath(intent: Readonly<PluginPackageActivationIntent>): string {
|
||||
return path.join(
|
||||
this.#activation.path,
|
||||
`.${this.#pointerKey(intent)}.lock`,
|
||||
);
|
||||
}
|
||||
|
||||
#assertStage(intent: Readonly<PluginPackageActivationIntent>): void {
|
||||
verifyDirectory(this.#staging);
|
||||
if (intent.stageRef !== `${STAGE_REFERENCE_PREFIX}${intent.lockDigest}`) {
|
||||
throw new PluginPackageActivationConflictError();
|
||||
}
|
||||
const stageDirectory = path.join(this.#staging.path, intent.lockDigest);
|
||||
const stageStat = fs.lstatSync(stageDirectory, { bigint: true });
|
||||
if (
|
||||
!stageStat.isDirectory() ||
|
||||
stageStat.isSymbolicLink() ||
|
||||
Number(stageStat.uid) !== this.#staging.uid ||
|
||||
(Number(stageStat.mode) & 0o777) !== 0o700
|
||||
) {
|
||||
throw new PluginPackageActivationUnavailableError();
|
||||
}
|
||||
const receiptBytes = readPrivateFile(
|
||||
Object.freeze({
|
||||
path: stageDirectory,
|
||||
uid: this.#staging.uid,
|
||||
device: stageStat.dev,
|
||||
inode: stageStat.ino,
|
||||
}),
|
||||
path.join(stageDirectory, 'receipt.json'),
|
||||
MAX_STAGE_RECEIPT_BYTES,
|
||||
);
|
||||
try {
|
||||
if (
|
||||
createHash('sha256').update(receiptBytes).digest('hex') !==
|
||||
intent.stageEvidenceDigest
|
||||
) {
|
||||
throw new PluginPackageActivationConflictError();
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(receiptBytes.toString('utf8'));
|
||||
} catch {
|
||||
throw new PluginPackageActivationConflictError();
|
||||
}
|
||||
const receipt = dataRecord(parsed, 'stage receipt');
|
||||
exactKeys(receipt, ['schema', 'lockDigest', 'inspection', 'entries']);
|
||||
const inspection = dataRecord(receipt.inspection, 'stage inspection');
|
||||
const entries = receipt.entries;
|
||||
if (
|
||||
receipt.schema !== STAGE_RECEIPT_SCHEMA ||
|
||||
receipt.lockDigest !== intent.lockDigest ||
|
||||
inspection.lockDigest !== intent.lockDigest ||
|
||||
inspection.contentDigest !== intent.contentDigest ||
|
||||
!Array.isArray(entries) ||
|
||||
entries.length < 1 ||
|
||||
entries.length > MAX_STAGE_ENTRIES
|
||||
) {
|
||||
throw new PluginPackageActivationConflictError();
|
||||
}
|
||||
const directoryEntries = fs.readdirSync(stageDirectory).sort();
|
||||
if (
|
||||
directoryEntries.length !== 2 ||
|
||||
directoryEntries[0] !== 'blobs' ||
|
||||
directoryEntries[1] !== 'receipt.json'
|
||||
) {
|
||||
throw new PluginPackageActivationUnavailableError();
|
||||
}
|
||||
const blobDirectory = path.join(stageDirectory, 'blobs');
|
||||
const blobStat = fs.lstatSync(blobDirectory, { bigint: true });
|
||||
if (
|
||||
!blobStat.isDirectory() ||
|
||||
blobStat.isSymbolicLink() ||
|
||||
Number(blobStat.uid) !== this.#staging.uid ||
|
||||
(Number(blobStat.mode) & 0o777) !== 0o700
|
||||
) {
|
||||
throw new PluginPackageActivationUnavailableError();
|
||||
}
|
||||
const blobAuthority = Object.freeze({
|
||||
path: blobDirectory,
|
||||
uid: this.#staging.uid,
|
||||
device: blobStat.dev,
|
||||
inode: blobStat.ino,
|
||||
});
|
||||
const expectedNames: string[] = [];
|
||||
for (const entryValue of entries) {
|
||||
const entry = dataRecord(entryValue, 'stage entry');
|
||||
exactKeys(entry, ['path', 'bytes', 'digest', 'blob']);
|
||||
const blob = typeof entry.blob === 'string' ? entry.blob : '';
|
||||
if (!BLOB_NAME_PATTERN.test(blob)) {
|
||||
throw new PluginPackageActivationConflictError();
|
||||
}
|
||||
const bytes = boundedInteger(entry.bytes);
|
||||
const entryDigest = digest(entry.digest);
|
||||
const material = readPrivateFile(
|
||||
blobAuthority,
|
||||
path.join(blobDirectory, blob),
|
||||
bytes,
|
||||
true,
|
||||
);
|
||||
try {
|
||||
if (
|
||||
material.byteLength !== bytes ||
|
||||
createHash('sha256').update(material).digest('hex') !== entryDigest
|
||||
) {
|
||||
throw new PluginPackageActivationConflictError();
|
||||
}
|
||||
} finally {
|
||||
material.fill(0);
|
||||
}
|
||||
expectedNames.push(blob);
|
||||
}
|
||||
expectedNames.sort();
|
||||
const actualNames = fs.readdirSync(blobDirectory).sort();
|
||||
if (
|
||||
actualNames.length !== expectedNames.length ||
|
||||
actualNames.some((name, index) => name !== expectedNames[index])
|
||||
) {
|
||||
throw new PluginPackageActivationUnavailableError();
|
||||
}
|
||||
} finally {
|
||||
receiptBytes.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
#readPointer(
|
||||
identity: Readonly<
|
||||
Pick<PluginPackageActivationIntent, 'projectId' | 'packageName'>
|
||||
>,
|
||||
): Readonly<ActivePointer> | null {
|
||||
verifyDirectory(this.#activation);
|
||||
let bytes: Buffer;
|
||||
try {
|
||||
bytes = readPrivateFile(
|
||||
this.#activation,
|
||||
this.#pointerPath(identity),
|
||||
MAX_ACTIVE_POINTER_BYTES,
|
||||
);
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ENOENT')) return null;
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(bytes.toString('utf8'));
|
||||
} catch {
|
||||
throw new PluginPackageActivationConflictError();
|
||||
}
|
||||
const pointer = dataRecord(parsed, 'active pointer');
|
||||
exactKeys(pointer, ['schema', 'intent', 'receipt']);
|
||||
const pointerIntent = normalizePluginPackageActivationIntent(
|
||||
pointer.intent,
|
||||
);
|
||||
const receipt = normalizePluginPackageActivationReceipt(pointer.receipt);
|
||||
if (
|
||||
pointer.schema !== ACTIVE_POINTER_SCHEMA ||
|
||||
pointerIntent.projectId !== identity.projectId ||
|
||||
pointerIntent.packageName !== identity.packageName ||
|
||||
receipt.intentDigest !== pointerIntent.intentDigest ||
|
||||
receipt.generation !== pointerIntent.targetGeneration ||
|
||||
receipt.contentDigest !== pointerIntent.contentDigest ||
|
||||
`${JSON.stringify(pointer)}\n` !== bytes.toString('utf8')
|
||||
) {
|
||||
throw new PluginPackageActivationConflictError();
|
||||
}
|
||||
return Object.freeze({
|
||||
schema: ACTIVE_POINTER_SCHEMA,
|
||||
intent: pointerIntent,
|
||||
receipt,
|
||||
});
|
||||
} finally {
|
||||
bytes.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
#observe(
|
||||
intent: Readonly<PluginPackageActivationIntent>,
|
||||
): Readonly<PluginPackageActivationObservation> {
|
||||
this.#assertStage(intent);
|
||||
const pointer = this.#readPointer(intent);
|
||||
if (!pointer) {
|
||||
if (intent.previousActiveLockDigest !== null) {
|
||||
throw new PluginPackageActivationConflictError();
|
||||
}
|
||||
return Object.freeze({ status: 'not_published' });
|
||||
}
|
||||
if (same(pointer.intent, intent)) {
|
||||
return Object.freeze({
|
||||
status: 'published',
|
||||
receipt: pointer.receipt,
|
||||
});
|
||||
}
|
||||
if (
|
||||
pointer.intent.projectId === intent.projectId &&
|
||||
pointer.intent.packageName === intent.packageName &&
|
||||
pointer.intent.lockDigest === intent.previousActiveLockDigest
|
||||
) {
|
||||
return Object.freeze({ status: 'not_published' });
|
||||
}
|
||||
throw new PluginPackageActivationConflictError();
|
||||
}
|
||||
|
||||
async inspect(
|
||||
value: Readonly<PluginPackageActivationIntent>,
|
||||
): Promise<Readonly<PluginPackageActivationObservation>> {
|
||||
try {
|
||||
return this.#observe(normalizePluginPackageActivationIntent(value));
|
||||
} catch (error) {
|
||||
return preserveDomainError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async findActiveResourceGeneration(
|
||||
projectId: string,
|
||||
packageName: string,
|
||||
): Promise<Readonly<PluginPackageResourceGeneration> | null> {
|
||||
if (
|
||||
typeof projectId !== 'string' ||
|
||||
!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(projectId) ||
|
||||
typeof packageName !== 'string' ||
|
||||
!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(packageName)
|
||||
) {
|
||||
throw new TypeError('Plugin Package active resource identity is invalid');
|
||||
}
|
||||
try {
|
||||
return (
|
||||
this.#readPointer(Object.freeze({ projectId, packageName }))?.intent
|
||||
.resourceGeneration ?? null
|
||||
);
|
||||
} catch (error) {
|
||||
return preserveDomainError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async publish(
|
||||
value: Readonly<PluginPackageActivationIntent>,
|
||||
): Promise<Readonly<PluginPackageActivationReceipt>> {
|
||||
const intent = normalizePluginPackageActivationIntent(value);
|
||||
let descriptor: number | undefined;
|
||||
let ownedLock: OwnedLock | undefined;
|
||||
let temporaryPath: string | undefined;
|
||||
const lockPath = this.#lockPath(intent);
|
||||
try {
|
||||
const first = this.#observe(intent);
|
||||
if (first.status === 'published') return first.receipt;
|
||||
verifyDirectory(this.#activation);
|
||||
descriptor = fs.openSync(
|
||||
lockPath,
|
||||
fs.constants.O_WRONLY |
|
||||
fs.constants.O_CREAT |
|
||||
fs.constants.O_EXCL |
|
||||
(fs.constants.O_NOFOLLOW ?? 0),
|
||||
0o600,
|
||||
);
|
||||
const lockStat = fs.fstatSync(descriptor, { bigint: true });
|
||||
if (
|
||||
!lockStat.isFile() ||
|
||||
Number(lockStat.uid) !== this.#activation.uid ||
|
||||
(Number(lockStat.mode) & 0o777) !== 0o600 ||
|
||||
lockStat.nlink !== 1n
|
||||
) {
|
||||
throw new PluginPackageActivationUnavailableError();
|
||||
}
|
||||
ownedLock = Object.freeze({
|
||||
device: lockStat.dev,
|
||||
inode: lockStat.ino,
|
||||
});
|
||||
fs.writeFileSync(descriptor, `${intent.intentDigest}\n`, 'utf8');
|
||||
fs.fsyncSync(descriptor);
|
||||
fs.closeSync(descriptor);
|
||||
descriptor = undefined;
|
||||
syncDirectory(this.#activation.path);
|
||||
|
||||
const second = this.#observe(intent);
|
||||
if (second.status === 'published') return second.receipt;
|
||||
const activatedAtMs = this.#now();
|
||||
if (!Number.isSafeInteger(activatedAtMs) || activatedAtMs < 0) {
|
||||
throw new PluginPackageActivationUnavailableError();
|
||||
}
|
||||
const receipt = createPluginPackageActivationReceipt({
|
||||
activationRef: `local-active:${this.#pointerKey(intent)}`,
|
||||
intentDigest: intent.intentDigest,
|
||||
generation: intent.targetGeneration,
|
||||
contentDigest: intent.contentDigest,
|
||||
activatedAtMs,
|
||||
});
|
||||
const pointer: Readonly<ActivePointer> = Object.freeze({
|
||||
schema: ACTIVE_POINTER_SCHEMA,
|
||||
intent,
|
||||
receipt,
|
||||
});
|
||||
const serialized = `${JSON.stringify(pointer)}\n`;
|
||||
if (Buffer.byteLength(serialized, 'utf8') > MAX_ACTIVE_POINTER_BYTES) {
|
||||
throw new PluginPackageActivationUnavailableError();
|
||||
}
|
||||
temporaryPath = path.join(
|
||||
this.#activation.path,
|
||||
`.${this.#pointerKey(intent)}.${randomBytes(16).toString('hex')}.tmp`,
|
||||
);
|
||||
descriptor = fs.openSync(
|
||||
temporaryPath,
|
||||
fs.constants.O_WRONLY |
|
||||
fs.constants.O_CREAT |
|
||||
fs.constants.O_EXCL |
|
||||
(fs.constants.O_NOFOLLOW ?? 0),
|
||||
0o600,
|
||||
);
|
||||
fs.writeFileSync(descriptor, serialized, 'utf8');
|
||||
fs.fsyncSync(descriptor);
|
||||
fs.closeSync(descriptor);
|
||||
descriptor = undefined;
|
||||
fs.renameSync(temporaryPath, this.#pointerPath(intent));
|
||||
temporaryPath = undefined;
|
||||
syncDirectory(this.#activation.path);
|
||||
const final = this.#observe(intent);
|
||||
if (final.status !== 'published') {
|
||||
throw new PluginPackageActivationUnavailableError();
|
||||
}
|
||||
return final.receipt;
|
||||
} catch (error) {
|
||||
return preserveDomainError(error);
|
||||
} finally {
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
if (temporaryPath) {
|
||||
try {
|
||||
fs.unlinkSync(temporaryPath);
|
||||
syncDirectory(this.#activation.path);
|
||||
} catch {
|
||||
// A non-published private temporary file requires explicit repair.
|
||||
}
|
||||
}
|
||||
if (ownedLock) {
|
||||
try {
|
||||
const lockStat = fs.lstatSync(lockPath, { bigint: true });
|
||||
if (
|
||||
lockStat.isFile() &&
|
||||
!lockStat.isSymbolicLink() &&
|
||||
Number(lockStat.uid) === this.#activation.uid &&
|
||||
(Number(lockStat.mode) & 0o777) === 0o600 &&
|
||||
lockStat.dev === ownedLock.device &&
|
||||
lockStat.ino === ownedLock.inode
|
||||
) {
|
||||
fs.unlinkSync(lockPath);
|
||||
syncDirectory(this.#activation.path);
|
||||
}
|
||||
} catch {
|
||||
// A missing or replaced owned lock is left for explicit repair.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isLocalPluginPackageActivePointerName(value: string): boolean {
|
||||
return ACTIVE_POINTER_NAME_PATTERN.test(value);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import { LocalSqliteApprovedActionExecutionRepository } from '@qinglong/local-sqlite/approved-action-execution';
|
||||
import { LocalSqliteOperationAuthority } from '@qinglong/local-sqlite/operation-authority';
|
||||
import { LocalSqlitePluginPackageInstallRepository } from '@qinglong/local-sqlite/plugin-package-install';
|
||||
import { LocalSqlitePluginPackageInstallProposalRepository } from '@qinglong/local-sqlite/plugin-package-proposal';
|
||||
import {
|
||||
ApprovedActionDispatcher,
|
||||
type ApprovedActionDispatcherOptions,
|
||||
} from '@qinglong/runtime-core/approved-action-dispatcher';
|
||||
import { PluginPackageApprovedActionHandler } from '@qinglong/runtime-core/plugin-package-approved-action';
|
||||
|
||||
export const LOCAL_PLUGIN_PACKAGE_DISPATCH_BATCH_LIMITS = Object.freeze({
|
||||
edge: 1,
|
||||
standalone: 4,
|
||||
} as const);
|
||||
|
||||
export type LocalPluginPackageDispatchProfile =
|
||||
keyof typeof LOCAL_PLUGIN_PACKAGE_DISPATCH_BATCH_LIMITS;
|
||||
|
||||
export interface LocalPluginPackageApprovedActionDispatcherOptions
|
||||
extends Omit<ApprovedActionDispatcherOptions, 'defaultBatchSize'> {
|
||||
readonly authority: LocalSqliteOperationAuthority | DatabaseSync;
|
||||
readonly profile: LocalPluginPackageDispatchProfile;
|
||||
readonly defaultBatchSize?: number;
|
||||
}
|
||||
|
||||
export function createLocalPluginPackageApprovedActionDispatcher(
|
||||
options: LocalPluginPackageApprovedActionDispatcherOptions,
|
||||
): ApprovedActionDispatcher {
|
||||
if (!options || typeof options !== 'object') {
|
||||
throw new TypeError('local Package Approved Action options are invalid');
|
||||
}
|
||||
const {
|
||||
authority: authorityValue,
|
||||
profile,
|
||||
defaultBatchSize,
|
||||
...dispatcherOptions
|
||||
} = options;
|
||||
if (!Object.hasOwn(LOCAL_PLUGIN_PACKAGE_DISPATCH_BATCH_LIMITS, profile)) {
|
||||
throw new TypeError('local Package Approved Action profile is invalid');
|
||||
}
|
||||
const authority =
|
||||
authorityValue instanceof LocalSqliteOperationAuthority
|
||||
? authorityValue
|
||||
: new LocalSqliteOperationAuthority(authorityValue);
|
||||
const executions = new LocalSqliteApprovedActionExecutionRepository(
|
||||
authority,
|
||||
);
|
||||
const handler = new PluginPackageApprovedActionHandler(
|
||||
new LocalSqlitePluginPackageInstallProposalRepository(authority),
|
||||
new LocalSqlitePluginPackageInstallRepository(authority),
|
||||
);
|
||||
return new ApprovedActionDispatcher(executions, [handler], {
|
||||
...dispatcherOptions,
|
||||
defaultBatchSize:
|
||||
defaultBatchSize ?? LOCAL_PLUGIN_PACKAGE_DISPATCH_BATCH_LIMITS[profile],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
PluginPackageInstallationCoordinator,
|
||||
type PluginPackageStageProvider,
|
||||
} from '@qinglong/runtime-core/plugin-package-installation';
|
||||
import type { PluginPackageAdmissionRepository } from '@qinglong/runtime-core/plugin-package-admission';
|
||||
import type { PluginPackageActivationPublisher } from '@qinglong/runtime-core/plugin-package-activation';
|
||||
import {
|
||||
normalizePluginPackageLock,
|
||||
type PluginPackageLock,
|
||||
} from '@qinglong/runtime-core/plugin-package-install';
|
||||
import type { PluginPackageManifest } from '@qinglong/runtime-core/plugin-package';
|
||||
import type {
|
||||
PluginPackagePublisherTrustRegistry,
|
||||
PluginPackageSignature,
|
||||
} from '@qinglong/runtime-core/plugin-package-bundle';
|
||||
|
||||
import {
|
||||
stagePluginPackageFromFile,
|
||||
type StagePluginPackageFromFileOptions,
|
||||
} from './pluginPackageStaging';
|
||||
|
||||
export type LocalPluginPackageFileStageProviderOptions = Omit<
|
||||
StagePluginPackageFromFileOptions,
|
||||
'lock'
|
||||
>;
|
||||
|
||||
export function createLocalPluginPackageFileStageProvider(
|
||||
options: LocalPluginPackageFileStageProviderOptions,
|
||||
): PluginPackageStageProvider {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).length !== 6
|
||||
) {
|
||||
throw new TypeError('Plugin Package file stage provider is invalid');
|
||||
}
|
||||
const frozen = Object.freeze({
|
||||
bundlePath: options.bundlePath,
|
||||
stagingRoot: options.stagingRoot,
|
||||
manifest: options.manifest as PluginPackageManifest,
|
||||
signature: options.signature as PluginPackageSignature,
|
||||
trust: options.trust as PluginPackagePublisherTrustRegistry,
|
||||
observedAtMs: options.observedAtMs,
|
||||
});
|
||||
return Object.freeze({
|
||||
async stage(lockValue: Readonly<PluginPackageLock>) {
|
||||
const lock = normalizePluginPackageLock(lockValue);
|
||||
const staged = await stagePluginPackageFromFile({
|
||||
...frozen,
|
||||
lock,
|
||||
});
|
||||
return Object.freeze({
|
||||
stageRef: staged.stageRef,
|
||||
artifactDigest: staged.inspection.artifactDigest,
|
||||
manifestDigest: staged.inspection.manifestDigest,
|
||||
contentDigest: staged.inspection.contentDigest,
|
||||
evidenceDigest: staged.receiptDigest,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createLocalPluginPackageInstallationCoordinator(options: {
|
||||
readonly repository: PluginPackageAdmissionRepository;
|
||||
readonly publisher: PluginPackageActivationPublisher;
|
||||
}): PluginPackageInstallationCoordinator {
|
||||
return new PluginPackageInstallationCoordinator(options);
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import { LocalSqliteApprovalRequestRepository } from '@qinglong/local-sqlite/approved-action';
|
||||
import { LocalSqliteOperationAuthority } from '@qinglong/local-sqlite/operation-authority';
|
||||
import { LocalSqlitePluginPackageLifecycleRepository } from '@qinglong/local-sqlite/plugin-package-lifecycle';
|
||||
import { LocalSqliteProjectPolicyRepository } from '@qinglong/local-sqlite/project-policy';
|
||||
import {
|
||||
createApprovalRequest,
|
||||
normalizeApprovalRequestRecord,
|
||||
type ApprovedActionBinding,
|
||||
type ApprovalRequestRecord,
|
||||
} from '@qinglong/runtime-core/approved-action';
|
||||
import {
|
||||
createPluginPackageLifecycleEvent,
|
||||
normalizePluginPackageLifecycleImpact,
|
||||
pluginPackageLifecycleActionDigest,
|
||||
PluginPackageLifecycleConflictError,
|
||||
type PluginPackageLifecycleAction,
|
||||
type PluginPackageLifecycleImpact,
|
||||
type PluginPackageLifecycleReceipt,
|
||||
} from '@qinglong/runtime-core/plugin-package-lifecycle';
|
||||
import { ProjectPolicyEngine } from '@qinglong/runtime-core/project-policy';
|
||||
import {
|
||||
normalizeSecurityPrincipal,
|
||||
type SecurityPolicyFence,
|
||||
type SecurityPrincipal,
|
||||
type SecuritySubject,
|
||||
} from '@qinglong/runtime-core/security';
|
||||
import type { SecurityAuditRecord } from '@qinglong/runtime-core/security-audit';
|
||||
|
||||
const APPROVAL_LIFETIME_MS = 15 * 60 * 1000;
|
||||
const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const REASON_PATTERN = /^[a-z][a-z0-9_]{0,63}$/;
|
||||
const LOCAL_LIFECYCLE_CONSUMER = Object.freeze({
|
||||
subject: Object.freeze({
|
||||
type: 'system' as const,
|
||||
id: 'local_plugin_package_lifecycle_executor',
|
||||
}),
|
||||
authenticationId: 'local_plugin_package_lifecycle_executor_v1',
|
||||
});
|
||||
|
||||
export interface LocalPluginPackageLifecycleOptions {
|
||||
readonly authority: LocalSqliteOperationAuthority | DatabaseSync;
|
||||
readonly now?: () => number;
|
||||
}
|
||||
|
||||
export interface ExecuteLocalPluginPackageLifecycleRequest {
|
||||
readonly impact: PluginPackageLifecycleImpact;
|
||||
readonly approvalRequestId: string;
|
||||
readonly decisionId: string;
|
||||
readonly consumptionId: string;
|
||||
readonly dispatchId: string;
|
||||
readonly approvalAuditEventId: string;
|
||||
readonly decisionAuditEventId: string;
|
||||
readonly consumptionAuditEventId: string;
|
||||
readonly reasonCode: string;
|
||||
readonly principal: SecurityPrincipal;
|
||||
readonly confirmAuthorization: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface LocalPluginPackageLifecycleExecutionResult {
|
||||
readonly status: 'created' | 'existing';
|
||||
readonly approval: Readonly<ApprovalRequestRecord>;
|
||||
readonly receipt: Readonly<PluginPackageLifecycleReceipt>;
|
||||
}
|
||||
|
||||
export interface LocalPluginPackageLifecycleService {
|
||||
plan(
|
||||
action: PluginPackageLifecycleAction,
|
||||
projectId: string,
|
||||
packageName: string,
|
||||
principal: SecurityPrincipal,
|
||||
): Promise<Readonly<PluginPackageLifecycleImpact>>;
|
||||
execute(
|
||||
request: ExecuteLocalPluginPackageLifecycleRequest,
|
||||
): Promise<Readonly<LocalPluginPackageLifecycleExecutionResult>>;
|
||||
}
|
||||
|
||||
function identifier(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !IDENTIFIER_PATTERN.test(value)) {
|
||||
throw new TypeError(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function observedTime(now: () => number): number {
|
||||
const value = now();
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new TypeError('local Plugin Package lifecycle clock is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function sameSubject(
|
||||
left: Readonly<SecuritySubject>,
|
||||
right: Readonly<SecuritySubject>,
|
||||
): boolean {
|
||||
return left.type === right.type && left.id === right.id;
|
||||
}
|
||||
|
||||
function sameAction(
|
||||
left: Readonly<ApprovedActionBinding>,
|
||||
right: Readonly<ApprovedActionBinding>,
|
||||
): boolean {
|
||||
return (
|
||||
left.permission === right.permission &&
|
||||
left.actionType === right.actionType &&
|
||||
left.actionRef === right.actionRef &&
|
||||
left.actionDigest === right.actionDigest &&
|
||||
left.previewDigest === right.previewDigest
|
||||
);
|
||||
}
|
||||
|
||||
function audit(
|
||||
eventId: string,
|
||||
requestId: string,
|
||||
operationId: 'approval.request' | 'approval.decide' | 'approval.consume',
|
||||
projectId: string,
|
||||
subject: Readonly<SecuritySubject>,
|
||||
authenticationId: string,
|
||||
outcome: 'allowed' | 'approval_required',
|
||||
fence: Readonly<SecurityPolicyFence>,
|
||||
occurredAtMs: number,
|
||||
): Readonly<SecurityAuditRecord> {
|
||||
return Object.freeze({
|
||||
eventId,
|
||||
requestId,
|
||||
operationId,
|
||||
projectId,
|
||||
subject,
|
||||
authenticationId,
|
||||
outcome,
|
||||
reasons: Object.freeze(['package_lifecycle_review']),
|
||||
fence,
|
||||
occurredAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
function actionBinding(
|
||||
impact: Readonly<PluginPackageLifecycleImpact>,
|
||||
): Readonly<ApprovedActionBinding> {
|
||||
return Object.freeze({
|
||||
permission: 'package.manage',
|
||||
actionType: `plugin_package.lifecycle.${impact.action}`,
|
||||
actionRef: `lifecycle:${impact.impactDigest}`,
|
||||
actionDigest: pluginPackageLifecycleActionDigest(impact),
|
||||
previewDigest: impact.impactDigest,
|
||||
});
|
||||
}
|
||||
|
||||
export function createLocalPluginPackageLifecycleService(
|
||||
options: LocalPluginPackageLifecycleOptions,
|
||||
): Readonly<LocalPluginPackageLifecycleService> {
|
||||
if (
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
Object.keys(options).some(
|
||||
(key) => key !== 'authority' && key !== 'now',
|
||||
) ||
|
||||
(options.now !== undefined && typeof options.now !== 'function')
|
||||
) {
|
||||
throw new TypeError('local Plugin Package lifecycle options are invalid');
|
||||
}
|
||||
const authority =
|
||||
options.authority instanceof LocalSqliteOperationAuthority
|
||||
? options.authority
|
||||
: new LocalSqliteOperationAuthority(options.authority);
|
||||
const now = options.now ?? Date.now;
|
||||
const policy = new ProjectPolicyEngine(
|
||||
new LocalSqliteProjectPolicyRepository(authority),
|
||||
);
|
||||
const approvals = new LocalSqliteApprovalRequestRepository(authority);
|
||||
const lifecycles = new LocalSqlitePluginPackageLifecycleRepository(authority);
|
||||
|
||||
const authorize = async (
|
||||
principalValue: SecurityPrincipal,
|
||||
projectId: string,
|
||||
): Promise<
|
||||
Readonly<{
|
||||
principal: Readonly<SecurityPrincipal>;
|
||||
fence: Readonly<SecurityPolicyFence>;
|
||||
}>
|
||||
> => {
|
||||
const at = observedTime(now);
|
||||
const principal = normalizeSecurityPrincipal(principalValue, at);
|
||||
if (
|
||||
principal.subject.type !== 'user' ||
|
||||
principal.assurance !== 'local_console'
|
||||
) {
|
||||
throw new PluginPackageLifecycleConflictError(
|
||||
'local lifecycle requires a local-console User',
|
||||
);
|
||||
}
|
||||
const decision = await policy.authorize(
|
||||
principal,
|
||||
projectId,
|
||||
'package.manage',
|
||||
);
|
||||
if (decision.effect !== 'allow' || decision.fence === null) {
|
||||
throw new PluginPackageLifecycleConflictError(
|
||||
'local lifecycle is not authorized by current Project policy',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ principal, fence: decision.fence });
|
||||
};
|
||||
|
||||
return Object.freeze({
|
||||
async plan(
|
||||
action: PluginPackageLifecycleAction,
|
||||
projectId: string,
|
||||
packageName: string,
|
||||
principalValue: SecurityPrincipal,
|
||||
) {
|
||||
await authorize(principalValue, projectId);
|
||||
return lifecycles.plan(action, projectId, packageName);
|
||||
},
|
||||
|
||||
async execute(request: ExecuteLocalPluginPackageLifecycleRequest) {
|
||||
if (
|
||||
!request ||
|
||||
typeof request !== 'object' ||
|
||||
Array.isArray(request) ||
|
||||
Object.keys(request).sort().join('\0') !==
|
||||
[
|
||||
'approvalAuditEventId',
|
||||
'approvalRequestId',
|
||||
'confirmAuthorization',
|
||||
'consumptionAuditEventId',
|
||||
'consumptionId',
|
||||
'decisionAuditEventId',
|
||||
'decisionId',
|
||||
'dispatchId',
|
||||
'impact',
|
||||
'principal',
|
||||
'reasonCode',
|
||||
]
|
||||
.sort()
|
||||
.join('\0') ||
|
||||
typeof request.confirmAuthorization !== 'function' ||
|
||||
typeof request.reasonCode !== 'string' ||
|
||||
!REASON_PATTERN.test(request.reasonCode)
|
||||
) {
|
||||
throw new TypeError(
|
||||
'local Plugin Package lifecycle execution request is invalid',
|
||||
);
|
||||
}
|
||||
const approvalRequestId = identifier(
|
||||
request.approvalRequestId,
|
||||
'approvalRequestId',
|
||||
);
|
||||
const decisionId = identifier(request.decisionId, 'decisionId');
|
||||
const consumptionId = identifier(
|
||||
request.consumptionId,
|
||||
'consumptionId',
|
||||
);
|
||||
const dispatchId = identifier(request.dispatchId, 'dispatchId');
|
||||
const approvalAuditEventId = identifier(
|
||||
request.approvalAuditEventId,
|
||||
'approvalAuditEventId',
|
||||
);
|
||||
const decisionAuditEventId = identifier(
|
||||
request.decisionAuditEventId,
|
||||
'decisionAuditEventId',
|
||||
);
|
||||
const consumptionAuditEventId = identifier(
|
||||
request.consumptionAuditEventId,
|
||||
'consumptionAuditEventId',
|
||||
);
|
||||
const impact = normalizePluginPackageLifecycleImpact(request.impact);
|
||||
await request.confirmAuthorization();
|
||||
let authorization = await authorize(
|
||||
request.principal,
|
||||
impact.target.projectId,
|
||||
);
|
||||
const action = actionBinding(impact);
|
||||
let approval = await approvals.findById(approvalRequestId);
|
||||
if (!approval) {
|
||||
const requestedAtMs = observedTime(now);
|
||||
const created = await approvals.create({
|
||||
request: createApprovalRequest({
|
||||
id: approvalRequestId,
|
||||
projectId: impact.target.projectId,
|
||||
action,
|
||||
risk: 'high',
|
||||
decisionMode: 'human_confirmation',
|
||||
requestedBy: authorization.principal.subject,
|
||||
requestedAtMs,
|
||||
expiresAtMs: requestedAtMs + APPROVAL_LIFETIME_MS,
|
||||
requestFence: authorization.fence,
|
||||
}),
|
||||
audit: audit(
|
||||
approvalAuditEventId,
|
||||
approvalRequestId,
|
||||
'approval.request',
|
||||
impact.target.projectId,
|
||||
authorization.principal.subject,
|
||||
authorization.principal.authenticationId,
|
||||
'approval_required',
|
||||
authorization.fence,
|
||||
requestedAtMs,
|
||||
),
|
||||
});
|
||||
approval = created.request;
|
||||
} else {
|
||||
approval = normalizeApprovalRequestRecord(approval);
|
||||
if (
|
||||
approval.projectId !== impact.target.projectId ||
|
||||
approval.decisionMode !== 'human_confirmation' ||
|
||||
!sameSubject(
|
||||
approval.requestedBy,
|
||||
authorization.principal.subject,
|
||||
) ||
|
||||
!sameAction(approval.action, action)
|
||||
) {
|
||||
throw new PluginPackageLifecycleConflictError(
|
||||
'approval identity is already bound to another lifecycle action',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (approval.version === 1) {
|
||||
const decidedAtMs = observedTime(now);
|
||||
authorization = await authorize(
|
||||
request.principal,
|
||||
impact.target.projectId,
|
||||
);
|
||||
const decided = await approvals.decide({
|
||||
requestId: approvalRequestId,
|
||||
expectedVersion: 1,
|
||||
decisionId,
|
||||
decision: 'approved',
|
||||
reasonCode: request.reasonCode,
|
||||
principal: authorization.principal,
|
||||
decidedAtMs,
|
||||
authorizationFence: authorization.fence,
|
||||
audit: audit(
|
||||
decisionAuditEventId,
|
||||
approvalRequestId,
|
||||
'approval.decide',
|
||||
impact.target.projectId,
|
||||
authorization.principal.subject,
|
||||
authorization.principal.authenticationId,
|
||||
'allowed',
|
||||
authorization.fence,
|
||||
decidedAtMs,
|
||||
),
|
||||
});
|
||||
approval = decided.request;
|
||||
}
|
||||
if (
|
||||
approval.state !== 'approved' &&
|
||||
approval.state !== 'consumed'
|
||||
) {
|
||||
throw new PluginPackageLifecycleConflictError(
|
||||
'lifecycle approval is not approved',
|
||||
);
|
||||
}
|
||||
if (
|
||||
approval.decisionId !== decisionId ||
|
||||
approval.decision !== 'approved' ||
|
||||
approval.decisionReasonCode !== request.reasonCode ||
|
||||
!approval.decidedBy ||
|
||||
!sameSubject(
|
||||
approval.decidedBy,
|
||||
authorization.principal.subject,
|
||||
)
|
||||
) {
|
||||
throw new PluginPackageLifecycleConflictError(
|
||||
'lifecycle approval decision is bound to another command',
|
||||
);
|
||||
}
|
||||
|
||||
let dispatch = await approvals.findDispatchById(dispatchId);
|
||||
if (approval.version === 2) {
|
||||
const consumedAtMs = observedTime(now);
|
||||
authorization = await authorize(
|
||||
request.principal,
|
||||
impact.target.projectId,
|
||||
);
|
||||
const consumed = await approvals.consume({
|
||||
requestId: approvalRequestId,
|
||||
expectedVersion: 2,
|
||||
consumptionId,
|
||||
dispatchId,
|
||||
action,
|
||||
requestedBy: authorization.principal.subject,
|
||||
consumedBy: LOCAL_LIFECYCLE_CONSUMER.subject,
|
||||
consumedAtMs,
|
||||
authorizationFence: authorization.fence,
|
||||
audit: audit(
|
||||
consumptionAuditEventId,
|
||||
approvalRequestId,
|
||||
'approval.consume',
|
||||
impact.target.projectId,
|
||||
LOCAL_LIFECYCLE_CONSUMER.subject,
|
||||
LOCAL_LIFECYCLE_CONSUMER.authenticationId,
|
||||
'allowed',
|
||||
authorization.fence,
|
||||
consumedAtMs,
|
||||
),
|
||||
});
|
||||
approval = consumed.request;
|
||||
dispatch = consumed.dispatch;
|
||||
}
|
||||
if (
|
||||
approval.version !== 3 ||
|
||||
approval.state !== 'consumed' ||
|
||||
approval.consumptionId !== consumptionId ||
|
||||
approval.dispatchId !== dispatchId ||
|
||||
!dispatch ||
|
||||
!sameAction(dispatch.action, action) ||
|
||||
!sameSubject(
|
||||
dispatch.requestedBy,
|
||||
authorization.principal.subject,
|
||||
) ||
|
||||
!sameSubject(
|
||||
dispatch.approvedBy,
|
||||
authorization.principal.subject,
|
||||
) ||
|
||||
!sameSubject(dispatch.consumedBy, LOCAL_LIFECYCLE_CONSUMER.subject)
|
||||
) {
|
||||
throw new PluginPackageLifecycleConflictError(
|
||||
'lifecycle dispatch is bound to another command',
|
||||
);
|
||||
}
|
||||
|
||||
const event = createPluginPackageLifecycleEvent({
|
||||
dispatchId: dispatch.id,
|
||||
impact,
|
||||
requestedBy: dispatch.requestedBy,
|
||||
approvedBy: dispatch.approvedBy,
|
||||
authorizationMode: 'human_confirmation',
|
||||
occurredAtMs: dispatch.createdAtMs,
|
||||
});
|
||||
const transitioned = await lifecycles.transition(event, async () => {
|
||||
await request.confirmAuthorization();
|
||||
});
|
||||
return Object.freeze({
|
||||
status: transitioned.status,
|
||||
approval,
|
||||
receipt: transitioned.receipt,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import { LocalSqliteApprovalRequestRepository } from '@qinglong/local-sqlite/approved-action';
|
||||
import { LocalSqliteOperationAuthority } from '@qinglong/local-sqlite/operation-authority';
|
||||
import { LocalSqlitePluginPackageInstallProposalRepository } from '@qinglong/local-sqlite/plugin-package-proposal';
|
||||
import { LocalSqliteProjectPolicyRepository } from '@qinglong/local-sqlite/project-policy';
|
||||
import type { ApprovedActionDispatcherOptions } from '@qinglong/runtime-core/approved-action-dispatcher';
|
||||
import {
|
||||
createPluginPackageManagementService,
|
||||
type PluginPackageManagementOptions,
|
||||
type PluginPackageManagementService,
|
||||
} from '@qinglong/runtime-core/plugin-package-management';
|
||||
import { ProjectPolicyEngine } from '@qinglong/runtime-core/project-policy';
|
||||
|
||||
import {
|
||||
createLocalPluginPackageApprovedActionDispatcher,
|
||||
type LocalPluginPackageDispatchProfile,
|
||||
} from './pluginPackageApprovedAction';
|
||||
|
||||
export const LOCAL_PLUGIN_PACKAGE_MANAGEMENT_DECISION_MODE =
|
||||
'human_confirmation' as const;
|
||||
|
||||
export interface LocalPluginPackageManagementOptions {
|
||||
readonly authority: LocalSqliteOperationAuthority | DatabaseSync;
|
||||
readonly profile: LocalPluginPackageDispatchProfile;
|
||||
readonly consumer: PluginPackageManagementOptions['consumer'];
|
||||
readonly dispatcher: Omit<
|
||||
ApprovedActionDispatcherOptions,
|
||||
'defaultBatchSize'
|
||||
> & {
|
||||
readonly defaultBatchSize?: number;
|
||||
};
|
||||
readonly approvalLifetimeMs?: number;
|
||||
readonly now?: () => number;
|
||||
}
|
||||
|
||||
export function createLocalPluginPackageManagementService(
|
||||
options: LocalPluginPackageManagementOptions,
|
||||
): PluginPackageManagementService {
|
||||
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
||||
throw new TypeError('local Plugin Package management options are invalid');
|
||||
}
|
||||
const authority =
|
||||
options.authority instanceof LocalSqliteOperationAuthority
|
||||
? options.authority
|
||||
: new LocalSqliteOperationAuthority(options.authority);
|
||||
const dispatcher = createLocalPluginPackageApprovedActionDispatcher({
|
||||
authority,
|
||||
profile: options.profile,
|
||||
...options.dispatcher,
|
||||
});
|
||||
return createPluginPackageManagementService(
|
||||
new ProjectPolicyEngine(
|
||||
new LocalSqliteProjectPolicyRepository(authority),
|
||||
),
|
||||
new LocalSqlitePluginPackageInstallProposalRepository(authority),
|
||||
new LocalSqliteApprovalRequestRepository(authority),
|
||||
dispatcher,
|
||||
{
|
||||
decisionMode: LOCAL_PLUGIN_PACKAGE_MANAGEMENT_DECISION_MODE,
|
||||
consumer: options.consumer,
|
||||
...(options.approvalLifetimeMs === undefined
|
||||
? {}
|
||||
: { approvalLifetimeMs: options.approvalLifetimeMs }),
|
||||
...(options.now === undefined ? {} : { now: options.now }),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export * from './publisher-trust/contracts';
|
||||
|
||||
export {
|
||||
createLocalPluginPackagePublisherTrustRegistry,
|
||||
localPluginPackagePublisherKeyRevocationImpactDigest,
|
||||
normalizeLocalPluginPackagePublisherTrustDocument,
|
||||
} from './publisher-trust/codec';
|
||||
|
||||
export { inspectLocalPluginPackagePublisherTrust } from './publisher-trust/lifecycle/inspection';
|
||||
export {
|
||||
assertLocalPluginPackagePublisherKeyPublicationAllowed,
|
||||
publishLocalPluginPackagePublisherTrust,
|
||||
} from './publisher-trust/lifecycle/publication';
|
||||
export { retireLocalPluginPackagePublisherKey } from './publisher-trust/lifecycle/retirement';
|
||||
export {
|
||||
confirmLocalPluginPackagePublisherKeyRevocation,
|
||||
proposeLocalPluginPackagePublisherKeyRevocation,
|
||||
} from './publisher-trust/lifecycle/revocation';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,468 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { constants } from 'node:fs';
|
||||
import fs, { type FileHandle } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
pluginPackageContentTreeDigest,
|
||||
type PluginPackageContentEntryDescriptor,
|
||||
} from '@qinglong/runtime-core/plugin-package-bundle';
|
||||
import {
|
||||
normalizePluginPackageResourceGeneration,
|
||||
type PluginPackageResourceGeneration,
|
||||
} from '@qinglong/runtime-core/plugin-package-resource-generation';
|
||||
import type {
|
||||
PluginPackageResourceByteReader,
|
||||
PluginPackageResourceByteSource,
|
||||
} from '@qinglong/runtime-core/plugin-package-resource-materialization';
|
||||
|
||||
const STAGE_RECEIPT_SCHEMA = 'qinglong/plugin-package-stage-receipt@v1';
|
||||
const STAGE_RECEIPT_BYTES = 64 * 1024;
|
||||
const MAX_STAGE_ENTRIES = 257;
|
||||
const DIGEST = /^[0-9a-f]{64}$/;
|
||||
const BLOB = /^[0-9]{4}-[0-9a-f]{64}\.blob$/;
|
||||
|
||||
export interface LocalPluginPackageResourceByteSourceOptions {
|
||||
/** Existing owner-only 0700 root used by Package staging. */
|
||||
readonly stagingRoot: string;
|
||||
}
|
||||
|
||||
export class InvalidLocalPluginPackageResourceSourceError extends Error {
|
||||
readonly code = 'LOCAL_PLUGIN_PACKAGE_RESOURCE_SOURCE_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Local Plugin Package resource source is invalid: ${message}`);
|
||||
this.name = 'InvalidLocalPluginPackageResourceSourceError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalPluginPackageResourceSourceUnavailableError extends Error {
|
||||
readonly code = 'LOCAL_PLUGIN_PACKAGE_RESOURCE_SOURCE_UNAVAILABLE';
|
||||
|
||||
constructor(options?: ErrorOptions) {
|
||||
super('Local Plugin Package resource source is unavailable', options);
|
||||
this.name = 'LocalPluginPackageResourceSourceUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
interface DirectoryAuthority {
|
||||
readonly path: string;
|
||||
readonly uid: number;
|
||||
readonly device: bigint;
|
||||
readonly inode: bigint;
|
||||
}
|
||||
|
||||
interface ReceiptEntry extends PluginPackageContentEntryDescriptor {
|
||||
readonly blob: string;
|
||||
}
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new InvalidLocalPluginPackageResourceSourceError(message);
|
||||
}
|
||||
|
||||
function unavailable(error: unknown): never {
|
||||
throw new LocalPluginPackageResourceSourceUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function dataRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
(Object.getPrototypeOf(value) !== Object.prototype &&
|
||||
Object.getPrototypeOf(value) !== null)
|
||||
) {
|
||||
return invalid(`${label} must be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactKeys(
|
||||
value: object,
|
||||
expected: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
if (
|
||||
actual.length !== canonical.length ||
|
||||
actual.some((key, index) => key !== canonical[index])
|
||||
) {
|
||||
invalid(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function currentUid(): number {
|
||||
if (typeof process.getuid !== 'function') {
|
||||
return invalid('POSIX process identity is required');
|
||||
}
|
||||
return process.getuid();
|
||||
}
|
||||
|
||||
function absoluteRoot(value: unknown): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!path.isAbsolute(value) ||
|
||||
path.parse(value).root === value ||
|
||||
value.includes('\0') ||
|
||||
Buffer.byteLength(value, 'utf8') > 4096
|
||||
) {
|
||||
return invalid('staging root must be a bounded non-root absolute path');
|
||||
}
|
||||
return path.normalize(value);
|
||||
}
|
||||
|
||||
async function directoryAuthority(
|
||||
value: string,
|
||||
uid: number,
|
||||
label: string,
|
||||
expectedDevice?: bigint,
|
||||
): Promise<Readonly<DirectoryAuthority>> {
|
||||
const stat = await fs.lstat(value, { bigint: true });
|
||||
if (
|
||||
!stat.isDirectory() ||
|
||||
stat.isSymbolicLink() ||
|
||||
Number(stat.uid) !== uid ||
|
||||
(Number(stat.mode) & 0o777) !== 0o700 ||
|
||||
(expectedDevice !== undefined && stat.dev !== expectedDevice)
|
||||
) {
|
||||
return invalid(`${label} is not an owner-only directory`);
|
||||
}
|
||||
return Object.freeze({
|
||||
path: value,
|
||||
uid,
|
||||
device: stat.dev,
|
||||
inode: stat.ino,
|
||||
});
|
||||
}
|
||||
|
||||
async function verifyDirectory(
|
||||
authority: Readonly<DirectoryAuthority>,
|
||||
): Promise<void> {
|
||||
const stat = await fs.lstat(authority.path, { bigint: true });
|
||||
if (
|
||||
!stat.isDirectory() ||
|
||||
stat.isSymbolicLink() ||
|
||||
Number(stat.uid) !== authority.uid ||
|
||||
(Number(stat.mode) & 0o777) !== 0o700 ||
|
||||
stat.dev !== authority.device ||
|
||||
stat.ino !== authority.inode
|
||||
) {
|
||||
invalid('staging directory authority changed');
|
||||
}
|
||||
}
|
||||
|
||||
async function privateFile(
|
||||
authority: Readonly<DirectoryAuthority>,
|
||||
filePath: string,
|
||||
maximumBytes: number,
|
||||
expectedBytes?: number,
|
||||
): Promise<Buffer> {
|
||||
await verifyDirectory(authority);
|
||||
let handle: FileHandle | undefined;
|
||||
try {
|
||||
handle = await fs.open(
|
||||
filePath,
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
const before = await handle.stat({ bigint: true });
|
||||
const bytes = Number(before.size);
|
||||
if (
|
||||
!before.isFile() ||
|
||||
Number(before.uid) !== authority.uid ||
|
||||
(Number(before.mode) & 0o777) !== 0o600 ||
|
||||
before.dev !== authority.device ||
|
||||
bytes < 1 ||
|
||||
bytes > maximumBytes ||
|
||||
(expectedBytes !== undefined && bytes !== expectedBytes)
|
||||
) {
|
||||
return invalid('staged file is not private, bounded and exact');
|
||||
}
|
||||
const material = await handle.readFile();
|
||||
const after = await handle.stat({ bigint: true });
|
||||
if (
|
||||
after.dev !== before.dev ||
|
||||
after.ino !== before.ino ||
|
||||
after.size !== before.size ||
|
||||
after.mtimeNs !== before.mtimeNs ||
|
||||
material.byteLength !== bytes
|
||||
) {
|
||||
material.fill(0);
|
||||
return invalid('staged file changed while it was read');
|
||||
}
|
||||
await verifyDirectory(authority);
|
||||
return material;
|
||||
} finally {
|
||||
await handle?.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function strictJson(value: Buffer): unknown {
|
||||
let text: string;
|
||||
try {
|
||||
text = new TextDecoder('utf-8', { fatal: true }).decode(value);
|
||||
} catch {
|
||||
return invalid('stage receipt is not strict UTF-8');
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return invalid('stage receipt is not JSON');
|
||||
}
|
||||
}
|
||||
|
||||
function receiptEntries(
|
||||
value: unknown,
|
||||
generation: Readonly<PluginPackageResourceGeneration>,
|
||||
): readonly Readonly<ReceiptEntry>[] {
|
||||
const receipt = dataRecord(value, 'stage receipt');
|
||||
exactKeys(
|
||||
receipt,
|
||||
['schema', 'lockDigest', 'inspection', 'entries'],
|
||||
'stage receipt',
|
||||
);
|
||||
const inspection = dataRecord(receipt.inspection, 'stage inspection');
|
||||
exactKeys(
|
||||
inspection,
|
||||
[
|
||||
'mediaType',
|
||||
'lockDigest',
|
||||
'packageName',
|
||||
'packageVersion',
|
||||
'artifactBytes',
|
||||
'artifactDigest',
|
||||
'manifestDigest',
|
||||
'contentBytes',
|
||||
'contentDigest',
|
||||
'entries',
|
||||
'signature',
|
||||
],
|
||||
'stage inspection',
|
||||
);
|
||||
const signature = dataRecord(inspection.signature, 'signature evidence');
|
||||
exactKeys(
|
||||
signature,
|
||||
[
|
||||
'publisher',
|
||||
'keyId',
|
||||
'signatureDigest',
|
||||
'keyNotBeforeMs',
|
||||
'keyNotAfterMs',
|
||||
'verifiedAtMs',
|
||||
],
|
||||
'signature evidence',
|
||||
);
|
||||
if (
|
||||
receipt.schema !== STAGE_RECEIPT_SCHEMA ||
|
||||
receipt.lockDigest !== generation.lockDigest ||
|
||||
inspection.lockDigest !== generation.lockDigest ||
|
||||
inspection.packageName !== generation.packageName ||
|
||||
inspection.contentDigest !== generation.contentDigest ||
|
||||
!Array.isArray(inspection.entries) ||
|
||||
!Array.isArray(receipt.entries) ||
|
||||
inspection.entries.length !== receipt.entries.length ||
|
||||
receipt.entries.length < 1 ||
|
||||
receipt.entries.length > MAX_STAGE_ENTRIES
|
||||
) {
|
||||
return invalid('stage receipt does not match active generation');
|
||||
}
|
||||
const expectedPaths = [
|
||||
'package.json',
|
||||
...generation.resources.map((resource) => resource.path).sort(),
|
||||
];
|
||||
if (expectedPaths.length !== receipt.entries.length) {
|
||||
return invalid('stage receipt entry set is incomplete');
|
||||
}
|
||||
const result: ReceiptEntry[] = [];
|
||||
let contentBytes = 0;
|
||||
for (const [index, expectedPath] of expectedPaths.entries()) {
|
||||
const inspected = dataRecord(
|
||||
inspection.entries[index],
|
||||
'inspected entry',
|
||||
);
|
||||
exactKeys(inspected, ['path', 'bytes', 'digest'], 'inspected entry');
|
||||
const staged = dataRecord(receipt.entries[index], 'staged entry');
|
||||
exactKeys(staged, ['path', 'bytes', 'digest', 'blob'], 'staged entry');
|
||||
const expectedBlob = `${index.toString().padStart(4, '0')}-${createHash(
|
||||
'sha256',
|
||||
)
|
||||
.update(expectedPath)
|
||||
.digest('hex')}.blob`;
|
||||
if (
|
||||
inspected.path !== expectedPath ||
|
||||
!Number.isSafeInteger(inspected.bytes) ||
|
||||
(inspected.bytes as number) < 1 ||
|
||||
(inspected.bytes as number) > 4 * 1024 * 1024 ||
|
||||
typeof inspected.digest !== 'string' ||
|
||||
!DIGEST.test(inspected.digest) ||
|
||||
staged.path !== inspected.path ||
|
||||
staged.bytes !== inspected.bytes ||
|
||||
staged.digest !== inspected.digest ||
|
||||
staged.blob !== expectedBlob ||
|
||||
!BLOB.test(expectedBlob)
|
||||
) {
|
||||
return invalid('stage receipt entry is invalid or inconsistent');
|
||||
}
|
||||
const entry = Object.freeze({
|
||||
path: expectedPath,
|
||||
bytes: inspected.bytes as number,
|
||||
digest: inspected.digest,
|
||||
blob: expectedBlob,
|
||||
});
|
||||
if (index > 0) contentBytes += entry.bytes;
|
||||
result.push(entry);
|
||||
}
|
||||
if (
|
||||
inspection.contentBytes !== contentBytes ||
|
||||
pluginPackageContentTreeDigest(
|
||||
result.slice(1).map(({ path, bytes, digest }) =>
|
||||
Object.freeze({ path, bytes, digest }),
|
||||
),
|
||||
) !== generation.contentDigest
|
||||
) {
|
||||
return invalid('stage receipt content tree is inconsistent');
|
||||
}
|
||||
return Object.freeze(result);
|
||||
}
|
||||
|
||||
class LocalResourceByteReader implements PluginPackageResourceByteReader {
|
||||
readonly #stage: Readonly<DirectoryAuthority>;
|
||||
readonly #blobs: Readonly<DirectoryAuthority>;
|
||||
readonly #entries: Map<string, Readonly<ReceiptEntry>>;
|
||||
readonly #readPaths = new Set<string>();
|
||||
#closed = false;
|
||||
|
||||
constructor(
|
||||
stage: Readonly<DirectoryAuthority>,
|
||||
blobs: Readonly<DirectoryAuthority>,
|
||||
entries: readonly Readonly<ReceiptEntry>[],
|
||||
) {
|
||||
this.#stage = stage;
|
||||
this.#blobs = blobs;
|
||||
this.#entries = new Map(entries.map((entry) => [entry.path, entry]));
|
||||
}
|
||||
|
||||
async read(pathValue: string, maximumBytesValue: number): Promise<Uint8Array> {
|
||||
if (this.#closed) return invalid('resource reader is closed');
|
||||
if (
|
||||
typeof pathValue !== 'string' ||
|
||||
!Number.isSafeInteger(maximumBytesValue) ||
|
||||
maximumBytesValue < 1 ||
|
||||
maximumBytesValue > 4 * 1024 * 1024 ||
|
||||
this.#readPaths.has(pathValue)
|
||||
) {
|
||||
return invalid('resource read request is invalid or duplicated');
|
||||
}
|
||||
const entry = this.#entries.get(pathValue);
|
||||
if (!entry || entry.bytes > maximumBytesValue) {
|
||||
return invalid('resource read is unknown or exceeds its requested bound');
|
||||
}
|
||||
this.#readPaths.add(pathValue);
|
||||
try {
|
||||
await verifyDirectory(this.#stage);
|
||||
const material = await privateFile(
|
||||
this.#blobs,
|
||||
path.join(this.#blobs.path, entry.blob),
|
||||
maximumBytesValue,
|
||||
entry.bytes,
|
||||
);
|
||||
if (
|
||||
createHash('sha256').update(material).digest('hex') !== entry.digest
|
||||
) {
|
||||
material.fill(0);
|
||||
return invalid('staged resource digest does not match its receipt');
|
||||
}
|
||||
return material;
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidLocalPluginPackageResourceSourceError) {
|
||||
throw error;
|
||||
}
|
||||
return unavailable(error);
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.#closed = true;
|
||||
this.#entries.clear();
|
||||
this.#readPaths.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalPluginPackageResourceByteSource
|
||||
implements PluginPackageResourceByteSource
|
||||
{
|
||||
readonly #stagingRoot: string;
|
||||
|
||||
constructor(value: LocalPluginPackageResourceByteSourceOptions) {
|
||||
const options = dataRecord(value, 'resource source options');
|
||||
exactKeys(options, ['stagingRoot'], 'resource source options');
|
||||
this.#stagingRoot = absoluteRoot(value.stagingRoot);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
async open(
|
||||
generationValue: Readonly<PluginPackageResourceGeneration>,
|
||||
): Promise<PluginPackageResourceByteReader> {
|
||||
let receiptMaterial: Buffer | undefined;
|
||||
try {
|
||||
const generation =
|
||||
normalizePluginPackageResourceGeneration(generationValue);
|
||||
const uid = currentUid();
|
||||
const root = await directoryAuthority(
|
||||
this.#stagingRoot,
|
||||
uid,
|
||||
'staging root',
|
||||
);
|
||||
if ((await fs.realpath(root.path)) !== root.path) {
|
||||
return invalid('staging root traverses a symbolic link');
|
||||
}
|
||||
const stage = await directoryAuthority(
|
||||
path.join(root.path, generation.lockDigest),
|
||||
uid,
|
||||
'stage directory',
|
||||
root.device,
|
||||
);
|
||||
const names = (await fs.readdir(stage.path)).sort();
|
||||
if (
|
||||
names.length !== 2 ||
|
||||
names[0] !== 'blobs' ||
|
||||
names[1] !== 'receipt.json'
|
||||
) {
|
||||
return invalid('stage directory contains unknown entries');
|
||||
}
|
||||
const blobs = await directoryAuthority(
|
||||
path.join(stage.path, 'blobs'),
|
||||
uid,
|
||||
'stage blob directory',
|
||||
stage.device,
|
||||
);
|
||||
receiptMaterial = await privateFile(
|
||||
stage,
|
||||
path.join(stage.path, 'receipt.json'),
|
||||
STAGE_RECEIPT_BYTES,
|
||||
);
|
||||
const entries = receiptEntries(strictJson(receiptMaterial), generation);
|
||||
const actualBlobs = (await fs.readdir(blobs.path)).sort();
|
||||
const expectedBlobs = entries.map((entry) => entry.blob).sort();
|
||||
if (
|
||||
actualBlobs.length !== expectedBlobs.length ||
|
||||
actualBlobs.some((name, index) => name !== expectedBlobs[index])
|
||||
) {
|
||||
return invalid('stage blob inventory is incomplete or contains extras');
|
||||
}
|
||||
await verifyDirectory(root);
|
||||
await verifyDirectory(stage);
|
||||
await verifyDirectory(blobs);
|
||||
return new LocalResourceByteReader(stage, blobs, entries);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidLocalPluginPackageResourceSourceError) {
|
||||
throw error;
|
||||
}
|
||||
return unavailable(error);
|
||||
} finally {
|
||||
receiptMaterial?.fill(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,908 @@
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { constants } from 'node:fs';
|
||||
import fs, { type FileHandle } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
type PluginPackageManifest,
|
||||
normalizePluginPackageManifest,
|
||||
} from '@qinglong/runtime-core/plugin-package';
|
||||
import {
|
||||
type PluginPackageBundleEntry,
|
||||
type PluginPackageBundleInspection,
|
||||
type PluginPackageBundleSink,
|
||||
type PluginPackagePublisherSignatureEvidence,
|
||||
type PluginPackagePublisherTrustRegistry,
|
||||
type PluginPackageSignature,
|
||||
PLUGIN_PACKAGE_BUNDLE_MEDIA_TYPE,
|
||||
inspectPluginPackageBundle,
|
||||
pluginPackageContentTreeDigest,
|
||||
verifyPluginPackagePublisherSignature,
|
||||
} from '@qinglong/runtime-core/plugin-package-bundle';
|
||||
import {
|
||||
type PluginPackageLock,
|
||||
normalizePluginPackageLock,
|
||||
} from '@qinglong/runtime-core/plugin-package-install';
|
||||
|
||||
const STAGING_RECEIPT_SCHEMA = 'qinglong/plugin-package-stage-receipt@v1';
|
||||
const STAGING_REFERENCE_PREFIX = 'local-stage:';
|
||||
const MAX_STAGING_ROOT_ENTRIES = 64;
|
||||
const MAX_STAGING_RECEIPT_BYTES = 64 * 1024;
|
||||
const LOCK_DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const TEMPORARY_DIRECTORY_PATTERN = /^\.qlpkg-[0-9a-f]{32}$/;
|
||||
const BLOB_NAME_PATTERN = /^[0-9]{4}-[0-9a-f]{64}\.blob$/;
|
||||
|
||||
export interface StagePluginPackageFromFileOptions {
|
||||
readonly bundlePath: string;
|
||||
readonly stagingRoot: string;
|
||||
readonly lock: PluginPackageLock;
|
||||
readonly manifest: PluginPackageManifest;
|
||||
readonly signature: PluginPackageSignature;
|
||||
readonly trust: PluginPackagePublisherTrustRegistry;
|
||||
readonly observedAtMs: number;
|
||||
}
|
||||
|
||||
export interface StagedPluginPackage {
|
||||
readonly status: 'staged' | 'existing';
|
||||
readonly stageRef: string;
|
||||
readonly directory: string;
|
||||
readonly receiptDigest: string;
|
||||
readonly inspection: Readonly<PluginPackageBundleInspection>;
|
||||
}
|
||||
|
||||
interface StagingReceiptEntry extends PluginPackageBundleEntry {
|
||||
readonly blob: string;
|
||||
}
|
||||
|
||||
interface StagingReceipt {
|
||||
readonly schema: typeof STAGING_RECEIPT_SCHEMA;
|
||||
readonly lockDigest: string;
|
||||
readonly inspection: Readonly<PluginPackageBundleInspection>;
|
||||
readonly entries: readonly Readonly<StagingReceiptEntry>[];
|
||||
}
|
||||
|
||||
export class InvalidPluginPackageStagingError extends Error {
|
||||
readonly code = 'PLUGIN_PACKAGE_STAGING_INVALID';
|
||||
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(`Plugin Package staging is invalid: ${message}`, options);
|
||||
this.name = 'InvalidPluginPackageStagingError';
|
||||
}
|
||||
}
|
||||
|
||||
export class PluginPackageStagingUnavailableError extends Error {
|
||||
readonly code = 'PLUGIN_PACKAGE_STAGING_UNAVAILABLE';
|
||||
|
||||
constructor(options?: ErrorOptions) {
|
||||
super('Plugin Package staging is unavailable', options);
|
||||
this.name = 'PluginPackageStagingUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
function isCode(error: unknown, ...codes: string[]): boolean {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
codes.includes((error as { code?: string }).code ?? '')
|
||||
);
|
||||
}
|
||||
|
||||
function dataRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
(Object.getPrototypeOf(value) !== Object.prototype &&
|
||||
Object.getPrototypeOf(value) !== null)
|
||||
) {
|
||||
throw new InvalidPluginPackageStagingError(`${label} must be an object`);
|
||||
}
|
||||
const descriptors = Object.getOwnPropertyDescriptors(value);
|
||||
if (
|
||||
Object.values(descriptors).some(
|
||||
(descriptor) =>
|
||||
descriptor.get !== undefined ||
|
||||
descriptor.set !== undefined ||
|
||||
descriptor.enumerable !== true,
|
||||
)
|
||||
) {
|
||||
throw new InvalidPluginPackageStagingError(
|
||||
`${label} must contain enumerable data properties`,
|
||||
);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactKeys(
|
||||
value: object,
|
||||
expected: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
if (
|
||||
actual.length !== canonical.length ||
|
||||
actual.some((key, index) => key !== canonical[index])
|
||||
) {
|
||||
throw new InvalidPluginPackageStagingError(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function currentUid(): number {
|
||||
if (typeof process.getuid !== 'function') {
|
||||
throw new InvalidPluginPackageStagingError(
|
||||
'local staging requires a POSIX process identity',
|
||||
);
|
||||
}
|
||||
return process.getuid();
|
||||
}
|
||||
|
||||
function boundedAbsolute(value: unknown, label: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!path.isAbsolute(value) ||
|
||||
path.parse(value).root === value ||
|
||||
value.includes('\0') ||
|
||||
Buffer.byteLength(value, 'utf8') > 4096
|
||||
) {
|
||||
throw new InvalidPluginPackageStagingError(
|
||||
`${label} must be a bounded non-root absolute path`,
|
||||
);
|
||||
}
|
||||
return path.normalize(value);
|
||||
}
|
||||
|
||||
async function privateDirectory(value: string, uid: number): Promise<void> {
|
||||
let stat;
|
||||
try {
|
||||
stat = await fs.lstat(value);
|
||||
} catch (error) {
|
||||
throw new InvalidPluginPackageStagingError(
|
||||
'staging root must already exist',
|
||||
{ cause: error instanceof Error ? error : undefined },
|
||||
);
|
||||
}
|
||||
if (
|
||||
!stat.isDirectory() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.uid !== uid ||
|
||||
(stat.mode & 0o777) !== 0o700
|
||||
) {
|
||||
throw new InvalidPluginPackageStagingError(
|
||||
'staging root must be one owner-only directory',
|
||||
);
|
||||
}
|
||||
if ((await fs.realpath(value)) !== value) {
|
||||
throw new InvalidPluginPackageStagingError(
|
||||
'staging root must not traverse symbolic links',
|
||||
);
|
||||
}
|
||||
const entries = await fs.readdir(value);
|
||||
if (
|
||||
entries.length > MAX_STAGING_ROOT_ENTRIES ||
|
||||
entries.some(
|
||||
(entry) =>
|
||||
!LOCK_DIGEST_PATTERN.test(entry) &&
|
||||
!TEMPORARY_DIRECTORY_PATTERN.test(entry),
|
||||
)
|
||||
) {
|
||||
throw new InvalidPluginPackageStagingError(
|
||||
'staging root contains unbounded or unknown entries',
|
||||
);
|
||||
}
|
||||
if (entries.some((entry) => TEMPORARY_DIRECTORY_PATTERN.test(entry))) {
|
||||
throw new InvalidPluginPackageStagingError(
|
||||
'staging root contains an unresolved temporary transaction',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function openPrivateBundle(
|
||||
bundlePath: string,
|
||||
uid: number,
|
||||
expectedBytes: number,
|
||||
): Promise<FileHandle> {
|
||||
let before;
|
||||
try {
|
||||
before = await fs.lstat(bundlePath);
|
||||
} catch (error) {
|
||||
throw new InvalidPluginPackageStagingError('bundle file is unavailable', {
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
if (
|
||||
!before.isFile() ||
|
||||
before.isSymbolicLink() ||
|
||||
before.uid !== uid ||
|
||||
(before.mode & 0o077) !== 0 ||
|
||||
(before.mode & 0o111) !== 0 ||
|
||||
before.size !== expectedBytes
|
||||
) {
|
||||
throw new InvalidPluginPackageStagingError(
|
||||
'bundle must be an exact owner-only regular file',
|
||||
);
|
||||
}
|
||||
let handle: FileHandle;
|
||||
try {
|
||||
handle = await fs.open(
|
||||
bundlePath,
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
} catch (error) {
|
||||
throw new InvalidPluginPackageStagingError('bundle file cannot be opened', {
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
const opened = await handle.stat();
|
||||
if (
|
||||
!opened.isFile() ||
|
||||
opened.dev !== before.dev ||
|
||||
opened.ino !== before.ino ||
|
||||
opened.uid !== uid ||
|
||||
(opened.mode & 0o077) !== 0 ||
|
||||
(opened.mode & 0o111) !== 0 ||
|
||||
opened.size !== expectedBytes
|
||||
) {
|
||||
await handle.close();
|
||||
throw new InvalidPluginPackageStagingError(
|
||||
'bundle identity changed while opening',
|
||||
);
|
||||
}
|
||||
return handle;
|
||||
}
|
||||
|
||||
async function* fileChunks(
|
||||
handle: FileHandle,
|
||||
expectedBytes: number,
|
||||
): AsyncGenerator<Uint8Array> {
|
||||
let offset = 0;
|
||||
while (offset < expectedBytes) {
|
||||
const buffer = Buffer.allocUnsafe(
|
||||
Math.min(64 * 1024, expectedBytes - offset),
|
||||
);
|
||||
const { bytesRead } = await handle.read(
|
||||
buffer,
|
||||
0,
|
||||
buffer.byteLength,
|
||||
offset,
|
||||
);
|
||||
if (bytesRead === 0) {
|
||||
throw new InvalidPluginPackageStagingError(
|
||||
'bundle ended while it was being staged',
|
||||
);
|
||||
}
|
||||
offset += bytesRead;
|
||||
yield buffer.subarray(0, bytesRead);
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalReceipt(receipt: Readonly<StagingReceipt>): string {
|
||||
return `${JSON.stringify(receipt)}\n`;
|
||||
}
|
||||
|
||||
function receiptDigest(receipt: Readonly<StagingReceipt>): string {
|
||||
return createHash('sha256').update(canonicalReceipt(receipt)).digest('hex');
|
||||
}
|
||||
|
||||
async function syncDirectory(directory: string): Promise<void> {
|
||||
const handle = await fs.open(directory, constants.O_RDONLY);
|
||||
try {
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
class OpaqueBlobStagingSink implements PluginPackageBundleSink {
|
||||
readonly #temporaryDirectory: string;
|
||||
readonly #blobDirectory: string;
|
||||
readonly #entries: StagingReceiptEntry[] = [];
|
||||
#currentHandle: FileHandle | undefined;
|
||||
#currentBlob: string | undefined;
|
||||
#currentBytes = 0;
|
||||
#receipt: Readonly<StagingReceipt> | undefined;
|
||||
|
||||
constructor(temporaryDirectory: string) {
|
||||
this.#temporaryDirectory = temporaryDirectory;
|
||||
this.#blobDirectory = path.join(temporaryDirectory, 'blobs');
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
await fs.mkdir(this.#temporaryDirectory, { mode: 0o700 });
|
||||
await fs.mkdir(this.#blobDirectory, { mode: 0o700 });
|
||||
}
|
||||
|
||||
async begin(entry: Readonly<{ path: string; bytes: number }>): Promise<void> {
|
||||
if (this.#currentHandle || this.#entries.length > 9_999) {
|
||||
throw new InvalidPluginPackageStagingError(
|
||||
'staging sink entry sequence is invalid',
|
||||
);
|
||||
}
|
||||
const pathDigest = createHash('sha256').update(entry.path).digest('hex');
|
||||
this.#currentBlob = `${this.#entries.length
|
||||
.toString()
|
||||
.padStart(4, '0')}-${pathDigest}.blob`;
|
||||
this.#currentBytes = 0;
|
||||
this.#currentHandle = await fs.open(
|
||||
path.join(this.#blobDirectory, this.#currentBlob),
|
||||
'wx',
|
||||
0o600,
|
||||
);
|
||||
}
|
||||
|
||||
async write(chunk: Uint8Array): Promise<void> {
|
||||
if (!this.#currentHandle) {
|
||||
throw new InvalidPluginPackageStagingError(
|
||||
'staging sink has no active entry',
|
||||
);
|
||||
}
|
||||
await this.#currentHandle.writeFile(chunk);
|
||||
this.#currentBytes += chunk.byteLength;
|
||||
}
|
||||
|
||||
async end(entry: Readonly<PluginPackageBundleEntry>): Promise<void> {
|
||||
if (
|
||||
!this.#currentHandle ||
|
||||
!this.#currentBlob ||
|
||||
this.#currentBytes !== entry.bytes
|
||||
) {
|
||||
throw new InvalidPluginPackageStagingError(
|
||||
'staging sink entry boundary is invalid',
|
||||
);
|
||||
}
|
||||
await this.#currentHandle.sync();
|
||||
await this.#currentHandle.close();
|
||||
this.#currentHandle = undefined;
|
||||
this.#entries.push(Object.freeze({ ...entry, blob: this.#currentBlob }));
|
||||
this.#currentBlob = undefined;
|
||||
this.#currentBytes = 0;
|
||||
}
|
||||
|
||||
async commit(
|
||||
inspection: Readonly<PluginPackageBundleInspection>,
|
||||
): Promise<void> {
|
||||
if (
|
||||
this.#currentHandle ||
|
||||
this.#entries.length !== inspection.entries.length
|
||||
) {
|
||||
throw new InvalidPluginPackageStagingError(
|
||||
'staging sink cannot commit incomplete entries',
|
||||
);
|
||||
}
|
||||
const receipt = Object.freeze({
|
||||
schema: STAGING_RECEIPT_SCHEMA,
|
||||
lockDigest: inspection.lockDigest,
|
||||
inspection,
|
||||
entries: Object.freeze(this.#entries),
|
||||
});
|
||||
const serialized = canonicalReceipt(receipt);
|
||||
if (Buffer.byteLength(serialized) > MAX_STAGING_RECEIPT_BYTES) {
|
||||
throw new InvalidPluginPackageStagingError(
|
||||
'staging receipt exceeds its byte budget',
|
||||
);
|
||||
}
|
||||
const handle = await fs.open(
|
||||
path.join(this.#temporaryDirectory, 'receipt.json'),
|
||||
'wx',
|
||||
0o600,
|
||||
);
|
||||
try {
|
||||
await handle.writeFile(serialized, 'utf8');
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
await syncDirectory(this.#blobDirectory);
|
||||
await syncDirectory(this.#temporaryDirectory);
|
||||
this.#receipt = receipt;
|
||||
}
|
||||
|
||||
async abort(): Promise<void> {
|
||||
await this.#currentHandle?.close().catch(() => undefined);
|
||||
this.#currentHandle = undefined;
|
||||
}
|
||||
|
||||
receipt(): Readonly<StagingReceipt> {
|
||||
if (!this.#receipt) {
|
||||
throw new InvalidPluginPackageStagingError(
|
||||
'staging receipt is not committed',
|
||||
);
|
||||
}
|
||||
return this.#receipt;
|
||||
}
|
||||
|
||||
blobNames(): readonly string[] {
|
||||
const values = this.#entries.map((entry) => entry.blob);
|
||||
if (this.#currentBlob) values.push(this.#currentBlob);
|
||||
return values;
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupTemporary(
|
||||
temporaryDirectory: string,
|
||||
blobNames: readonly string[],
|
||||
): Promise<void> {
|
||||
const parent = path.dirname(temporaryDirectory);
|
||||
if (
|
||||
!TEMPORARY_DIRECTORY_PATTERN.test(path.basename(temporaryDirectory)) ||
|
||||
path.dirname(path.join(parent, path.basename(temporaryDirectory))) !==
|
||||
parent
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const blobDirectory = path.join(temporaryDirectory, 'blobs');
|
||||
await Promise.all(
|
||||
blobNames.map((blob) =>
|
||||
BLOB_NAME_PATTERN.test(blob)
|
||||
? fs.unlink(path.join(blobDirectory, blob)).catch(() => undefined)
|
||||
: Promise.resolve(),
|
||||
),
|
||||
);
|
||||
await fs
|
||||
.unlink(path.join(temporaryDirectory, 'receipt.json'))
|
||||
.catch(() => undefined);
|
||||
await fs.rmdir(blobDirectory).catch(() => undefined);
|
||||
await fs.rmdir(temporaryDirectory).catch(() => undefined);
|
||||
}
|
||||
|
||||
function parseReceipt(
|
||||
value: string,
|
||||
lock: Readonly<PluginPackageLock>,
|
||||
manifest: Readonly<PluginPackageManifest>,
|
||||
signature: Readonly<PluginPackagePublisherSignatureEvidence>,
|
||||
): Readonly<StagingReceipt> {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch (error) {
|
||||
throw new InvalidPluginPackageStagingError('staging receipt is not JSON', {
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
const receipt = dataRecord(parsed, 'staging receipt');
|
||||
exactKeys(
|
||||
receipt,
|
||||
['schema', 'lockDigest', 'inspection', 'entries'],
|
||||
'receipt',
|
||||
);
|
||||
if (
|
||||
receipt.schema !== STAGING_RECEIPT_SCHEMA ||
|
||||
receipt.lockDigest !== lock.lockDigest
|
||||
) {
|
||||
throw new InvalidPluginPackageStagingError(
|
||||
'staging receipt does not match its PackageLock',
|
||||
);
|
||||
}
|
||||
const inspection = dataRecord(receipt.inspection, 'receipt inspection');
|
||||
exactKeys(
|
||||
inspection,
|
||||
[
|
||||
'mediaType',
|
||||
'lockDigest',
|
||||
'packageName',
|
||||
'packageVersion',
|
||||
'artifactBytes',
|
||||
'artifactDigest',
|
||||
'manifestDigest',
|
||||
'contentBytes',
|
||||
'contentDigest',
|
||||
'entries',
|
||||
'signature',
|
||||
],
|
||||
'receipt inspection',
|
||||
);
|
||||
if (
|
||||
inspection.mediaType !== PLUGIN_PACKAGE_BUNDLE_MEDIA_TYPE ||
|
||||
inspection.lockDigest !== lock.lockDigest ||
|
||||
inspection.packageName !== lock.packageName ||
|
||||
inspection.packageVersion !== lock.packageVersion ||
|
||||
inspection.artifactBytes !== lock.source.artifactBytes ||
|
||||
inspection.artifactDigest !== lock.source.artifactDigest ||
|
||||
inspection.manifestDigest !== lock.manifestDigest ||
|
||||
inspection.contentDigest !== lock.source.contentDigest ||
|
||||
!Array.isArray(receipt.entries) ||
|
||||
!Array.isArray(inspection.entries) ||
|
||||
receipt.entries.length !== inspection.entries.length
|
||||
) {
|
||||
throw new InvalidPluginPackageStagingError(
|
||||
'staging receipt inspection is inconsistent',
|
||||
);
|
||||
}
|
||||
const receiptSignature = dataRecord(
|
||||
inspection.signature,
|
||||
'receipt signature evidence',
|
||||
);
|
||||
exactKeys(
|
||||
receiptSignature,
|
||||
[
|
||||
'publisher',
|
||||
'keyId',
|
||||
'signatureDigest',
|
||||
'keyNotBeforeMs',
|
||||
'keyNotAfterMs',
|
||||
'verifiedAtMs',
|
||||
],
|
||||
'receipt signature evidence',
|
||||
);
|
||||
if (JSON.stringify(receiptSignature) !== JSON.stringify(signature)) {
|
||||
throw new InvalidPluginPackageStagingError(
|
||||
'staging receipt signature evidence is inconsistent',
|
||||
);
|
||||
}
|
||||
const expectedPaths = [
|
||||
'package.json',
|
||||
...[
|
||||
...manifest.spec.contents.tasks,
|
||||
...manifest.spec.contents.workflows,
|
||||
...manifest.spec.contents.prompts,
|
||||
...manifest.spec.contents.tools,
|
||||
].sort(),
|
||||
];
|
||||
if (inspection.entries.length !== expectedPaths.length) {
|
||||
throw new InvalidPluginPackageStagingError(
|
||||
'staging receipt entry count is inconsistent',
|
||||
);
|
||||
}
|
||||
let contentBytes = 0;
|
||||
const normalizedInspectionEntries: PluginPackageBundleEntry[] = [];
|
||||
const normalizedReceiptEntries: StagingReceiptEntry[] = [];
|
||||
for (const [index, expectedPath] of expectedPaths.entries()) {
|
||||
const inspected = dataRecord(
|
||||
inspection.entries[index],
|
||||
'receipt inspected entry',
|
||||
);
|
||||
exactKeys(
|
||||
inspected,
|
||||
['path', 'bytes', 'digest'],
|
||||
'receipt inspected entry',
|
||||
);
|
||||
const staged = dataRecord(receipt.entries[index], 'receipt staged entry');
|
||||
exactKeys(
|
||||
staged,
|
||||
['path', 'bytes', 'digest', 'blob'],
|
||||
'receipt staged entry',
|
||||
);
|
||||
if (
|
||||
inspected.path !== expectedPath ||
|
||||
typeof inspected.bytes !== 'number' ||
|
||||
!Number.isSafeInteger(inspected.bytes) ||
|
||||
inspected.bytes < 0 ||
|
||||
typeof inspected.digest !== 'string' ||
|
||||
!LOCK_DIGEST_PATTERN.test(inspected.digest)
|
||||
) {
|
||||
throw new InvalidPluginPackageStagingError(
|
||||
'staging receipt inspected entry is invalid',
|
||||
);
|
||||
}
|
||||
const expectedBlob = `${index.toString().padStart(4, '0')}-${createHash(
|
||||
'sha256',
|
||||
)
|
||||
.update(expectedPath)
|
||||
.digest('hex')}.blob`;
|
||||
if (
|
||||
staged.path !== inspected.path ||
|
||||
staged.bytes !== inspected.bytes ||
|
||||
staged.digest !== inspected.digest ||
|
||||
staged.blob !== expectedBlob
|
||||
) {
|
||||
throw new InvalidPluginPackageStagingError(
|
||||
'staging receipt staged entry is inconsistent',
|
||||
);
|
||||
}
|
||||
if (index > 0) contentBytes += inspected.bytes;
|
||||
normalizedInspectionEntries.push(
|
||||
Object.freeze({
|
||||
path: expectedPath,
|
||||
bytes: inspected.bytes,
|
||||
digest: inspected.digest,
|
||||
}),
|
||||
);
|
||||
normalizedReceiptEntries.push(
|
||||
Object.freeze({
|
||||
path: expectedPath,
|
||||
bytes: inspected.bytes,
|
||||
digest: inspected.digest,
|
||||
blob: expectedBlob,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (
|
||||
inspection.contentBytes !== contentBytes ||
|
||||
pluginPackageContentTreeDigest(normalizedInspectionEntries.slice(1)) !==
|
||||
lock.source.contentDigest
|
||||
) {
|
||||
throw new InvalidPluginPackageStagingError(
|
||||
'staging receipt content evidence is inconsistent',
|
||||
);
|
||||
}
|
||||
const normalizedInspection = Object.freeze({
|
||||
mediaType: PLUGIN_PACKAGE_BUNDLE_MEDIA_TYPE,
|
||||
lockDigest: lock.lockDigest,
|
||||
packageName: lock.packageName,
|
||||
packageVersion: lock.packageVersion,
|
||||
artifactBytes: lock.source.artifactBytes,
|
||||
artifactDigest: lock.source.artifactDigest,
|
||||
manifestDigest: lock.manifestDigest,
|
||||
contentBytes,
|
||||
contentDigest: lock.source.contentDigest,
|
||||
entries: Object.freeze(normalizedInspectionEntries),
|
||||
signature,
|
||||
});
|
||||
return Object.freeze({
|
||||
schema: STAGING_RECEIPT_SCHEMA,
|
||||
lockDigest: lock.lockDigest,
|
||||
inspection: normalizedInspection,
|
||||
entries: Object.freeze(normalizedReceiptEntries),
|
||||
});
|
||||
}
|
||||
|
||||
async function readExistingStage(
|
||||
directory: string,
|
||||
lock: Readonly<PluginPackageLock>,
|
||||
manifest: Readonly<PluginPackageManifest>,
|
||||
signature: Readonly<PluginPackagePublisherSignatureEvidence>,
|
||||
uid: number,
|
||||
): Promise<Readonly<StagingReceipt> | undefined> {
|
||||
let directoryStat;
|
||||
try {
|
||||
directoryStat = await fs.lstat(directory);
|
||||
} catch (error) {
|
||||
if (isCode(error, 'ENOENT')) return undefined;
|
||||
throw error;
|
||||
}
|
||||
if (
|
||||
!directoryStat.isDirectory() ||
|
||||
directoryStat.isSymbolicLink() ||
|
||||
directoryStat.uid !== uid ||
|
||||
(directoryStat.mode & 0o777) !== 0o700
|
||||
) {
|
||||
throw new InvalidPluginPackageStagingError(
|
||||
'existing stage directory is not private',
|
||||
);
|
||||
}
|
||||
const receiptPath = path.join(directory, 'receipt.json');
|
||||
const receiptHandle = await fs.open(
|
||||
receiptPath,
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
let serialized: Buffer;
|
||||
try {
|
||||
const stat = await receiptHandle.stat();
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.uid !== uid ||
|
||||
(stat.mode & 0o777) !== 0o600 ||
|
||||
stat.size < 1 ||
|
||||
stat.size > MAX_STAGING_RECEIPT_BYTES
|
||||
) {
|
||||
throw new InvalidPluginPackageStagingError(
|
||||
'existing staging receipt is not private and bounded',
|
||||
);
|
||||
}
|
||||
serialized = Buffer.allocUnsafe(stat.size);
|
||||
const { bytesRead } = await receiptHandle.read(
|
||||
serialized,
|
||||
0,
|
||||
serialized.byteLength,
|
||||
0,
|
||||
);
|
||||
if (bytesRead !== serialized.byteLength) {
|
||||
throw new InvalidPluginPackageStagingError(
|
||||
'existing staging receipt changed while reading',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await receiptHandle.close();
|
||||
}
|
||||
const receipt = parseReceipt(
|
||||
serialized.toString('utf8'),
|
||||
lock,
|
||||
manifest,
|
||||
signature,
|
||||
);
|
||||
if (canonicalReceipt(receipt) !== serialized.toString('utf8')) {
|
||||
throw new InvalidPluginPackageStagingError(
|
||||
'existing staging receipt is not canonical',
|
||||
);
|
||||
}
|
||||
const directoryEntries = (await fs.readdir(directory)).sort();
|
||||
if (
|
||||
directoryEntries.length !== 2 ||
|
||||
directoryEntries[0] !== 'blobs' ||
|
||||
directoryEntries[1] !== 'receipt.json'
|
||||
) {
|
||||
throw new InvalidPluginPackageStagingError(
|
||||
'existing stage contains unknown entries',
|
||||
);
|
||||
}
|
||||
const blobDirectory = path.join(directory, 'blobs');
|
||||
const blobStat = await fs.lstat(blobDirectory);
|
||||
if (
|
||||
!blobStat.isDirectory() ||
|
||||
blobStat.isSymbolicLink() ||
|
||||
blobStat.uid !== uid ||
|
||||
(blobStat.mode & 0o777) !== 0o700
|
||||
) {
|
||||
throw new InvalidPluginPackageStagingError(
|
||||
'existing stage blob directory is not private',
|
||||
);
|
||||
}
|
||||
const blobNames = (await fs.readdir(blobDirectory)).sort();
|
||||
const expectedBlobNames = receipt.entries.map((entry) => entry.blob).sort();
|
||||
if (
|
||||
blobNames.length !== expectedBlobNames.length ||
|
||||
blobNames.some((name, index) => name !== expectedBlobNames[index])
|
||||
) {
|
||||
throw new InvalidPluginPackageStagingError(
|
||||
'existing stage blob set is inconsistent',
|
||||
);
|
||||
}
|
||||
for (const [index, entry] of receipt.entries.entries()) {
|
||||
const inspected = receipt.inspection.entries[index];
|
||||
if (
|
||||
!inspected ||
|
||||
!BLOB_NAME_PATTERN.test(entry.blob) ||
|
||||
entry.path !== inspected.path ||
|
||||
entry.bytes !== inspected.bytes ||
|
||||
entry.digest !== inspected.digest
|
||||
) {
|
||||
throw new InvalidPluginPackageStagingError(
|
||||
'existing stage entry metadata is inconsistent',
|
||||
);
|
||||
}
|
||||
const handle = await fs.open(
|
||||
path.join(blobDirectory, entry.blob),
|
||||
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
try {
|
||||
const stat = await handle.stat();
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.uid !== uid ||
|
||||
(stat.mode & 0o777) !== 0o600 ||
|
||||
stat.size !== entry.bytes
|
||||
) {
|
||||
throw new InvalidPluginPackageStagingError(
|
||||
'existing stage blob is not private and exact',
|
||||
);
|
||||
}
|
||||
const hash = createHash('sha256');
|
||||
let offset = 0;
|
||||
while (offset < entry.bytes) {
|
||||
const buffer = Buffer.allocUnsafe(
|
||||
Math.min(64 * 1024, entry.bytes - offset),
|
||||
);
|
||||
const { bytesRead } = await handle.read(
|
||||
buffer,
|
||||
0,
|
||||
buffer.byteLength,
|
||||
offset,
|
||||
);
|
||||
if (bytesRead === 0) break;
|
||||
hash.update(buffer.subarray(0, bytesRead));
|
||||
offset += bytesRead;
|
||||
}
|
||||
if (offset !== entry.bytes || hash.digest('hex') !== entry.digest) {
|
||||
throw new InvalidPluginPackageStagingError(
|
||||
'existing stage blob digest does not match',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
return receipt;
|
||||
}
|
||||
|
||||
export async function stagePluginPackageFromFile(
|
||||
value: StagePluginPackageFromFileOptions,
|
||||
): Promise<Readonly<StagedPluginPackage>> {
|
||||
const options = dataRecord(value, 'staging options');
|
||||
exactKeys(
|
||||
options,
|
||||
[
|
||||
'bundlePath',
|
||||
'stagingRoot',
|
||||
'lock',
|
||||
'manifest',
|
||||
'signature',
|
||||
'trust',
|
||||
'observedAtMs',
|
||||
],
|
||||
'staging options',
|
||||
);
|
||||
const lock = normalizePluginPackageLock(value.lock);
|
||||
const manifest = normalizePluginPackageManifest(value.manifest);
|
||||
const bundlePath = boundedAbsolute(value.bundlePath, 'bundlePath');
|
||||
const stagingRoot = boundedAbsolute(value.stagingRoot, 'stagingRoot');
|
||||
const signature = verifyPluginPackagePublisherSignature(
|
||||
lock,
|
||||
value.signature,
|
||||
value.trust,
|
||||
value.observedAtMs,
|
||||
);
|
||||
const uid = currentUid();
|
||||
await privateDirectory(stagingRoot, uid);
|
||||
const finalDirectory = path.join(stagingRoot, lock.lockDigest);
|
||||
const existing = await readExistingStage(
|
||||
finalDirectory,
|
||||
lock,
|
||||
manifest,
|
||||
signature,
|
||||
uid,
|
||||
);
|
||||
if (existing) {
|
||||
return Object.freeze({
|
||||
status: 'existing',
|
||||
stageRef: `${STAGING_REFERENCE_PREFIX}${lock.lockDigest}`,
|
||||
directory: finalDirectory,
|
||||
receiptDigest: receiptDigest(existing),
|
||||
inspection: existing.inspection,
|
||||
});
|
||||
}
|
||||
|
||||
const temporaryDirectory = path.join(
|
||||
stagingRoot,
|
||||
`.qlpkg-${randomBytes(16).toString('hex')}`,
|
||||
);
|
||||
const sink = new OpaqueBlobStagingSink(temporaryDirectory);
|
||||
let handle: FileHandle | undefined;
|
||||
try {
|
||||
await sink.initialize();
|
||||
handle = await openPrivateBundle(
|
||||
bundlePath,
|
||||
uid,
|
||||
lock.source.artifactBytes,
|
||||
);
|
||||
const inspection = await inspectPluginPackageBundle({
|
||||
lock,
|
||||
manifest,
|
||||
signature: value.signature,
|
||||
trust: value.trust,
|
||||
observedAtMs: value.observedAtMs,
|
||||
chunks: fileChunks(handle, lock.source.artifactBytes),
|
||||
sink,
|
||||
});
|
||||
await handle.close();
|
||||
handle = undefined;
|
||||
const receipt = sink.receipt();
|
||||
try {
|
||||
await fs.rename(temporaryDirectory, finalDirectory);
|
||||
} catch (error) {
|
||||
if (!isCode(error, 'EEXIST', 'ENOTEMPTY')) throw error;
|
||||
await cleanupTemporary(temporaryDirectory, sink.blobNames());
|
||||
const raced = await readExistingStage(
|
||||
finalDirectory,
|
||||
lock,
|
||||
manifest,
|
||||
signature,
|
||||
uid,
|
||||
);
|
||||
if (!raced) throw error;
|
||||
return Object.freeze({
|
||||
status: 'existing',
|
||||
stageRef: `${STAGING_REFERENCE_PREFIX}${lock.lockDigest}`,
|
||||
directory: finalDirectory,
|
||||
receiptDigest: receiptDigest(raced),
|
||||
inspection: raced.inspection,
|
||||
});
|
||||
}
|
||||
await syncDirectory(stagingRoot);
|
||||
return Object.freeze({
|
||||
status: 'staged',
|
||||
stageRef: `${STAGING_REFERENCE_PREFIX}${lock.lockDigest}`,
|
||||
directory: finalDirectory,
|
||||
receiptDigest: receiptDigest(receipt),
|
||||
inspection,
|
||||
});
|
||||
} catch (error) {
|
||||
await handle?.close().catch(() => undefined);
|
||||
await sink.abort().catch(() => undefined);
|
||||
await cleanupTemporary(temporaryDirectory, sink.blobNames());
|
||||
if (error instanceof InvalidPluginPackageStagingError) throw error;
|
||||
throw new PluginPackageStagingUnavailableError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,828 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import {
|
||||
PluginPackagePublisherTrustRegistry,
|
||||
type PluginPackagePublisherKeyDefinition,
|
||||
} from '@qinglong/runtime-core/plugin-package-bundle';
|
||||
|
||||
import {
|
||||
LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_RETIREMENT_INTENT_SCHEMA,
|
||||
LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_RETIREMENT_RECEIPT_SCHEMA,
|
||||
LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_REVOCATION_PROPOSAL_SCHEMA,
|
||||
LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_REVOCATION_RECEIPT_SCHEMA,
|
||||
LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SCHEMA,
|
||||
LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SNAPSHOT_SCHEMA,
|
||||
LocalPluginPackagePublisherTrustConfigurationError,
|
||||
type LocalPluginPackagePublisherKeyRevocationReceipt,
|
||||
type LocalPluginPackagePublisherTrustDocument,
|
||||
} from './contracts';
|
||||
|
||||
export const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
export const MUTATION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
|
||||
export interface TrustSnapshot {
|
||||
readonly schema: typeof LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SNAPSHOT_SCHEMA;
|
||||
readonly generation: number;
|
||||
readonly previousSnapshotDigest: string | null;
|
||||
readonly previousTrustDigest: string | null;
|
||||
readonly trustDigest: string;
|
||||
readonly mutationId: string;
|
||||
readonly occurredAtMs: number;
|
||||
readonly mode: 'provision' | 'rotate' | 'retire' | 'revoke';
|
||||
readonly trust: Readonly<LocalPluginPackagePublisherTrustDocument>;
|
||||
readonly snapshotDigest: string;
|
||||
}
|
||||
|
||||
export interface RetirementIntent {
|
||||
readonly schema: typeof LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_RETIREMENT_INTENT_SCHEMA;
|
||||
readonly publisher: string;
|
||||
readonly keyId: string;
|
||||
readonly expectedGeneration: number;
|
||||
readonly previousTrustDigest: string;
|
||||
readonly mutationId: string;
|
||||
readonly occurredAtMs: number;
|
||||
readonly intentDigest: string;
|
||||
}
|
||||
|
||||
export interface RetirementReceipt {
|
||||
readonly schema: typeof LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_RETIREMENT_RECEIPT_SCHEMA;
|
||||
readonly publisher: string;
|
||||
readonly keyId: string;
|
||||
readonly expectedGeneration: number;
|
||||
readonly mutationId: string;
|
||||
readonly intentDigest: string;
|
||||
readonly catalogEntryCount: number;
|
||||
readonly bundleCount: number;
|
||||
readonly matchingEntryCount: 0;
|
||||
readonly unresolvedTransactions: 0;
|
||||
readonly occurredAtMs: number;
|
||||
readonly receiptDigest: string;
|
||||
}
|
||||
|
||||
export interface RevocationProposal {
|
||||
readonly schema: typeof LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_REVOCATION_PROPOSAL_SCHEMA;
|
||||
readonly publisher: string;
|
||||
readonly keyId: string;
|
||||
readonly expectedGeneration: number;
|
||||
readonly previousTrustDigest: string;
|
||||
readonly mutationId: string;
|
||||
readonly occurredAtMs: number;
|
||||
readonly proposerSubjectId: string;
|
||||
readonly catalogEntryCount: number;
|
||||
readonly bundleCount: number;
|
||||
readonly matchingEntryCount: number;
|
||||
readonly unresolvedTransactions: number;
|
||||
readonly impactedLockDigests: readonly string[];
|
||||
readonly impactDigest: string;
|
||||
readonly proposalDigest: string;
|
||||
}
|
||||
|
||||
export interface RevocationReceipt
|
||||
extends LocalPluginPackagePublisherKeyRevocationReceipt {
|
||||
readonly schema: typeof LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_REVOCATION_RECEIPT_SCHEMA;
|
||||
readonly publisher: string;
|
||||
readonly keyId: string;
|
||||
readonly expectedGeneration: number;
|
||||
readonly mutationId: string;
|
||||
readonly proposalDigest: string;
|
||||
readonly proposerSubjectId: string;
|
||||
readonly confirmerSubjectId: string;
|
||||
readonly authorizationMode: 'dual_control' | 'break_glass';
|
||||
readonly reasonCode: 'suspected_key_compromise' | 'confirmed_key_compromise';
|
||||
readonly confirmedAtMs: number;
|
||||
readonly impactDigest: string;
|
||||
readonly impactedLockDigests: readonly string[];
|
||||
readonly receiptDigest: string;
|
||||
}
|
||||
|
||||
export function activeKeyCount(
|
||||
trust: Readonly<LocalPluginPackagePublisherTrustDocument> | undefined,
|
||||
observedAtMs: number,
|
||||
): number {
|
||||
return (
|
||||
trust?.keys.filter(
|
||||
(key) => key.notBeforeMs <= observedAtMs && observedAtMs < key.notAfterMs,
|
||||
).length ?? 0
|
||||
);
|
||||
}
|
||||
|
||||
export function keyMap(
|
||||
trust: Readonly<LocalPluginPackagePublisherTrustDocument>,
|
||||
): ReadonlyMap<string, Readonly<PluginPackagePublisherKeyDefinition>> {
|
||||
return new Map(
|
||||
trust.keys.map((key) => [`${key.publisher}\0${key.keyId}`, key]),
|
||||
);
|
||||
}
|
||||
|
||||
export function sameSnapshot(
|
||||
left: Readonly<TrustSnapshot>,
|
||||
right: Readonly<TrustSnapshot>,
|
||||
): boolean {
|
||||
return left.snapshotDigest === right.snapshotDigest;
|
||||
}
|
||||
|
||||
export function dataRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== 'object' ||
|
||||
Array.isArray(value) ||
|
||||
(Object.getPrototypeOf(value) !== Object.prototype &&
|
||||
Object.getPrototypeOf(value) !== null)
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
`${label} must be an object`,
|
||||
);
|
||||
}
|
||||
const descriptors = Object.getOwnPropertyDescriptors(value);
|
||||
if (
|
||||
Object.values(descriptors).some(
|
||||
(descriptor) =>
|
||||
descriptor.get !== undefined ||
|
||||
descriptor.set !== undefined ||
|
||||
descriptor.enumerable !== true,
|
||||
)
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
`${label} must contain enumerable data properties`,
|
||||
);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function exactKeys(
|
||||
value: Record<string, unknown>,
|
||||
expected: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const canonical = [...expected].sort();
|
||||
if (
|
||||
actual.length !== canonical.length ||
|
||||
actual.some((key, index) => key !== canonical[index])
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
`${label} shape is invalid`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function integer(value: unknown, minimum: number, label: string): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < minimum) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
`${label} is invalid`,
|
||||
);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
export function digest(value: string): string {
|
||||
return createHash('sha256').update(value, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
export function canonicalKey(
|
||||
value: unknown,
|
||||
): Readonly<PluginPackagePublisherKeyDefinition> {
|
||||
const key = dataRecord(value, 'publisher key');
|
||||
exactKeys(
|
||||
key,
|
||||
['keyId', 'notAfterMs', 'notBeforeMs', 'publicKeyPem', 'publisher'],
|
||||
'publisher key',
|
||||
);
|
||||
return Object.freeze({
|
||||
publisher: key.publisher as string,
|
||||
keyId: key.keyId as string,
|
||||
publicKeyPem: key.publicKeyPem as string,
|
||||
notBeforeMs: key.notBeforeMs as number,
|
||||
notAfterMs: key.notAfterMs as number,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeLocalPluginPackagePublisherTrustDocument(
|
||||
value: unknown,
|
||||
): Readonly<LocalPluginPackagePublisherTrustDocument> {
|
||||
const document = dataRecord(value, 'publisher trust');
|
||||
exactKeys(document, ['keys', 'schema'], 'publisher trust');
|
||||
if (
|
||||
document.schema !== LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SCHEMA ||
|
||||
!Array.isArray(document.keys)
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'publisher trust shape is invalid',
|
||||
);
|
||||
}
|
||||
const keys = document.keys.map(canonicalKey);
|
||||
if (keys.length > 0) {
|
||||
try {
|
||||
new PluginPackagePublisherTrustRegistry(keys);
|
||||
} catch (error) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'publisher trust keys are invalid',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
keys.sort((left, right) =>
|
||||
`${left.publisher}\0${left.keyId}`.localeCompare(
|
||||
`${right.publisher}\0${right.keyId}`,
|
||||
),
|
||||
);
|
||||
return Object.freeze({
|
||||
schema: LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SCHEMA,
|
||||
keys: Object.freeze(keys),
|
||||
});
|
||||
}
|
||||
|
||||
export function createLocalPluginPackagePublisherTrustRegistry(
|
||||
value: unknown,
|
||||
): PluginPackagePublisherTrustRegistry {
|
||||
const trust = normalizeLocalPluginPackagePublisherTrustDocument(value);
|
||||
return new PluginPackagePublisherTrustRegistry(trust.keys);
|
||||
}
|
||||
|
||||
export function canonicalTrust(
|
||||
trust: Readonly<LocalPluginPackagePublisherTrustDocument>,
|
||||
): string {
|
||||
return `${JSON.stringify(trust)}\n`;
|
||||
}
|
||||
|
||||
export function boundedIdentity(value: unknown, label: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length === 0 ||
|
||||
Buffer.byteLength(value, 'utf8') > 256 ||
|
||||
value.includes('\0')
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
`${label} is invalid`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function retirementIdentityDigest(publisher: string, keyId: string): string {
|
||||
return digest(`${publisher}\0${keyId}`);
|
||||
}
|
||||
|
||||
export function retirementIntentMaterial(
|
||||
value: Omit<RetirementIntent, 'intentDigest'>,
|
||||
): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
export function retirementReceiptMaterial(
|
||||
value: Omit<RetirementReceipt, 'receiptDigest'>,
|
||||
): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
export function normalizeRetirementIntent(value: unknown): Readonly<RetirementIntent> {
|
||||
const intent = dataRecord(value, 'publisher key retirement intent');
|
||||
exactKeys(
|
||||
intent,
|
||||
[
|
||||
'expectedGeneration',
|
||||
'intentDigest',
|
||||
'keyId',
|
||||
'mutationId',
|
||||
'occurredAtMs',
|
||||
'previousTrustDigest',
|
||||
'publisher',
|
||||
'schema',
|
||||
],
|
||||
'publisher key retirement intent',
|
||||
);
|
||||
const publisher = boundedIdentity(intent.publisher, 'retirement publisher');
|
||||
const keyId = boundedIdentity(intent.keyId, 'retirement keyId');
|
||||
const expectedGeneration = integer(
|
||||
intent.expectedGeneration,
|
||||
1,
|
||||
'retirement expectedGeneration',
|
||||
);
|
||||
const occurredAtMs = integer(
|
||||
intent.occurredAtMs,
|
||||
0,
|
||||
'retirement occurredAtMs',
|
||||
);
|
||||
if (
|
||||
intent.schema !==
|
||||
LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_RETIREMENT_INTENT_SCHEMA ||
|
||||
typeof intent.mutationId !== 'string' ||
|
||||
!MUTATION_ID_PATTERN.test(intent.mutationId) ||
|
||||
typeof intent.previousTrustDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(intent.previousTrustDigest) ||
|
||||
typeof intent.intentDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(intent.intentDigest)
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'publisher key retirement intent fields are invalid',
|
||||
);
|
||||
}
|
||||
const material = Object.freeze({
|
||||
schema: LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_RETIREMENT_INTENT_SCHEMA,
|
||||
publisher,
|
||||
keyId,
|
||||
expectedGeneration,
|
||||
previousTrustDigest: intent.previousTrustDigest,
|
||||
mutationId: intent.mutationId,
|
||||
occurredAtMs,
|
||||
});
|
||||
if (digest(retirementIntentMaterial(material)) !== intent.intentDigest) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'publisher key retirement intent digest is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ ...material, intentDigest: intent.intentDigest });
|
||||
}
|
||||
|
||||
export function normalizeRetirementReceipt(
|
||||
value: unknown,
|
||||
): Readonly<RetirementReceipt> {
|
||||
const receipt = dataRecord(value, 'publisher key retirement receipt');
|
||||
exactKeys(
|
||||
receipt,
|
||||
[
|
||||
'bundleCount',
|
||||
'catalogEntryCount',
|
||||
'expectedGeneration',
|
||||
'intentDigest',
|
||||
'keyId',
|
||||
'matchingEntryCount',
|
||||
'mutationId',
|
||||
'occurredAtMs',
|
||||
'publisher',
|
||||
'receiptDigest',
|
||||
'schema',
|
||||
'unresolvedTransactions',
|
||||
],
|
||||
'publisher key retirement receipt',
|
||||
);
|
||||
const publisher = boundedIdentity(receipt.publisher, 'retirement publisher');
|
||||
const keyId = boundedIdentity(receipt.keyId, 'retirement keyId');
|
||||
const expectedGeneration = integer(
|
||||
receipt.expectedGeneration,
|
||||
1,
|
||||
'retirement expectedGeneration',
|
||||
);
|
||||
const occurredAtMs = integer(
|
||||
receipt.occurredAtMs,
|
||||
0,
|
||||
'retirement occurredAtMs',
|
||||
);
|
||||
const catalogEntryCount = integer(
|
||||
receipt.catalogEntryCount,
|
||||
0,
|
||||
'retirement catalogEntryCount',
|
||||
);
|
||||
const bundleCount = integer(receipt.bundleCount, 0, 'retirement bundleCount');
|
||||
if (
|
||||
receipt.schema !==
|
||||
LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_RETIREMENT_RECEIPT_SCHEMA ||
|
||||
typeof receipt.mutationId !== 'string' ||
|
||||
!MUTATION_ID_PATTERN.test(receipt.mutationId) ||
|
||||
typeof receipt.intentDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(receipt.intentDigest) ||
|
||||
receipt.matchingEntryCount !== 0 ||
|
||||
receipt.unresolvedTransactions !== 0 ||
|
||||
typeof receipt.receiptDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(receipt.receiptDigest)
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'publisher key retirement receipt fields are invalid',
|
||||
);
|
||||
}
|
||||
const material = Object.freeze({
|
||||
schema: LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_RETIREMENT_RECEIPT_SCHEMA,
|
||||
publisher,
|
||||
keyId,
|
||||
expectedGeneration,
|
||||
mutationId: receipt.mutationId,
|
||||
intentDigest: receipt.intentDigest,
|
||||
catalogEntryCount,
|
||||
bundleCount,
|
||||
matchingEntryCount: 0 as const,
|
||||
unresolvedTransactions: 0 as const,
|
||||
occurredAtMs,
|
||||
});
|
||||
if (digest(retirementReceiptMaterial(material)) !== receipt.receiptDigest) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'publisher key retirement receipt digest is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ ...material, receiptDigest: receipt.receiptDigest });
|
||||
}
|
||||
|
||||
export function lockDigests(value: unknown, label: string): readonly string[] {
|
||||
if (!Array.isArray(value) || value.length > 64) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
`${label} is invalid`,
|
||||
);
|
||||
}
|
||||
const normalized = value.map((candidate) => {
|
||||
if (typeof candidate !== 'string' || !DIGEST_PATTERN.test(candidate)) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
`${label} is invalid`,
|
||||
);
|
||||
}
|
||||
return candidate;
|
||||
});
|
||||
const sorted = [...normalized].sort();
|
||||
if (
|
||||
new Set(sorted).size !== sorted.length ||
|
||||
normalized.some((candidate, index) => candidate !== sorted[index])
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
`${label} must be unique and sorted`,
|
||||
);
|
||||
}
|
||||
return Object.freeze(sorted);
|
||||
}
|
||||
|
||||
export function localPluginPackagePublisherKeyRevocationImpactDigest(
|
||||
value: Readonly<{
|
||||
publisher: string;
|
||||
keyId: string;
|
||||
catalogEntryCount: number;
|
||||
bundleCount: number;
|
||||
matchingEntryCount: number;
|
||||
unresolvedTransactions: number;
|
||||
impactedLockDigests: readonly string[];
|
||||
}>,
|
||||
): string {
|
||||
const publisher = boundedIdentity(value.publisher, 'impact publisher');
|
||||
const keyId = boundedIdentity(value.keyId, 'impact keyId');
|
||||
const catalogEntryCount = integer(
|
||||
value.catalogEntryCount,
|
||||
0,
|
||||
'impact catalogEntryCount',
|
||||
);
|
||||
const bundleCount = integer(value.bundleCount, 0, 'impact bundleCount');
|
||||
const matchingEntryCount = integer(
|
||||
value.matchingEntryCount,
|
||||
0,
|
||||
'impact matchingEntryCount',
|
||||
);
|
||||
const unresolvedTransactions = integer(
|
||||
value.unresolvedTransactions,
|
||||
0,
|
||||
'impact unresolvedTransactions',
|
||||
);
|
||||
const impactedLockDigests = lockDigests(
|
||||
value.impactedLockDigests,
|
||||
'impact lock digests',
|
||||
);
|
||||
if (
|
||||
matchingEntryCount !== impactedLockDigests.length ||
|
||||
catalogEntryCount < matchingEntryCount
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'revocation impact counts are invalid',
|
||||
);
|
||||
}
|
||||
return digest(
|
||||
JSON.stringify({
|
||||
publisher,
|
||||
keyId,
|
||||
catalogEntryCount,
|
||||
bundleCount,
|
||||
matchingEntryCount,
|
||||
unresolvedTransactions,
|
||||
impactedLockDigests,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function revocationProposalMaterial(
|
||||
value: Omit<RevocationProposal, 'proposalDigest'>,
|
||||
): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
export function revocationReceiptMaterial(
|
||||
value: Omit<RevocationReceipt, 'receiptDigest'>,
|
||||
): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
export function normalizeRevocationProposal(
|
||||
value: unknown,
|
||||
): Readonly<RevocationProposal> {
|
||||
const proposal = dataRecord(value, 'publisher key revocation proposal');
|
||||
exactKeys(
|
||||
proposal,
|
||||
[
|
||||
'bundleCount',
|
||||
'catalogEntryCount',
|
||||
'expectedGeneration',
|
||||
'impactDigest',
|
||||
'impactedLockDigests',
|
||||
'keyId',
|
||||
'matchingEntryCount',
|
||||
'mutationId',
|
||||
'occurredAtMs',
|
||||
'previousTrustDigest',
|
||||
'proposalDigest',
|
||||
'proposerSubjectId',
|
||||
'publisher',
|
||||
'schema',
|
||||
'unresolvedTransactions',
|
||||
],
|
||||
'publisher key revocation proposal',
|
||||
);
|
||||
const publisher = boundedIdentity(proposal.publisher, 'revocation publisher');
|
||||
const keyId = boundedIdentity(proposal.keyId, 'revocation keyId');
|
||||
const proposerSubjectId = boundedIdentity(
|
||||
proposal.proposerSubjectId,
|
||||
'revocation proposer subject',
|
||||
);
|
||||
const expectedGeneration = integer(
|
||||
proposal.expectedGeneration,
|
||||
1,
|
||||
'revocation expectedGeneration',
|
||||
);
|
||||
const occurredAtMs = integer(
|
||||
proposal.occurredAtMs,
|
||||
0,
|
||||
'revocation occurredAtMs',
|
||||
);
|
||||
const catalogEntryCount = integer(
|
||||
proposal.catalogEntryCount,
|
||||
0,
|
||||
'revocation catalogEntryCount',
|
||||
);
|
||||
const bundleCount = integer(
|
||||
proposal.bundleCount,
|
||||
0,
|
||||
'revocation bundleCount',
|
||||
);
|
||||
const matchingEntryCount = integer(
|
||||
proposal.matchingEntryCount,
|
||||
0,
|
||||
'revocation matchingEntryCount',
|
||||
);
|
||||
const unresolvedTransactions = integer(
|
||||
proposal.unresolvedTransactions,
|
||||
0,
|
||||
'revocation unresolvedTransactions',
|
||||
);
|
||||
const impactedLockDigests = lockDigests(
|
||||
proposal.impactedLockDigests,
|
||||
'revocation impacted lock digests',
|
||||
);
|
||||
if (
|
||||
proposal.schema !==
|
||||
LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_REVOCATION_PROPOSAL_SCHEMA ||
|
||||
typeof proposal.mutationId !== 'string' ||
|
||||
!MUTATION_ID_PATTERN.test(proposal.mutationId) ||
|
||||
typeof proposal.previousTrustDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(proposal.previousTrustDigest) ||
|
||||
typeof proposal.impactDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(proposal.impactDigest) ||
|
||||
typeof proposal.proposalDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(proposal.proposalDigest) ||
|
||||
localPluginPackagePublisherKeyRevocationImpactDigest({
|
||||
publisher,
|
||||
keyId,
|
||||
catalogEntryCount,
|
||||
bundleCount,
|
||||
matchingEntryCount,
|
||||
unresolvedTransactions,
|
||||
impactedLockDigests,
|
||||
}) !== proposal.impactDigest
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'publisher key revocation proposal fields are invalid',
|
||||
);
|
||||
}
|
||||
const material = Object.freeze({
|
||||
schema: LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_REVOCATION_PROPOSAL_SCHEMA,
|
||||
publisher,
|
||||
keyId,
|
||||
expectedGeneration,
|
||||
previousTrustDigest: proposal.previousTrustDigest,
|
||||
mutationId: proposal.mutationId,
|
||||
occurredAtMs,
|
||||
proposerSubjectId,
|
||||
catalogEntryCount,
|
||||
bundleCount,
|
||||
matchingEntryCount,
|
||||
unresolvedTransactions,
|
||||
impactedLockDigests,
|
||||
impactDigest: proposal.impactDigest,
|
||||
});
|
||||
if (
|
||||
digest(revocationProposalMaterial(material)) !== proposal.proposalDigest
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'publisher key revocation proposal digest is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
...material,
|
||||
proposalDigest: proposal.proposalDigest,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeRevocationReceipt(
|
||||
value: unknown,
|
||||
): Readonly<RevocationReceipt> {
|
||||
const receipt = dataRecord(value, 'publisher key revocation receipt');
|
||||
exactKeys(
|
||||
receipt,
|
||||
[
|
||||
'authorizationMode',
|
||||
'confirmedAtMs',
|
||||
'confirmerSubjectId',
|
||||
'expectedGeneration',
|
||||
'impactDigest',
|
||||
'impactedLockDigests',
|
||||
'keyId',
|
||||
'mutationId',
|
||||
'proposalDigest',
|
||||
'proposerSubjectId',
|
||||
'publisher',
|
||||
'reasonCode',
|
||||
'receiptDigest',
|
||||
'schema',
|
||||
],
|
||||
'publisher key revocation receipt',
|
||||
);
|
||||
const publisher = boundedIdentity(receipt.publisher, 'revocation publisher');
|
||||
const keyId = boundedIdentity(receipt.keyId, 'revocation keyId');
|
||||
const proposerSubjectId = boundedIdentity(
|
||||
receipt.proposerSubjectId,
|
||||
'revocation proposer subject',
|
||||
);
|
||||
const confirmerSubjectId = boundedIdentity(
|
||||
receipt.confirmerSubjectId,
|
||||
'revocation confirmer subject',
|
||||
);
|
||||
const expectedGeneration = integer(
|
||||
receipt.expectedGeneration,
|
||||
1,
|
||||
'revocation expectedGeneration',
|
||||
);
|
||||
const confirmedAtMs = integer(
|
||||
receipt.confirmedAtMs,
|
||||
0,
|
||||
'revocation confirmedAtMs',
|
||||
);
|
||||
const impactedLockDigests = lockDigests(
|
||||
receipt.impactedLockDigests,
|
||||
'revocation impacted lock digests',
|
||||
);
|
||||
if (
|
||||
receipt.schema !==
|
||||
LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_REVOCATION_RECEIPT_SCHEMA ||
|
||||
typeof receipt.mutationId !== 'string' ||
|
||||
!MUTATION_ID_PATTERN.test(receipt.mutationId) ||
|
||||
typeof receipt.proposalDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(receipt.proposalDigest) ||
|
||||
typeof receipt.impactDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(receipt.impactDigest) ||
|
||||
(receipt.authorizationMode !== 'dual_control' &&
|
||||
receipt.authorizationMode !== 'break_glass') ||
|
||||
(receipt.authorizationMode === 'dual_control' &&
|
||||
proposerSubjectId === confirmerSubjectId) ||
|
||||
(receipt.reasonCode !== 'suspected_key_compromise' &&
|
||||
receipt.reasonCode !== 'confirmed_key_compromise') ||
|
||||
typeof receipt.receiptDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(receipt.receiptDigest)
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'publisher key revocation receipt fields are invalid',
|
||||
);
|
||||
}
|
||||
const material = Object.freeze({
|
||||
schema: LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_REVOCATION_RECEIPT_SCHEMA,
|
||||
publisher,
|
||||
keyId,
|
||||
expectedGeneration,
|
||||
mutationId: receipt.mutationId,
|
||||
proposalDigest: receipt.proposalDigest,
|
||||
proposerSubjectId,
|
||||
confirmerSubjectId,
|
||||
authorizationMode: receipt.authorizationMode,
|
||||
reasonCode: receipt.reasonCode,
|
||||
confirmedAtMs,
|
||||
impactDigest: receipt.impactDigest,
|
||||
impactedLockDigests,
|
||||
});
|
||||
if (digest(revocationReceiptMaterial(material)) !== receipt.receiptDigest) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'publisher key revocation receipt digest is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ ...material, receiptDigest: receipt.receiptDigest });
|
||||
}
|
||||
|
||||
export function snapshotMaterial(
|
||||
value: Omit<TrustSnapshot, 'snapshotDigest'>,
|
||||
): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
export function normalizeSnapshot(value: unknown): Readonly<TrustSnapshot> {
|
||||
const snapshot = dataRecord(value, 'publisher trust snapshot');
|
||||
exactKeys(
|
||||
snapshot,
|
||||
[
|
||||
'generation',
|
||||
'mode',
|
||||
'mutationId',
|
||||
'occurredAtMs',
|
||||
'previousSnapshotDigest',
|
||||
'previousTrustDigest',
|
||||
'schema',
|
||||
'snapshotDigest',
|
||||
'trust',
|
||||
'trustDigest',
|
||||
],
|
||||
'publisher trust snapshot',
|
||||
);
|
||||
const generation = integer(snapshot.generation, 1, 'snapshot generation');
|
||||
const occurredAtMs = integer(
|
||||
snapshot.occurredAtMs,
|
||||
0,
|
||||
'snapshot occurredAtMs',
|
||||
);
|
||||
if (
|
||||
snapshot.schema !== LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SNAPSHOT_SCHEMA ||
|
||||
(snapshot.mode !== 'provision' &&
|
||||
snapshot.mode !== 'rotate' &&
|
||||
snapshot.mode !== 'retire' &&
|
||||
snapshot.mode !== 'revoke') ||
|
||||
typeof snapshot.mutationId !== 'string' ||
|
||||
!MUTATION_ID_PATTERN.test(snapshot.mutationId) ||
|
||||
(snapshot.previousSnapshotDigest !== null &&
|
||||
(typeof snapshot.previousSnapshotDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(snapshot.previousSnapshotDigest))) ||
|
||||
(snapshot.previousTrustDigest !== null &&
|
||||
(typeof snapshot.previousTrustDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(snapshot.previousTrustDigest))) ||
|
||||
typeof snapshot.trustDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(snapshot.trustDigest) ||
|
||||
typeof snapshot.snapshotDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(snapshot.snapshotDigest)
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'publisher trust snapshot fields are invalid',
|
||||
);
|
||||
}
|
||||
const trust = normalizeLocalPluginPackagePublisherTrustDocument(
|
||||
snapshot.trust,
|
||||
);
|
||||
if (digest(canonicalTrust(trust)) !== snapshot.trustDigest) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'publisher trust snapshot trust digest is invalid',
|
||||
);
|
||||
}
|
||||
const material = Object.freeze({
|
||||
schema: LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SNAPSHOT_SCHEMA,
|
||||
generation,
|
||||
previousSnapshotDigest: snapshot.previousSnapshotDigest,
|
||||
previousTrustDigest: snapshot.previousTrustDigest,
|
||||
trustDigest: snapshot.trustDigest,
|
||||
mutationId: snapshot.mutationId,
|
||||
occurredAtMs,
|
||||
mode: snapshot.mode,
|
||||
trust,
|
||||
});
|
||||
if (digest(snapshotMaterial(material)) !== snapshot.snapshotDigest) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'publisher trust snapshot digest is invalid',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
...material,
|
||||
snapshotDigest: snapshot.snapshotDigest,
|
||||
});
|
||||
}
|
||||
|
||||
export function snapshotName(generation: number): string {
|
||||
return `${String(generation).padStart(20, '0')}.json`;
|
||||
}
|
||||
|
||||
export function createSnapshot(
|
||||
mode: 'provision' | 'rotate' | 'retire' | 'revoke',
|
||||
expectedGeneration: number,
|
||||
mutationId: string,
|
||||
occurredAtMs: number,
|
||||
trust: Readonly<LocalPluginPackagePublisherTrustDocument>,
|
||||
previous: Readonly<TrustSnapshot> | undefined,
|
||||
): Readonly<TrustSnapshot> {
|
||||
const material = Object.freeze({
|
||||
schema: LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SNAPSHOT_SCHEMA,
|
||||
generation: expectedGeneration + 1,
|
||||
previousSnapshotDigest: previous?.snapshotDigest ?? null,
|
||||
previousTrustDigest: previous?.trustDigest ?? null,
|
||||
trustDigest: digest(canonicalTrust(trust)),
|
||||
mutationId,
|
||||
occurredAtMs,
|
||||
mode,
|
||||
trust,
|
||||
});
|
||||
return Object.freeze({
|
||||
...material,
|
||||
snapshotDigest: digest(snapshotMaterial(material)),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import type { PluginPackagePublisherKeyDefinition } from '@qinglong/runtime-core/plugin-package-bundle';
|
||||
|
||||
export const LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SCHEMA =
|
||||
'qinglong/plugin-package-publisher-trust@v1' as const;
|
||||
export const LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SNAPSHOT_SCHEMA =
|
||||
'qinglong/local-plugin-package-publisher-trust-snapshot@v1' as const;
|
||||
export const LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_RETIREMENT_INTENT_SCHEMA =
|
||||
'qinglong/local-plugin-package-publisher-trust-retirement-intent@v1' as const;
|
||||
export const LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_RETIREMENT_RECEIPT_SCHEMA =
|
||||
'qinglong/local-plugin-package-publisher-trust-retirement-receipt@v1' as const;
|
||||
export const LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_REVOCATION_PROPOSAL_SCHEMA =
|
||||
'qinglong/local-plugin-package-publisher-trust-revocation-proposal@v1' as const;
|
||||
export const LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_REVOCATION_RECEIPT_SCHEMA =
|
||||
'qinglong/local-plugin-package-publisher-trust-revocation-receipt@v1' as const;
|
||||
export const MAX_LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_GENERATIONS = 64;
|
||||
export const MAX_LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_RETIREMENTS = 32;
|
||||
export const MAX_LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_REVOCATIONS = 32;
|
||||
|
||||
export interface LocalPluginPackagePublisherTrustDocument {
|
||||
readonly schema: typeof LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SCHEMA;
|
||||
readonly keys: readonly Readonly<PluginPackagePublisherKeyDefinition>[];
|
||||
}
|
||||
|
||||
export interface PublishLocalPluginPackagePublisherTrustOptions {
|
||||
readonly trustRoot: string;
|
||||
readonly mode: 'provision' | 'rotate';
|
||||
readonly expectedGeneration: number;
|
||||
readonly mutationId: string;
|
||||
readonly occurredAtMs: number;
|
||||
readonly trust: unknown;
|
||||
readonly beforePublish?: () => void | Promise<void>;
|
||||
readonly afterSnapshotPublished?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface RetireLocalPluginPackagePublisherKeyOptions {
|
||||
readonly trustRoot: string;
|
||||
readonly expectedGeneration: number;
|
||||
readonly mutationId: string;
|
||||
readonly occurredAtMs: number;
|
||||
readonly publisher: string;
|
||||
readonly keyId: string;
|
||||
readonly proveRetirement: () =>
|
||||
| Readonly<LocalPluginPackagePublisherKeyRetirementProof>
|
||||
| Promise<Readonly<LocalPluginPackagePublisherKeyRetirementProof>>;
|
||||
readonly beforePublish?: () => void | Promise<void>;
|
||||
readonly afterIntentPublished?: () => void | Promise<void>;
|
||||
readonly afterReceiptPublished?: () => void | Promise<void>;
|
||||
readonly afterSnapshotPublished?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface LocalPluginPackagePublisherKeyRetirementProof {
|
||||
readonly catalogEntryCount: number;
|
||||
readonly bundleCount: number;
|
||||
readonly matchingEntryCount: number;
|
||||
readonly unresolvedTransactions: number;
|
||||
}
|
||||
|
||||
export interface LocalPluginPackagePublisherKeyRevocationImpact {
|
||||
readonly catalogEntryCount: number;
|
||||
readonly bundleCount: number;
|
||||
readonly matchingEntryCount: number;
|
||||
readonly unresolvedTransactions: number;
|
||||
readonly impactedLockDigests: readonly string[];
|
||||
readonly impactDigest: string;
|
||||
}
|
||||
|
||||
export interface ProposeLocalPluginPackagePublisherKeyRevocationOptions {
|
||||
readonly trustRoot: string;
|
||||
readonly expectedGeneration: number;
|
||||
readonly mutationId: string;
|
||||
readonly occurredAtMs: number;
|
||||
readonly publisher: string;
|
||||
readonly keyId: string;
|
||||
readonly proposerSubjectId: string;
|
||||
readonly impact: Readonly<LocalPluginPackagePublisherKeyRevocationImpact>;
|
||||
readonly beforePublish?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface LocalPluginPackagePublisherKeyRevocationReceipt {
|
||||
readonly schema: typeof LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_REVOCATION_RECEIPT_SCHEMA;
|
||||
readonly publisher: string;
|
||||
readonly keyId: string;
|
||||
readonly expectedGeneration: number;
|
||||
readonly mutationId: string;
|
||||
readonly proposalDigest: string;
|
||||
readonly proposerSubjectId: string;
|
||||
readonly confirmerSubjectId: string;
|
||||
readonly authorizationMode: 'dual_control' | 'break_glass';
|
||||
readonly reasonCode: 'suspected_key_compromise' | 'confirmed_key_compromise';
|
||||
readonly confirmedAtMs: number;
|
||||
readonly impactDigest: string;
|
||||
readonly impactedLockDigests: readonly string[];
|
||||
readonly receiptDigest: string;
|
||||
}
|
||||
|
||||
export interface ConfirmLocalPluginPackagePublisherKeyRevocationOptions {
|
||||
readonly trustRoot: string;
|
||||
readonly expectedGeneration: number;
|
||||
readonly mutationId: string;
|
||||
readonly confirmedAtMs: number;
|
||||
readonly publisher: string;
|
||||
readonly keyId: string;
|
||||
readonly proposerSubjectId: string;
|
||||
readonly confirmerSubjectId: string;
|
||||
readonly authorizationMode: 'dual_control' | 'break_glass';
|
||||
readonly reasonCode: 'suspected_key_compromise' | 'confirmed_key_compromise';
|
||||
readonly expectedImpactDigest: string;
|
||||
readonly confirmAuthorization: () => void | Promise<void>;
|
||||
readonly beforePublish?: () => void | Promise<void>;
|
||||
readonly afterReceiptPublished?: (
|
||||
receipt: Readonly<LocalPluginPackagePublisherKeyRevocationReceipt>,
|
||||
) => void | Promise<void>;
|
||||
readonly afterSnapshotPublished?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface ProposedLocalPluginPackagePublisherKeyRevocation {
|
||||
readonly status: 'proposed' | 'existing';
|
||||
readonly generation: number;
|
||||
readonly proposalDigest: string;
|
||||
readonly impactDigest: string;
|
||||
readonly matchingEntryCount: number;
|
||||
readonly runtimeAction: 'stop_required';
|
||||
}
|
||||
|
||||
export interface PublishedLocalPluginPackagePublisherTrust {
|
||||
readonly status: 'published' | 'existing' | 'recovered';
|
||||
readonly generation: number;
|
||||
readonly keyCount: number;
|
||||
readonly trustDigest: string;
|
||||
}
|
||||
|
||||
export interface ConfirmedLocalPluginPackagePublisherKeyRevocation
|
||||
extends PublishedLocalPluginPackagePublisherTrust {
|
||||
readonly authorizationMode: 'dual_control' | 'break_glass';
|
||||
readonly quarantinedLockCount: number;
|
||||
readonly runtimeAction: 'restart_required';
|
||||
}
|
||||
|
||||
export interface LocalPluginPackagePublisherTrustInspection {
|
||||
readonly generation: number;
|
||||
readonly keyCount: number;
|
||||
readonly activeKeyCount: number;
|
||||
readonly snapshotCount: number;
|
||||
readonly retirementCount: number;
|
||||
readonly pendingRetirementCount: number;
|
||||
readonly revocationCount: number;
|
||||
readonly pendingRevocationCount: number;
|
||||
readonly quarantinedLockCount: number;
|
||||
readonly recoveryRequired: boolean;
|
||||
readonly pendingGeneration: number | null;
|
||||
readonly pendingMutationId: string | null;
|
||||
readonly unresolvedTransactions: number;
|
||||
readonly trustDigest: string | null;
|
||||
}
|
||||
|
||||
export class LocalPluginPackagePublisherTrustConfigurationError extends TypeError {
|
||||
readonly code = 'LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_CONFIGURATION_INVALID';
|
||||
|
||||
constructor(message: string, readonly cause?: unknown) {
|
||||
super(`Local Plugin Package publisher trust is invalid: ${message}`);
|
||||
this.name = 'LocalPluginPackagePublisherTrustConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalPluginPackagePublisherTrustConflictError extends Error {
|
||||
readonly code = 'LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_CONFLICT';
|
||||
|
||||
constructor(message: string) {
|
||||
super(
|
||||
`Local Plugin Package publisher trust conflicts with current state: ${message}`,
|
||||
);
|
||||
this.name = 'LocalPluginPackagePublisherTrustConflictError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
type LocalPluginPackagePublisherTrustInspection,
|
||||
} from '../contracts';
|
||||
import {
|
||||
activeKeyCount,
|
||||
dataRecord,
|
||||
exactKeys,
|
||||
integer,
|
||||
digest,
|
||||
} from '../codec';
|
||||
import {
|
||||
TEMPORARY_PATTERN,
|
||||
loadState,
|
||||
} from '../privateFilesystemStore';
|
||||
|
||||
export function inspectLocalPluginPackagePublisherTrust(
|
||||
value: Readonly<{ trustRoot: string; observedAtMs: number }>,
|
||||
): Readonly<LocalPluginPackagePublisherTrustInspection> {
|
||||
const options = dataRecord(value, 'inspection options');
|
||||
exactKeys(options, ['observedAtMs', 'trustRoot'], 'inspection options');
|
||||
const observedAtMs = integer(
|
||||
value.observedAtMs,
|
||||
0,
|
||||
'inspection observedAtMs',
|
||||
);
|
||||
const state = loadState(value.trustRoot);
|
||||
return Object.freeze({
|
||||
generation: state.committed?.generation ?? 0,
|
||||
keyCount: state.current?.trust.keys.length ?? 0,
|
||||
activeKeyCount: activeKeyCount(state.current?.trust, observedAtMs),
|
||||
snapshotCount: state.snapshots.length,
|
||||
retirementCount: state.snapshots.filter(
|
||||
(snapshot) => snapshot.mode === 'retire',
|
||||
).length,
|
||||
pendingRetirementCount: state.pendingRetirement ? 1 : 0,
|
||||
revocationCount: state.snapshots.filter(
|
||||
(snapshot) => snapshot.mode === 'revoke',
|
||||
).length,
|
||||
pendingRevocationCount: state.pendingRevocation ? 1 : 0,
|
||||
quarantinedLockCount: new Set(
|
||||
state.revocationProposals.flatMap(
|
||||
(proposal) => proposal.impactedLockDigests,
|
||||
),
|
||||
).size,
|
||||
recoveryRequired:
|
||||
state.pending !== undefined ||
|
||||
state.pendingRetirement !== undefined ||
|
||||
state.pendingRevocation !== undefined,
|
||||
pendingGeneration:
|
||||
state.pending?.generation ??
|
||||
(state.pendingRetirement
|
||||
? state.pendingRetirement.expectedGeneration + 1
|
||||
: state.pendingRevocation
|
||||
? state.pendingRevocation.expectedGeneration + 1
|
||||
: null),
|
||||
pendingMutationId:
|
||||
state.pending?.mutationId ??
|
||||
state.pendingRetirement?.mutationId ??
|
||||
state.pendingRevocation?.mutationId ??
|
||||
null,
|
||||
unresolvedTransactions: state.root.entries.filter((entry) =>
|
||||
TEMPORARY_PATTERN.test(entry),
|
||||
).length,
|
||||
trustDigest: state.current?.digest ?? null,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import {
|
||||
LocalPluginPackagePublisherTrustConfigurationError,
|
||||
LocalPluginPackagePublisherTrustConflictError,
|
||||
MAX_LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_GENERATIONS,
|
||||
type LocalPluginPackagePublisherTrustDocument,
|
||||
type PublishedLocalPluginPackagePublisherTrust,
|
||||
type PublishLocalPluginPackagePublisherTrustOptions,
|
||||
} from '../contracts';
|
||||
import {
|
||||
MUTATION_ID_PATTERN,
|
||||
activeKeyCount,
|
||||
createSnapshot,
|
||||
dataRecord,
|
||||
exactKeys,
|
||||
integer,
|
||||
keyMap,
|
||||
digest,
|
||||
boundedIdentity,
|
||||
normalizeLocalPluginPackagePublisherTrustDocument,
|
||||
sameSnapshot,
|
||||
} from '../codec';
|
||||
import {
|
||||
revalidateDirectory,
|
||||
loadState,
|
||||
publishSnapshot,
|
||||
promoteCurrent,
|
||||
} from '../privateFilesystemStore';
|
||||
|
||||
function assertTransition(
|
||||
mode: 'provision' | 'rotate',
|
||||
current: Readonly<LocalPluginPackagePublisherTrustDocument> | undefined,
|
||||
candidate: Readonly<LocalPluginPackagePublisherTrustDocument>,
|
||||
occurredAtMs: number,
|
||||
): void {
|
||||
if (mode === 'provision') {
|
||||
if (current !== undefined) {
|
||||
throw new LocalPluginPackagePublisherTrustConflictError(
|
||||
'provision requires an empty trust root',
|
||||
);
|
||||
}
|
||||
if (activeKeyCount(candidate, occurredAtMs) < 1) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'provision requires a currently active publisher key',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (current === undefined) {
|
||||
throw new LocalPluginPackagePublisherTrustConflictError(
|
||||
'rotation requires an existing trust generation',
|
||||
);
|
||||
}
|
||||
const existing = keyMap(current);
|
||||
const next = keyMap(candidate);
|
||||
for (const [identifier, definition] of existing) {
|
||||
if (
|
||||
!next.has(identifier) ||
|
||||
JSON.stringify(next.get(identifier)) !== JSON.stringify(definition)
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'overlap rotation cannot remove or rewrite an existing key',
|
||||
);
|
||||
}
|
||||
}
|
||||
const added = [...next.entries()]
|
||||
.filter(([identifier]) => !existing.has(identifier))
|
||||
.map(([, definition]) => definition);
|
||||
if (
|
||||
added.length === 0 ||
|
||||
!added.some(
|
||||
(key) => key.notBeforeMs <= occurredAtMs && occurredAtMs < key.notAfterMs,
|
||||
)
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'overlap rotation requires a new currently active key',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function publishLocalPluginPackagePublisherTrust(
|
||||
value: PublishLocalPluginPackagePublisherTrustOptions,
|
||||
): Promise<Readonly<PublishedLocalPluginPackagePublisherTrust>> {
|
||||
const options = dataRecord(value, 'publication options');
|
||||
const optional = [
|
||||
...(Object.hasOwn(options, 'beforePublish') ? ['beforePublish'] : []),
|
||||
...(Object.hasOwn(options, 'afterSnapshotPublished')
|
||||
? ['afterSnapshotPublished']
|
||||
: []),
|
||||
];
|
||||
exactKeys(
|
||||
options,
|
||||
[
|
||||
'expectedGeneration',
|
||||
'mode',
|
||||
'mutationId',
|
||||
'occurredAtMs',
|
||||
'trust',
|
||||
'trustRoot',
|
||||
...optional,
|
||||
],
|
||||
'publication options',
|
||||
);
|
||||
const expectedGeneration = integer(
|
||||
value.expectedGeneration,
|
||||
0,
|
||||
'expectedGeneration',
|
||||
);
|
||||
const occurredAtMs = integer(value.occurredAtMs, 0, 'occurredAtMs');
|
||||
if (
|
||||
(value.mode !== 'provision' && value.mode !== 'rotate') ||
|
||||
typeof value.mutationId !== 'string' ||
|
||||
!MUTATION_ID_PATTERN.test(value.mutationId) ||
|
||||
(value.beforePublish !== undefined &&
|
||||
typeof value.beforePublish !== 'function') ||
|
||||
(value.afterSnapshotPublished !== undefined &&
|
||||
typeof value.afterSnapshotPublished !== 'function')
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'publication identity is invalid',
|
||||
);
|
||||
}
|
||||
const trust = normalizeLocalPluginPackagePublisherTrustDocument(value.trust);
|
||||
let state = loadState(value.trustRoot);
|
||||
const previous = state.snapshots[expectedGeneration - 1];
|
||||
const requested = createSnapshot(
|
||||
value.mode,
|
||||
expectedGeneration,
|
||||
value.mutationId,
|
||||
occurredAtMs,
|
||||
trust,
|
||||
previous,
|
||||
);
|
||||
const replay = state.snapshots.find(
|
||||
(snapshot) => snapshot.mutationId === value.mutationId,
|
||||
);
|
||||
if (replay) {
|
||||
if (!sameSnapshot(replay, requested)) {
|
||||
throw new LocalPluginPackagePublisherTrustConflictError(
|
||||
'mutation identity was reused with different trust',
|
||||
);
|
||||
}
|
||||
await value.beforePublish?.();
|
||||
if (state.pending?.snapshotDigest === replay.snapshotDigest) {
|
||||
promoteCurrent(state.root, replay);
|
||||
return Object.freeze({
|
||||
status: 'recovered',
|
||||
generation: replay.generation,
|
||||
keyCount: replay.trust.keys.length,
|
||||
trustDigest: replay.trustDigest,
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'existing',
|
||||
generation: replay.generation,
|
||||
keyCount: replay.trust.keys.length,
|
||||
trustDigest: replay.trustDigest,
|
||||
});
|
||||
}
|
||||
if (state.pending) {
|
||||
throw new LocalPluginPackagePublisherTrustConflictError(
|
||||
'a trust generation requires exact command replay',
|
||||
);
|
||||
}
|
||||
if (state.pendingRetirement) {
|
||||
throw new LocalPluginPackagePublisherTrustConflictError(
|
||||
'a publisher key retirement requires exact command replay',
|
||||
);
|
||||
}
|
||||
if (state.pendingRevocation) {
|
||||
throw new LocalPluginPackagePublisherTrustConflictError(
|
||||
'a publisher key revocation requires exact command replay',
|
||||
);
|
||||
}
|
||||
if (
|
||||
state.snapshots.length >=
|
||||
MAX_LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_GENERATIONS ||
|
||||
(state.committed?.generation ?? 0) !== expectedGeneration
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConflictError(
|
||||
'expected generation is stale or capacity is exhausted',
|
||||
);
|
||||
}
|
||||
assertTransition(value.mode, state.current?.trust, trust, occurredAtMs);
|
||||
await value.beforePublish?.();
|
||||
state = loadState(value.trustRoot);
|
||||
if (
|
||||
state.pending ||
|
||||
state.pendingRetirement ||
|
||||
state.pendingRevocation ||
|
||||
(state.committed?.generation ?? 0) !== expectedGeneration ||
|
||||
state.current?.digest !==
|
||||
(expectedGeneration === 0 ? undefined : requested.previousTrustDigest)
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConflictError(
|
||||
'trust state changed before publication',
|
||||
);
|
||||
}
|
||||
revalidateDirectory(state.root);
|
||||
publishSnapshot(state.root, requested);
|
||||
await value.afterSnapshotPublished?.();
|
||||
promoteCurrent(state.root, requested);
|
||||
return Object.freeze({
|
||||
status: 'published',
|
||||
generation: requested.generation,
|
||||
keyCount: requested.trust.keys.length,
|
||||
trustDigest: requested.trustDigest,
|
||||
});
|
||||
}
|
||||
|
||||
export function assertLocalPluginPackagePublisherKeyPublicationAllowed(
|
||||
value: Readonly<{
|
||||
trustRoot: string;
|
||||
publisher: string;
|
||||
keyId: string;
|
||||
}>,
|
||||
): void {
|
||||
const options = dataRecord(value, 'publication guard options');
|
||||
exactKeys(
|
||||
options,
|
||||
['keyId', 'publisher', 'trustRoot'],
|
||||
'publication guard options',
|
||||
);
|
||||
const publisher = boundedIdentity(value.publisher, 'publisher');
|
||||
const keyId = boundedIdentity(value.keyId, 'keyId');
|
||||
const state = loadState(value.trustRoot);
|
||||
if (
|
||||
state.retirementIntents.some(
|
||||
(intent) => intent.publisher === publisher && intent.keyId === keyId,
|
||||
) ||
|
||||
state.revocationProposals.some(
|
||||
(proposal) =>
|
||||
proposal.publisher === publisher && proposal.keyId === keyId,
|
||||
)
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConflictError(
|
||||
'publisher key is blocked by a durable lifecycle mutation',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import {
|
||||
LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_RETIREMENT_INTENT_SCHEMA,
|
||||
LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_RETIREMENT_RECEIPT_SCHEMA,
|
||||
LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SCHEMA,
|
||||
LocalPluginPackagePublisherTrustConfigurationError,
|
||||
LocalPluginPackagePublisherTrustConflictError,
|
||||
MAX_LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_RETIREMENTS,
|
||||
type PublishedLocalPluginPackagePublisherTrust,
|
||||
type RetireLocalPluginPackagePublisherKeyOptions,
|
||||
} from '../contracts';
|
||||
import {
|
||||
MUTATION_ID_PATTERN,
|
||||
activeKeyCount,
|
||||
createSnapshot,
|
||||
dataRecord,
|
||||
exactKeys,
|
||||
integer,
|
||||
keyMap,
|
||||
digest,
|
||||
boundedIdentity,
|
||||
normalizeLocalPluginPackagePublisherTrustDocument,
|
||||
normalizeRetirementIntent,
|
||||
normalizeRetirementReceipt,
|
||||
retirementIdentityDigest,
|
||||
retirementIntentMaterial,
|
||||
retirementReceiptMaterial,
|
||||
} from '../codec';
|
||||
import {
|
||||
revalidateDirectory,
|
||||
loadState,
|
||||
publishSnapshot,
|
||||
publishImmutableDocument,
|
||||
promoteCurrent,
|
||||
} from '../privateFilesystemStore';
|
||||
|
||||
export async function retireLocalPluginPackagePublisherKey(
|
||||
value: RetireLocalPluginPackagePublisherKeyOptions,
|
||||
): Promise<Readonly<PublishedLocalPluginPackagePublisherTrust>> {
|
||||
const options = dataRecord(value, 'retirement options');
|
||||
const optional = [
|
||||
...(Object.hasOwn(options, 'beforePublish') ? ['beforePublish'] : []),
|
||||
...(Object.hasOwn(options, 'afterIntentPublished')
|
||||
? ['afterIntentPublished']
|
||||
: []),
|
||||
...(Object.hasOwn(options, 'afterReceiptPublished')
|
||||
? ['afterReceiptPublished']
|
||||
: []),
|
||||
...(Object.hasOwn(options, 'afterSnapshotPublished')
|
||||
? ['afterSnapshotPublished']
|
||||
: []),
|
||||
];
|
||||
exactKeys(
|
||||
options,
|
||||
[
|
||||
'expectedGeneration',
|
||||
'keyId',
|
||||
'mutationId',
|
||||
'occurredAtMs',
|
||||
'proveRetirement',
|
||||
'publisher',
|
||||
'trustRoot',
|
||||
...optional,
|
||||
],
|
||||
'retirement options',
|
||||
);
|
||||
const expectedGeneration = integer(
|
||||
value.expectedGeneration,
|
||||
1,
|
||||
'expectedGeneration',
|
||||
);
|
||||
const occurredAtMs = integer(value.occurredAtMs, 0, 'occurredAtMs');
|
||||
const publisher = boundedIdentity(value.publisher, 'publisher');
|
||||
const keyId = boundedIdentity(value.keyId, 'keyId');
|
||||
if (
|
||||
typeof value.mutationId !== 'string' ||
|
||||
!MUTATION_ID_PATTERN.test(value.mutationId) ||
|
||||
typeof value.proveRetirement !== 'function' ||
|
||||
[
|
||||
value.beforePublish,
|
||||
value.afterIntentPublished,
|
||||
value.afterReceiptPublished,
|
||||
value.afterSnapshotPublished,
|
||||
].some(
|
||||
(callback) => callback !== undefined && typeof callback !== 'function',
|
||||
)
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'retirement identity is invalid',
|
||||
);
|
||||
}
|
||||
let state = loadState(value.trustRoot);
|
||||
const previous = state.snapshots[expectedGeneration - 1];
|
||||
if (!previous) {
|
||||
throw new LocalPluginPackagePublisherTrustConflictError(
|
||||
'expected generation is stale',
|
||||
);
|
||||
}
|
||||
const intentMaterial = Object.freeze({
|
||||
schema: LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_RETIREMENT_INTENT_SCHEMA,
|
||||
publisher,
|
||||
keyId,
|
||||
expectedGeneration,
|
||||
previousTrustDigest: previous.trustDigest,
|
||||
mutationId: value.mutationId,
|
||||
occurredAtMs,
|
||||
});
|
||||
const requestedIntent = Object.freeze({
|
||||
...intentMaterial,
|
||||
intentDigest: digest(retirementIntentMaterial(intentMaterial)),
|
||||
});
|
||||
const identityDigest = retirementIdentityDigest(publisher, keyId);
|
||||
let existingIntent = state.retirementIntents.find(
|
||||
(intent) =>
|
||||
retirementIdentityDigest(intent.publisher, intent.keyId) ===
|
||||
identityDigest,
|
||||
);
|
||||
if (
|
||||
existingIntent &&
|
||||
existingIntent.intentDigest !== requestedIntent.intentDigest
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConflictError(
|
||||
'publisher key retirement identity was reused',
|
||||
);
|
||||
}
|
||||
let completed = state.snapshots.find(
|
||||
(snapshot) =>
|
||||
snapshot.mode === 'retire' &&
|
||||
snapshot.mutationId === requestedIntent.mutationId,
|
||||
);
|
||||
await value.beforePublish?.();
|
||||
state = loadState(value.trustRoot);
|
||||
existingIntent = state.retirementIntents.find(
|
||||
(intent) =>
|
||||
retirementIdentityDigest(intent.publisher, intent.keyId) ===
|
||||
identityDigest,
|
||||
);
|
||||
if (
|
||||
existingIntent &&
|
||||
existingIntent.intentDigest !== requestedIntent.intentDigest
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConflictError(
|
||||
'publisher key retirement identity was reused',
|
||||
);
|
||||
}
|
||||
completed = state.snapshots.find(
|
||||
(snapshot) =>
|
||||
snapshot.mode === 'retire' &&
|
||||
snapshot.mutationId === requestedIntent.mutationId,
|
||||
);
|
||||
if (completed) {
|
||||
if (state.pending?.snapshotDigest === completed.snapshotDigest) {
|
||||
promoteCurrent(state.root, completed);
|
||||
return Object.freeze({
|
||||
status: 'recovered',
|
||||
generation: completed.generation,
|
||||
keyCount: completed.trust.keys.length,
|
||||
trustDigest: completed.trustDigest,
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'existing',
|
||||
generation: completed.generation,
|
||||
keyCount: completed.trust.keys.length,
|
||||
trustDigest: completed.trustDigest,
|
||||
});
|
||||
}
|
||||
const current = state.current?.trust;
|
||||
const target = `${publisher}\0${keyId}`;
|
||||
if (
|
||||
state.pending ||
|
||||
state.pendingRevocation ||
|
||||
(state.pendingRetirement &&
|
||||
state.pendingRetirement.intentDigest !== requestedIntent.intentDigest) ||
|
||||
(state.committed?.generation ?? 0) !== expectedGeneration ||
|
||||
!current ||
|
||||
!keyMap(current).has(target)
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConflictError(
|
||||
'retirement does not match the current trust head',
|
||||
);
|
||||
}
|
||||
const remainingTrust = normalizeLocalPluginPackagePublisherTrustDocument({
|
||||
schema: LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SCHEMA,
|
||||
keys: current.keys.filter(
|
||||
(key) => `${key.publisher}\0${key.keyId}` !== target,
|
||||
),
|
||||
});
|
||||
if (activeKeyCount(remainingTrust, occurredAtMs) < 1) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'retirement must retain a currently active publisher key',
|
||||
);
|
||||
}
|
||||
if (!existingIntent) {
|
||||
if (
|
||||
state.retirementIntents.length >=
|
||||
MAX_LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_RETIREMENTS
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConflictError(
|
||||
'publisher key retirement capacity is exhausted',
|
||||
);
|
||||
}
|
||||
revalidateDirectory(state.root);
|
||||
publishImmutableDocument(
|
||||
state.root,
|
||||
`retirement-${identityDigest}.json`,
|
||||
`${JSON.stringify(requestedIntent)}\n`,
|
||||
normalizeRetirementIntent,
|
||||
);
|
||||
await value.afterIntentPublished?.();
|
||||
}
|
||||
state = loadState(value.trustRoot);
|
||||
if (
|
||||
state.pending ||
|
||||
state.pendingRevocation ||
|
||||
state.pendingRetirement?.intentDigest !== requestedIntent.intentDigest ||
|
||||
(state.committed?.generation ?? 0) !== expectedGeneration ||
|
||||
state.current?.digest !== previous.trustDigest
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConflictError(
|
||||
'trust state changed after retirement intent publication',
|
||||
);
|
||||
}
|
||||
let receipt = state.retirementReceipts.find(
|
||||
(candidate) => candidate.mutationId === requestedIntent.mutationId,
|
||||
);
|
||||
if (!receipt) {
|
||||
const proofValue = await value.proveRetirement();
|
||||
const proof = dataRecord(proofValue, 'retirement proof');
|
||||
exactKeys(
|
||||
proof,
|
||||
[
|
||||
'bundleCount',
|
||||
'catalogEntryCount',
|
||||
'matchingEntryCount',
|
||||
'unresolvedTransactions',
|
||||
],
|
||||
'retirement proof',
|
||||
);
|
||||
const catalogEntryCount = integer(
|
||||
proof.catalogEntryCount,
|
||||
0,
|
||||
'retirement catalogEntryCount',
|
||||
);
|
||||
const bundleCount = integer(proof.bundleCount, 0, 'retirement bundleCount');
|
||||
const matchingEntryCount = integer(
|
||||
proof.matchingEntryCount,
|
||||
0,
|
||||
'retirement matchingEntryCount',
|
||||
);
|
||||
const unresolvedTransactions = integer(
|
||||
proof.unresolvedTransactions,
|
||||
0,
|
||||
'retirement unresolvedTransactions',
|
||||
);
|
||||
if (matchingEntryCount !== 0 || unresolvedTransactions !== 0) {
|
||||
throw new LocalPluginPackagePublisherTrustConflictError(
|
||||
'catalog signer coverage or transactions still block retirement',
|
||||
);
|
||||
}
|
||||
const receiptMaterial = Object.freeze({
|
||||
schema: LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_RETIREMENT_RECEIPT_SCHEMA,
|
||||
publisher,
|
||||
keyId,
|
||||
expectedGeneration,
|
||||
mutationId: value.mutationId,
|
||||
intentDigest: requestedIntent.intentDigest,
|
||||
catalogEntryCount,
|
||||
bundleCount,
|
||||
matchingEntryCount: 0 as const,
|
||||
unresolvedTransactions: 0 as const,
|
||||
occurredAtMs,
|
||||
});
|
||||
receipt = Object.freeze({
|
||||
...receiptMaterial,
|
||||
receiptDigest: digest(retirementReceiptMaterial(receiptMaterial)),
|
||||
});
|
||||
publishImmutableDocument(
|
||||
state.root,
|
||||
`retirement-receipt-${identityDigest}.json`,
|
||||
`${JSON.stringify(receipt)}\n`,
|
||||
normalizeRetirementReceipt,
|
||||
);
|
||||
await value.afterReceiptPublished?.();
|
||||
}
|
||||
const requestedSnapshot = createSnapshot(
|
||||
'retire',
|
||||
expectedGeneration,
|
||||
value.mutationId,
|
||||
occurredAtMs,
|
||||
remainingTrust,
|
||||
previous,
|
||||
);
|
||||
state = loadState(value.trustRoot);
|
||||
if (
|
||||
state.pending ||
|
||||
state.pendingRevocation ||
|
||||
state.pendingRetirement?.intentDigest !== requestedIntent.intentDigest ||
|
||||
!state.retirementReceipts.some(
|
||||
(candidate) => candidate.receiptDigest === receipt.receiptDigest,
|
||||
) ||
|
||||
(state.committed?.generation ?? 0) !== expectedGeneration ||
|
||||
state.current?.digest !== previous.trustDigest
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConflictError(
|
||||
'trust state changed before retirement publication',
|
||||
);
|
||||
}
|
||||
revalidateDirectory(state.root);
|
||||
publishSnapshot(state.root, requestedSnapshot);
|
||||
await value.afterSnapshotPublished?.();
|
||||
promoteCurrent(state.root, requestedSnapshot);
|
||||
return Object.freeze({
|
||||
status: existingIntent ? 'recovered' : 'published',
|
||||
generation: requestedSnapshot.generation,
|
||||
keyCount: requestedSnapshot.trust.keys.length,
|
||||
trustDigest: requestedSnapshot.trustDigest,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
import {
|
||||
LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_REVOCATION_PROPOSAL_SCHEMA,
|
||||
LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_REVOCATION_RECEIPT_SCHEMA,
|
||||
LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SCHEMA,
|
||||
LocalPluginPackagePublisherTrustConfigurationError,
|
||||
LocalPluginPackagePublisherTrustConflictError,
|
||||
MAX_LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_REVOCATIONS,
|
||||
type ConfirmLocalPluginPackagePublisherKeyRevocationOptions,
|
||||
type ConfirmedLocalPluginPackagePublisherKeyRevocation,
|
||||
type ProposedLocalPluginPackagePublisherKeyRevocation,
|
||||
type ProposeLocalPluginPackagePublisherKeyRevocationOptions,
|
||||
} from '../contracts';
|
||||
import {
|
||||
DIGEST_PATTERN,
|
||||
MUTATION_ID_PATTERN,
|
||||
createSnapshot,
|
||||
dataRecord,
|
||||
exactKeys,
|
||||
integer,
|
||||
keyMap,
|
||||
digest,
|
||||
boundedIdentity,
|
||||
localPluginPackagePublisherKeyRevocationImpactDigest,
|
||||
normalizeLocalPluginPackagePublisherTrustDocument,
|
||||
normalizeRevocationProposal,
|
||||
normalizeRevocationReceipt,
|
||||
retirementIdentityDigest,
|
||||
lockDigests,
|
||||
revocationProposalMaterial,
|
||||
revocationReceiptMaterial,
|
||||
} from '../codec';
|
||||
import {
|
||||
revalidateDirectory,
|
||||
loadState,
|
||||
publishSnapshot,
|
||||
publishImmutableDocument,
|
||||
promoteCurrent,
|
||||
} from '../privateFilesystemStore';
|
||||
|
||||
export async function proposeLocalPluginPackagePublisherKeyRevocation(
|
||||
value: ProposeLocalPluginPackagePublisherKeyRevocationOptions,
|
||||
): Promise<Readonly<ProposedLocalPluginPackagePublisherKeyRevocation>> {
|
||||
const options = dataRecord(value, 'revocation proposal options');
|
||||
const optional = Object.hasOwn(options, 'beforePublish')
|
||||
? ['beforePublish']
|
||||
: [];
|
||||
exactKeys(
|
||||
options,
|
||||
[
|
||||
'expectedGeneration',
|
||||
'impact',
|
||||
'keyId',
|
||||
'mutationId',
|
||||
'occurredAtMs',
|
||||
'proposerSubjectId',
|
||||
'publisher',
|
||||
'trustRoot',
|
||||
...optional,
|
||||
],
|
||||
'revocation proposal options',
|
||||
);
|
||||
const expectedGeneration = integer(
|
||||
value.expectedGeneration,
|
||||
1,
|
||||
'expectedGeneration',
|
||||
);
|
||||
const occurredAtMs = integer(value.occurredAtMs, 0, 'occurredAtMs');
|
||||
const publisher = boundedIdentity(value.publisher, 'publisher');
|
||||
const keyId = boundedIdentity(value.keyId, 'keyId');
|
||||
const proposerSubjectId = boundedIdentity(
|
||||
value.proposerSubjectId,
|
||||
'proposerSubjectId',
|
||||
);
|
||||
if (
|
||||
typeof value.mutationId !== 'string' ||
|
||||
!MUTATION_ID_PATTERN.test(value.mutationId) ||
|
||||
(value.beforePublish !== undefined &&
|
||||
typeof value.beforePublish !== 'function')
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'revocation proposal identity is invalid',
|
||||
);
|
||||
}
|
||||
const impactValue = dataRecord(value.impact, 'revocation impact');
|
||||
exactKeys(
|
||||
impactValue,
|
||||
[
|
||||
'bundleCount',
|
||||
'catalogEntryCount',
|
||||
'impactDigest',
|
||||
'impactedLockDigests',
|
||||
'matchingEntryCount',
|
||||
'unresolvedTransactions',
|
||||
],
|
||||
'revocation impact',
|
||||
);
|
||||
const impactedLockDigests = lockDigests(
|
||||
value.impact.impactedLockDigests,
|
||||
'revocation impacted lock digests',
|
||||
);
|
||||
const catalogEntryCount = integer(
|
||||
value.impact.catalogEntryCount,
|
||||
0,
|
||||
'revocation catalogEntryCount',
|
||||
);
|
||||
const bundleCount = integer(
|
||||
value.impact.bundleCount,
|
||||
0,
|
||||
'revocation bundleCount',
|
||||
);
|
||||
const matchingEntryCount = integer(
|
||||
value.impact.matchingEntryCount,
|
||||
0,
|
||||
'revocation matchingEntryCount',
|
||||
);
|
||||
const unresolvedTransactions = integer(
|
||||
value.impact.unresolvedTransactions,
|
||||
0,
|
||||
'revocation unresolvedTransactions',
|
||||
);
|
||||
const impactDigest = localPluginPackagePublisherKeyRevocationImpactDigest({
|
||||
publisher,
|
||||
keyId,
|
||||
catalogEntryCount,
|
||||
bundleCount,
|
||||
matchingEntryCount,
|
||||
unresolvedTransactions,
|
||||
impactedLockDigests,
|
||||
});
|
||||
if (value.impact.impactDigest !== impactDigest) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'revocation impact digest is invalid',
|
||||
);
|
||||
}
|
||||
let state = loadState(value.trustRoot);
|
||||
const previous = state.snapshots[expectedGeneration - 1];
|
||||
if (!previous) {
|
||||
throw new LocalPluginPackagePublisherTrustConflictError(
|
||||
'expected generation is stale',
|
||||
);
|
||||
}
|
||||
const material = Object.freeze({
|
||||
schema: LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_REVOCATION_PROPOSAL_SCHEMA,
|
||||
publisher,
|
||||
keyId,
|
||||
expectedGeneration,
|
||||
previousTrustDigest: previous.trustDigest,
|
||||
mutationId: value.mutationId,
|
||||
occurredAtMs,
|
||||
proposerSubjectId,
|
||||
catalogEntryCount,
|
||||
bundleCount,
|
||||
matchingEntryCount,
|
||||
unresolvedTransactions,
|
||||
impactedLockDigests,
|
||||
impactDigest,
|
||||
});
|
||||
const requested = Object.freeze({
|
||||
...material,
|
||||
proposalDigest: digest(revocationProposalMaterial(material)),
|
||||
});
|
||||
const identityDigest = retirementIdentityDigest(publisher, keyId);
|
||||
let existing = state.revocationProposals.find(
|
||||
(proposal) =>
|
||||
retirementIdentityDigest(proposal.publisher, proposal.keyId) ===
|
||||
identityDigest,
|
||||
);
|
||||
if (existing && existing.proposalDigest !== requested.proposalDigest) {
|
||||
throw new LocalPluginPackagePublisherTrustConflictError(
|
||||
'publisher key revocation identity was reused',
|
||||
);
|
||||
}
|
||||
await value.beforePublish?.();
|
||||
state = loadState(value.trustRoot);
|
||||
existing = state.revocationProposals.find(
|
||||
(proposal) =>
|
||||
retirementIdentityDigest(proposal.publisher, proposal.keyId) ===
|
||||
identityDigest,
|
||||
);
|
||||
if (existing) {
|
||||
if (existing.proposalDigest !== requested.proposalDigest) {
|
||||
throw new LocalPluginPackagePublisherTrustConflictError(
|
||||
'publisher key revocation identity was reused',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'existing',
|
||||
generation: existing.expectedGeneration,
|
||||
proposalDigest: existing.proposalDigest,
|
||||
impactDigest: existing.impactDigest,
|
||||
matchingEntryCount: existing.matchingEntryCount,
|
||||
runtimeAction: 'stop_required',
|
||||
});
|
||||
}
|
||||
const current = state.current?.trust;
|
||||
const target = `${publisher}\0${keyId}`;
|
||||
if (
|
||||
state.pending ||
|
||||
state.pendingRetirement ||
|
||||
state.pendingRevocation ||
|
||||
(state.committed?.generation ?? 0) !== expectedGeneration ||
|
||||
state.current?.digest !== previous.trustDigest ||
|
||||
!current ||
|
||||
!keyMap(current).has(target)
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConflictError(
|
||||
'revocation proposal does not match the current trust head',
|
||||
);
|
||||
}
|
||||
const remainingTrust = normalizeLocalPluginPackagePublisherTrustDocument({
|
||||
schema: LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SCHEMA,
|
||||
keys: current.keys.filter(
|
||||
(key) => `${key.publisher}\0${key.keyId}` !== target,
|
||||
),
|
||||
});
|
||||
if (
|
||||
state.revocationProposals.length >=
|
||||
MAX_LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_REVOCATIONS
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConflictError(
|
||||
'publisher key revocation capacity is exhausted',
|
||||
);
|
||||
}
|
||||
revalidateDirectory(state.root);
|
||||
publishImmutableDocument(
|
||||
state.root,
|
||||
`revocation-${identityDigest}.json`,
|
||||
`${JSON.stringify(requested)}\n`,
|
||||
normalizeRevocationProposal,
|
||||
);
|
||||
return Object.freeze({
|
||||
status: 'proposed',
|
||||
generation: expectedGeneration,
|
||||
proposalDigest: requested.proposalDigest,
|
||||
impactDigest,
|
||||
matchingEntryCount,
|
||||
runtimeAction: 'stop_required',
|
||||
});
|
||||
}
|
||||
|
||||
export async function confirmLocalPluginPackagePublisherKeyRevocation(
|
||||
value: ConfirmLocalPluginPackagePublisherKeyRevocationOptions,
|
||||
): Promise<Readonly<ConfirmedLocalPluginPackagePublisherKeyRevocation>> {
|
||||
const options = dataRecord(value, 'revocation confirmation options');
|
||||
const optional = [
|
||||
...(Object.hasOwn(options, 'beforePublish') ? ['beforePublish'] : []),
|
||||
...(Object.hasOwn(options, 'afterReceiptPublished')
|
||||
? ['afterReceiptPublished']
|
||||
: []),
|
||||
...(Object.hasOwn(options, 'afterSnapshotPublished')
|
||||
? ['afterSnapshotPublished']
|
||||
: []),
|
||||
];
|
||||
exactKeys(
|
||||
options,
|
||||
[
|
||||
'authorizationMode',
|
||||
'confirmedAtMs',
|
||||
'confirmerSubjectId',
|
||||
'confirmAuthorization',
|
||||
'expectedGeneration',
|
||||
'expectedImpactDigest',
|
||||
'keyId',
|
||||
'mutationId',
|
||||
'proposerSubjectId',
|
||||
'publisher',
|
||||
'reasonCode',
|
||||
'trustRoot',
|
||||
...optional,
|
||||
],
|
||||
'revocation confirmation options',
|
||||
);
|
||||
const expectedGeneration = integer(
|
||||
value.expectedGeneration,
|
||||
1,
|
||||
'expectedGeneration',
|
||||
);
|
||||
const confirmedAtMs = integer(value.confirmedAtMs, 0, 'confirmedAtMs');
|
||||
const publisher = boundedIdentity(value.publisher, 'publisher');
|
||||
const keyId = boundedIdentity(value.keyId, 'keyId');
|
||||
const proposerSubjectId = boundedIdentity(
|
||||
value.proposerSubjectId,
|
||||
'proposerSubjectId',
|
||||
);
|
||||
const confirmerSubjectId = boundedIdentity(
|
||||
value.confirmerSubjectId,
|
||||
'confirmerSubjectId',
|
||||
);
|
||||
if (
|
||||
typeof value.mutationId !== 'string' ||
|
||||
!MUTATION_ID_PATTERN.test(value.mutationId) ||
|
||||
typeof value.expectedImpactDigest !== 'string' ||
|
||||
!DIGEST_PATTERN.test(value.expectedImpactDigest) ||
|
||||
(value.authorizationMode !== 'dual_control' &&
|
||||
value.authorizationMode !== 'break_glass') ||
|
||||
(value.reasonCode !== 'suspected_key_compromise' &&
|
||||
value.reasonCode !== 'confirmed_key_compromise') ||
|
||||
typeof value.confirmAuthorization !== 'function' ||
|
||||
[
|
||||
value.beforePublish,
|
||||
value.afterReceiptPublished,
|
||||
value.afterSnapshotPublished,
|
||||
].some(
|
||||
(callback) => callback !== undefined && typeof callback !== 'function',
|
||||
)
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'revocation confirmation identity is invalid',
|
||||
);
|
||||
}
|
||||
if (
|
||||
value.authorizationMode === 'dual_control' &&
|
||||
proposerSubjectId === confirmerSubjectId
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'dual-control revocation requires a distinct Owner',
|
||||
);
|
||||
}
|
||||
let state = loadState(value.trustRoot);
|
||||
const identityDigest = retirementIdentityDigest(publisher, keyId);
|
||||
const proposal = state.revocationProposals.find(
|
||||
(candidate) =>
|
||||
retirementIdentityDigest(candidate.publisher, candidate.keyId) ===
|
||||
identityDigest,
|
||||
);
|
||||
if (
|
||||
!proposal ||
|
||||
proposal.expectedGeneration !== expectedGeneration ||
|
||||
proposal.mutationId !== value.mutationId ||
|
||||
proposal.proposerSubjectId !== proposerSubjectId ||
|
||||
proposal.impactDigest !== value.expectedImpactDigest
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConflictError(
|
||||
'revocation confirmation does not match its proposal',
|
||||
);
|
||||
}
|
||||
let completed = state.snapshots.find(
|
||||
(snapshot) =>
|
||||
snapshot.mode === 'revoke' && snapshot.mutationId === proposal.mutationId,
|
||||
);
|
||||
await value.confirmAuthorization();
|
||||
await value.beforePublish?.();
|
||||
state = loadState(value.trustRoot);
|
||||
completed = state.snapshots.find(
|
||||
(snapshot) =>
|
||||
snapshot.mode === 'revoke' && snapshot.mutationId === proposal.mutationId,
|
||||
);
|
||||
if (completed) {
|
||||
const receipt = state.revocationReceipts.find(
|
||||
(candidate) => candidate.mutationId === proposal.mutationId,
|
||||
)!;
|
||||
if (
|
||||
receipt.confirmerSubjectId !== confirmerSubjectId ||
|
||||
receipt.authorizationMode !== value.authorizationMode ||
|
||||
receipt.reasonCode !== value.reasonCode ||
|
||||
receipt.confirmedAtMs !== confirmedAtMs
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConflictError(
|
||||
'revocation confirmation identity was reused',
|
||||
);
|
||||
}
|
||||
await value.afterReceiptPublished?.(receipt);
|
||||
if (state.pending?.snapshotDigest === completed.snapshotDigest) {
|
||||
promoteCurrent(state.root, completed);
|
||||
return Object.freeze({
|
||||
status: 'recovered',
|
||||
generation: completed.generation,
|
||||
keyCount: completed.trust.keys.length,
|
||||
trustDigest: completed.trustDigest,
|
||||
authorizationMode: receipt.authorizationMode,
|
||||
quarantinedLockCount: receipt.impactedLockDigests.length,
|
||||
runtimeAction: 'restart_required',
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
status: 'existing',
|
||||
generation: completed.generation,
|
||||
keyCount: completed.trust.keys.length,
|
||||
trustDigest: completed.trustDigest,
|
||||
authorizationMode: receipt.authorizationMode,
|
||||
quarantinedLockCount: receipt.impactedLockDigests.length,
|
||||
runtimeAction: 'restart_required',
|
||||
});
|
||||
}
|
||||
const previous = state.snapshots[expectedGeneration - 1];
|
||||
const current = state.current?.trust;
|
||||
const target = `${publisher}\0${keyId}`;
|
||||
if (
|
||||
state.pending ||
|
||||
state.pendingRetirement ||
|
||||
state.pendingRevocation?.proposalDigest !== proposal.proposalDigest ||
|
||||
!previous ||
|
||||
previous.trustDigest !== proposal.previousTrustDigest ||
|
||||
(state.committed?.generation ?? 0) !== expectedGeneration ||
|
||||
state.current?.digest !== proposal.previousTrustDigest ||
|
||||
!current ||
|
||||
!keyMap(current).has(target)
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConflictError(
|
||||
'trust state changed before revocation confirmation',
|
||||
);
|
||||
}
|
||||
const remainingTrust = normalizeLocalPluginPackagePublisherTrustDocument({
|
||||
schema: LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_SCHEMA,
|
||||
keys: current.keys.filter(
|
||||
(key) => `${key.publisher}\0${key.keyId}` !== target,
|
||||
),
|
||||
});
|
||||
const receiptMaterial = Object.freeze({
|
||||
schema: LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_REVOCATION_RECEIPT_SCHEMA,
|
||||
publisher,
|
||||
keyId,
|
||||
expectedGeneration,
|
||||
mutationId: value.mutationId,
|
||||
proposalDigest: proposal.proposalDigest,
|
||||
proposerSubjectId,
|
||||
confirmerSubjectId,
|
||||
authorizationMode: value.authorizationMode,
|
||||
reasonCode: value.reasonCode,
|
||||
confirmedAtMs,
|
||||
impactDigest: proposal.impactDigest,
|
||||
impactedLockDigests: proposal.impactedLockDigests,
|
||||
});
|
||||
const requestedReceipt = Object.freeze({
|
||||
...receiptMaterial,
|
||||
receiptDigest: digest(revocationReceiptMaterial(receiptMaterial)),
|
||||
});
|
||||
const existingReceipt = state.revocationReceipts.find(
|
||||
(candidate) => candidate.mutationId === proposal.mutationId,
|
||||
);
|
||||
if (
|
||||
existingReceipt &&
|
||||
existingReceipt.receiptDigest !== requestedReceipt.receiptDigest
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConflictError(
|
||||
'revocation receipt identity was reused',
|
||||
);
|
||||
}
|
||||
if (!existingReceipt) {
|
||||
publishImmutableDocument(
|
||||
state.root,
|
||||
`revocation-receipt-${identityDigest}.json`,
|
||||
`${JSON.stringify(requestedReceipt)}\n`,
|
||||
normalizeRevocationReceipt,
|
||||
);
|
||||
}
|
||||
await value.afterReceiptPublished?.(requestedReceipt);
|
||||
const requestedSnapshot = createSnapshot(
|
||||
'revoke',
|
||||
expectedGeneration,
|
||||
value.mutationId,
|
||||
confirmedAtMs,
|
||||
remainingTrust,
|
||||
previous,
|
||||
);
|
||||
state = loadState(value.trustRoot);
|
||||
if (
|
||||
state.pending ||
|
||||
state.pendingRetirement ||
|
||||
state.pendingRevocation?.proposalDigest !== proposal.proposalDigest ||
|
||||
!state.revocationReceipts.some(
|
||||
(candidate) => candidate.receiptDigest === requestedReceipt.receiptDigest,
|
||||
) ||
|
||||
(state.committed?.generation ?? 0) !== expectedGeneration ||
|
||||
state.current?.digest !== proposal.previousTrustDigest
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConflictError(
|
||||
'trust state changed before revocation publication',
|
||||
);
|
||||
}
|
||||
revalidateDirectory(state.root);
|
||||
publishSnapshot(state.root, requestedSnapshot);
|
||||
await value.afterSnapshotPublished?.();
|
||||
promoteCurrent(state.root, requestedSnapshot);
|
||||
return Object.freeze({
|
||||
status: existingReceipt ? 'recovered' : 'published',
|
||||
generation: requestedSnapshot.generation,
|
||||
keyCount: requestedSnapshot.trust.keys.length,
|
||||
trustDigest: requestedSnapshot.trustDigest,
|
||||
authorizationMode: requestedReceipt.authorizationMode,
|
||||
quarantinedLockCount: requestedReceipt.impactedLockDigests.length,
|
||||
runtimeAction: 'restart_required',
|
||||
});
|
||||
}
|
||||
+841
@@ -0,0 +1,841 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import fs, { constants } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
LocalPluginPackagePublisherTrustConfigurationError,
|
||||
LocalPluginPackagePublisherTrustConflictError,
|
||||
MAX_LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_GENERATIONS,
|
||||
MAX_LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_RETIREMENTS,
|
||||
MAX_LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_REVOCATIONS,
|
||||
type LocalPluginPackagePublisherTrustDocument,
|
||||
} from './contracts';
|
||||
import {
|
||||
activeKeyCount,
|
||||
digest,
|
||||
keyMap,
|
||||
normalizeLocalPluginPackagePublisherTrustDocument,
|
||||
canonicalTrust,
|
||||
retirementIdentityDigest,
|
||||
normalizeRetirementIntent,
|
||||
normalizeRetirementReceipt,
|
||||
normalizeRevocationProposal,
|
||||
normalizeRevocationReceipt,
|
||||
normalizeSnapshot,
|
||||
sameSnapshot,
|
||||
snapshotName,
|
||||
type TrustSnapshot,
|
||||
type RetirementIntent,
|
||||
type RetirementReceipt,
|
||||
type RevocationProposal,
|
||||
type RevocationReceipt,
|
||||
} from './codec';
|
||||
const CURRENT_FILE = 'current.json';
|
||||
const SNAPSHOT_PATTERN = /^([0-9]{20})\.json$/;
|
||||
export const TEMPORARY_PATTERN = /^\.qlpkg-trust-[0-9a-f]{32}\.tmp$/;
|
||||
const RETIREMENT_INTENT_PATTERN = /^retirement-([0-9a-f]{64})\.json$/;
|
||||
const RETIREMENT_RECEIPT_PATTERN = /^retirement-receipt-([0-9a-f]{64})\.json$/;
|
||||
const REVOCATION_PROPOSAL_PATTERN = /^revocation-([0-9a-f]{64})\.json$/;
|
||||
const REVOCATION_RECEIPT_PATTERN = /^revocation-receipt-([0-9a-f]{64})\.json$/;
|
||||
const MAX_ROOT_ENTRIES =
|
||||
MAX_LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_GENERATIONS * 2 +
|
||||
MAX_LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_RETIREMENTS * 2 +
|
||||
MAX_LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_REVOCATIONS * 2 +
|
||||
1;
|
||||
const MAX_FILE_BYTES = 256 * 1024;
|
||||
const MAX_PATH_BYTES = 4_096;
|
||||
|
||||
export interface DirectoryIdentity {
|
||||
readonly path: string;
|
||||
readonly uid: number;
|
||||
readonly device: bigint;
|
||||
readonly inode: bigint;
|
||||
readonly entries: readonly string[];
|
||||
}
|
||||
|
||||
export interface LoadedState {
|
||||
readonly root: DirectoryIdentity;
|
||||
readonly snapshots: readonly Readonly<TrustSnapshot>[];
|
||||
readonly current:
|
||||
| Readonly<{
|
||||
trust: Readonly<LocalPluginPackagePublisherTrustDocument>;
|
||||
digest: string;
|
||||
}>
|
||||
| undefined;
|
||||
readonly committed: Readonly<TrustSnapshot> | undefined;
|
||||
readonly pending: Readonly<TrustSnapshot> | undefined;
|
||||
readonly retirementIntents: readonly Readonly<RetirementIntent>[];
|
||||
readonly retirementReceipts: readonly Readonly<RetirementReceipt>[];
|
||||
readonly pendingRetirement: Readonly<RetirementIntent> | undefined;
|
||||
readonly revocationProposals: readonly Readonly<RevocationProposal>[];
|
||||
readonly revocationReceipts: readonly Readonly<RevocationReceipt>[];
|
||||
readonly pendingRevocation: Readonly<RevocationProposal> | undefined;
|
||||
}
|
||||
|
||||
export function absolutePath(value: unknown, label: string): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length === 0 ||
|
||||
Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES ||
|
||||
value.includes('\0') ||
|
||||
!path.isAbsolute(value) ||
|
||||
path.normalize(value) !== value ||
|
||||
path.parse(value).root === value
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
`${label} must be a normalized bounded absolute non-root path`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function currentUid(): number {
|
||||
if (
|
||||
typeof process.getuid !== 'function' ||
|
||||
typeof process.geteuid !== 'function' ||
|
||||
process.getuid() !== process.geteuid()
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'real and effective POSIX users must match',
|
||||
);
|
||||
}
|
||||
return process.getuid();
|
||||
}
|
||||
|
||||
export function directory(candidate: unknown): DirectoryIdentity {
|
||||
const root = absolutePath(candidate, 'trustRoot');
|
||||
const uid = currentUid();
|
||||
let stat: fs.BigIntStats;
|
||||
try {
|
||||
stat = fs.lstatSync(root, { bigint: true });
|
||||
} catch (error) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'trust root is unavailable',
|
||||
error,
|
||||
);
|
||||
}
|
||||
if (
|
||||
!stat.isDirectory() ||
|
||||
stat.isSymbolicLink() ||
|
||||
Number(stat.uid) !== uid ||
|
||||
(Number(stat.mode) & 0o777) !== 0o700 ||
|
||||
fs.realpathSync(root) !== root
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'trust root must be an owner-only non-symlink directory',
|
||||
);
|
||||
}
|
||||
const entries = fs.readdirSync(root).sort();
|
||||
const snapshots = entries.filter((entry) => SNAPSHOT_PATTERN.test(entry));
|
||||
const temporary = entries.filter((entry) => TEMPORARY_PATTERN.test(entry));
|
||||
const retirementIntents = entries.filter((entry) =>
|
||||
RETIREMENT_INTENT_PATTERN.test(entry),
|
||||
);
|
||||
const retirementReceipts = entries.filter((entry) =>
|
||||
RETIREMENT_RECEIPT_PATTERN.test(entry),
|
||||
);
|
||||
const revocationProposals = entries.filter((entry) =>
|
||||
REVOCATION_PROPOSAL_PATTERN.test(entry),
|
||||
);
|
||||
const revocationReceipts = entries.filter((entry) =>
|
||||
REVOCATION_RECEIPT_PATTERN.test(entry),
|
||||
);
|
||||
if (
|
||||
entries.length > MAX_ROOT_ENTRIES ||
|
||||
snapshots.length > MAX_LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_GENERATIONS ||
|
||||
temporary.length > MAX_LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_GENERATIONS ||
|
||||
retirementIntents.length >
|
||||
MAX_LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_RETIREMENTS ||
|
||||
retirementReceipts.length >
|
||||
MAX_LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_RETIREMENTS ||
|
||||
revocationProposals.length >
|
||||
MAX_LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_REVOCATIONS ||
|
||||
revocationReceipts.length >
|
||||
MAX_LOCAL_PLUGIN_PACKAGE_PUBLISHER_TRUST_REVOCATIONS ||
|
||||
entries.some(
|
||||
(entry) =>
|
||||
entry !== CURRENT_FILE &&
|
||||
!SNAPSHOT_PATTERN.test(entry) &&
|
||||
!TEMPORARY_PATTERN.test(entry) &&
|
||||
!RETIREMENT_INTENT_PATTERN.test(entry) &&
|
||||
!RETIREMENT_RECEIPT_PATTERN.test(entry) &&
|
||||
!REVOCATION_PROPOSAL_PATTERN.test(entry) &&
|
||||
!REVOCATION_RECEIPT_PATTERN.test(entry),
|
||||
)
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'trust root contains unbounded or unknown entries',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
path: root,
|
||||
uid,
|
||||
device: stat.dev,
|
||||
inode: stat.ino,
|
||||
entries: Object.freeze(entries),
|
||||
});
|
||||
}
|
||||
|
||||
export function revalidateDirectory(identity: DirectoryIdentity): void {
|
||||
const stat = fs.lstatSync(identity.path, { bigint: true });
|
||||
if (
|
||||
!stat.isDirectory() ||
|
||||
stat.isSymbolicLink() ||
|
||||
Number(stat.uid) !== identity.uid ||
|
||||
(Number(stat.mode) & 0o777) !== 0o700 ||
|
||||
stat.dev !== identity.device ||
|
||||
stat.ino !== identity.inode ||
|
||||
fs.realpathSync(identity.path) !== identity.path
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'trust root identity changed',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function syncDirectory(directoryPath: string): void {
|
||||
const descriptor = fs.openSync(directoryPath, constants.O_RDONLY);
|
||||
try {
|
||||
fs.fsyncSync(descriptor);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
export function readPrivateJson(filePath: string, uid: number): unknown {
|
||||
let descriptor: number | undefined;
|
||||
try {
|
||||
descriptor = fs.openSync(
|
||||
filePath,
|
||||
constants.O_RDONLY |
|
||||
(typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0),
|
||||
);
|
||||
const before = fs.fstatSync(descriptor, { bigint: true });
|
||||
if (
|
||||
!before.isFile() ||
|
||||
before.isSymbolicLink() ||
|
||||
Number(before.uid) !== uid ||
|
||||
(Number(before.mode) & 0o777) !== 0o600 ||
|
||||
before.size < 1n ||
|
||||
before.size > BigInt(MAX_FILE_BYTES)
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'trust file must be a bounded owner-only regular file',
|
||||
);
|
||||
}
|
||||
const buffer = Buffer.alloc(Number(before.size) + 1);
|
||||
const bytes = fs.readSync(descriptor, buffer, 0, buffer.length, 0);
|
||||
const after = fs.fstatSync(descriptor, { bigint: true });
|
||||
if (
|
||||
bytes !== Number(before.size) ||
|
||||
after.dev !== before.dev ||
|
||||
after.ino !== before.ino ||
|
||||
after.size !== before.size ||
|
||||
Number(after.uid) !== uid ||
|
||||
(Number(after.mode) & 0o777) !== 0o600
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'trust file changed while being read',
|
||||
);
|
||||
}
|
||||
const text = buffer.subarray(0, bytes).toString('utf8');
|
||||
if (!Buffer.from(text, 'utf8').equals(buffer.subarray(0, bytes))) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'trust file must contain strict UTF-8',
|
||||
);
|
||||
}
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch (error) {
|
||||
if (error instanceof LocalPluginPackagePublisherTrustConfigurationError) {
|
||||
throw error;
|
||||
}
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'trust file cannot be read',
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
export function loadRetirements(
|
||||
root: DirectoryIdentity,
|
||||
snapshots: readonly Readonly<TrustSnapshot>[],
|
||||
): Readonly<{
|
||||
intents: readonly Readonly<RetirementIntent>[];
|
||||
receipts: readonly Readonly<RetirementReceipt>[];
|
||||
pending: Readonly<RetirementIntent> | undefined;
|
||||
}> {
|
||||
const intents = root.entries
|
||||
.filter((entry) => RETIREMENT_INTENT_PATTERN.test(entry))
|
||||
.map((entry) => {
|
||||
const intent = normalizeRetirementIntent(
|
||||
readPrivateJson(path.join(root.path, entry), root.uid),
|
||||
);
|
||||
if (
|
||||
entry !==
|
||||
`retirement-${retirementIdentityDigest(
|
||||
intent.publisher,
|
||||
intent.keyId,
|
||||
)}.json`
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'publisher key retirement intent filename is invalid',
|
||||
);
|
||||
}
|
||||
return intent;
|
||||
});
|
||||
const receipts = root.entries
|
||||
.filter((entry) => RETIREMENT_RECEIPT_PATTERN.test(entry))
|
||||
.map((entry) => {
|
||||
const receipt = normalizeRetirementReceipt(
|
||||
readPrivateJson(path.join(root.path, entry), root.uid),
|
||||
);
|
||||
if (
|
||||
entry !==
|
||||
`retirement-receipt-${retirementIdentityDigest(
|
||||
receipt.publisher,
|
||||
receipt.keyId,
|
||||
)}.json`
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'publisher key retirement receipt filename is invalid',
|
||||
);
|
||||
}
|
||||
return receipt;
|
||||
});
|
||||
const intentByMutation = new Map(
|
||||
intents.map((intent) => [intent.mutationId, intent]),
|
||||
);
|
||||
const receiptByMutation = new Map(
|
||||
receipts.map((receipt) => [receipt.mutationId, receipt]),
|
||||
);
|
||||
if (
|
||||
intentByMutation.size !== intents.length ||
|
||||
receiptByMutation.size !== receipts.length
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'publisher key retirement mutation identity is duplicated',
|
||||
);
|
||||
}
|
||||
for (const receipt of receipts) {
|
||||
const intent = intentByMutation.get(receipt.mutationId);
|
||||
if (
|
||||
!intent ||
|
||||
receipt.publisher !== intent.publisher ||
|
||||
receipt.keyId !== intent.keyId ||
|
||||
receipt.expectedGeneration !== intent.expectedGeneration ||
|
||||
receipt.intentDigest !== intent.intentDigest ||
|
||||
receipt.occurredAtMs !== intent.occurredAtMs
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'publisher key retirement receipt is not bound to its intent',
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const snapshot of snapshots.filter(({ mode }) => mode === 'retire')) {
|
||||
const intent = intentByMutation.get(snapshot.mutationId);
|
||||
const receipt = receiptByMutation.get(snapshot.mutationId);
|
||||
const previous = snapshots[snapshot.generation - 2];
|
||||
if (
|
||||
!intent ||
|
||||
!receipt ||
|
||||
!previous ||
|
||||
intent.expectedGeneration !== previous.generation ||
|
||||
intent.previousTrustDigest !== previous.trustDigest ||
|
||||
intent.occurredAtMs !== snapshot.occurredAtMs
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'publisher key retirement snapshot lacks its proof chain',
|
||||
);
|
||||
}
|
||||
const before = keyMap(previous.trust);
|
||||
const after = keyMap(snapshot.trust);
|
||||
const target = `${intent.publisher}\0${intent.keyId}`;
|
||||
if (
|
||||
!before.has(target) ||
|
||||
after.has(target) ||
|
||||
before.size !== after.size + 1 ||
|
||||
[...after].some(
|
||||
([identifier, definition]) =>
|
||||
JSON.stringify(before.get(identifier)) !== JSON.stringify(definition),
|
||||
) ||
|
||||
activeKeyCount(snapshot.trust, snapshot.occurredAtMs) < 1
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'publisher key retirement snapshot transition is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
const pending = intents.filter(
|
||||
(intent) =>
|
||||
!snapshots.some(
|
||||
(snapshot) =>
|
||||
snapshot.mode === 'retire' &&
|
||||
snapshot.mutationId === intent.mutationId,
|
||||
),
|
||||
);
|
||||
if (
|
||||
pending.length > 1 ||
|
||||
pending.some(
|
||||
(intent) =>
|
||||
intent.expectedGeneration !== snapshots.length ||
|
||||
intent.previousTrustDigest !== snapshots.at(-1)?.trustDigest,
|
||||
)
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'publisher key retirement intent does not match the trust head',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
intents: Object.freeze(intents),
|
||||
receipts: Object.freeze(receipts),
|
||||
pending: pending[0],
|
||||
});
|
||||
}
|
||||
|
||||
export function loadRevocations(
|
||||
root: DirectoryIdentity,
|
||||
snapshots: readonly Readonly<TrustSnapshot>[],
|
||||
): Readonly<{
|
||||
proposals: readonly Readonly<RevocationProposal>[];
|
||||
receipts: readonly Readonly<RevocationReceipt>[];
|
||||
pending: Readonly<RevocationProposal> | undefined;
|
||||
}> {
|
||||
const proposals = root.entries
|
||||
.filter((entry) => REVOCATION_PROPOSAL_PATTERN.test(entry))
|
||||
.map((entry) => {
|
||||
const proposal = normalizeRevocationProposal(
|
||||
readPrivateJson(path.join(root.path, entry), root.uid),
|
||||
);
|
||||
if (
|
||||
entry !==
|
||||
`revocation-${retirementIdentityDigest(
|
||||
proposal.publisher,
|
||||
proposal.keyId,
|
||||
)}.json`
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'publisher key revocation proposal filename is invalid',
|
||||
);
|
||||
}
|
||||
return proposal;
|
||||
});
|
||||
const receipts = root.entries
|
||||
.filter((entry) => REVOCATION_RECEIPT_PATTERN.test(entry))
|
||||
.map((entry) => {
|
||||
const receipt = normalizeRevocationReceipt(
|
||||
readPrivateJson(path.join(root.path, entry), root.uid),
|
||||
);
|
||||
if (
|
||||
entry !==
|
||||
`revocation-receipt-${retirementIdentityDigest(
|
||||
receipt.publisher,
|
||||
receipt.keyId,
|
||||
)}.json`
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'publisher key revocation receipt filename is invalid',
|
||||
);
|
||||
}
|
||||
return receipt;
|
||||
});
|
||||
const proposalByMutation = new Map(
|
||||
proposals.map((proposal) => [proposal.mutationId, proposal]),
|
||||
);
|
||||
const receiptByMutation = new Map(
|
||||
receipts.map((receipt) => [receipt.mutationId, receipt]),
|
||||
);
|
||||
if (
|
||||
proposalByMutation.size !== proposals.length ||
|
||||
receiptByMutation.size !== receipts.length
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'publisher key revocation mutation identity is duplicated',
|
||||
);
|
||||
}
|
||||
for (const receipt of receipts) {
|
||||
const proposal = proposalByMutation.get(receipt.mutationId);
|
||||
if (
|
||||
!proposal ||
|
||||
receipt.publisher !== proposal.publisher ||
|
||||
receipt.keyId !== proposal.keyId ||
|
||||
receipt.expectedGeneration !== proposal.expectedGeneration ||
|
||||
receipt.proposalDigest !== proposal.proposalDigest ||
|
||||
receipt.proposerSubjectId !== proposal.proposerSubjectId ||
|
||||
receipt.impactDigest !== proposal.impactDigest ||
|
||||
JSON.stringify(receipt.impactedLockDigests) !==
|
||||
JSON.stringify(proposal.impactedLockDigests) ||
|
||||
receipt.confirmedAtMs < proposal.occurredAtMs
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'publisher key revocation receipt is not bound to its proposal',
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const snapshot of snapshots.filter(({ mode }) => mode === 'revoke')) {
|
||||
const proposal = proposalByMutation.get(snapshot.mutationId);
|
||||
const receipt = receiptByMutation.get(snapshot.mutationId);
|
||||
const previous = snapshots[snapshot.generation - 2];
|
||||
if (
|
||||
!proposal ||
|
||||
!receipt ||
|
||||
!previous ||
|
||||
proposal.expectedGeneration !== previous.generation ||
|
||||
proposal.previousTrustDigest !== previous.trustDigest ||
|
||||
receipt.confirmedAtMs !== snapshot.occurredAtMs
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'publisher key revocation snapshot lacks its authorization chain',
|
||||
);
|
||||
}
|
||||
const before = keyMap(previous.trust);
|
||||
const after = keyMap(snapshot.trust);
|
||||
const target = `${proposal.publisher}\0${proposal.keyId}`;
|
||||
if (
|
||||
!before.has(target) ||
|
||||
after.has(target) ||
|
||||
before.size !== after.size + 1 ||
|
||||
[...after].some(
|
||||
([identifier, definition]) =>
|
||||
JSON.stringify(before.get(identifier)) !== JSON.stringify(definition),
|
||||
)
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'publisher key revocation snapshot transition is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
const pending = proposals.filter(
|
||||
(proposal) =>
|
||||
!snapshots.some(
|
||||
(snapshot) =>
|
||||
snapshot.mode === 'revoke' &&
|
||||
snapshot.mutationId === proposal.mutationId,
|
||||
),
|
||||
);
|
||||
if (
|
||||
pending.length > 1 ||
|
||||
pending.some(
|
||||
(proposal) =>
|
||||
proposal.expectedGeneration !== snapshots.length ||
|
||||
proposal.previousTrustDigest !== snapshots.at(-1)?.trustDigest,
|
||||
)
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'publisher key revocation proposal does not match the trust head',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
proposals: Object.freeze(proposals),
|
||||
receipts: Object.freeze(receipts),
|
||||
pending: pending[0],
|
||||
});
|
||||
}
|
||||
|
||||
export function loadState(candidateRoot: unknown): LoadedState {
|
||||
const root = directory(candidateRoot);
|
||||
const snapshots = root.entries
|
||||
.filter((entry) => SNAPSHOT_PATTERN.test(entry))
|
||||
.map((entry) => {
|
||||
const match = SNAPSHOT_PATTERN.exec(entry)!;
|
||||
const generation = Number(match[1]);
|
||||
const snapshot = normalizeSnapshot(
|
||||
readPrivateJson(path.join(root.path, entry), root.uid),
|
||||
);
|
||||
if (
|
||||
!Number.isSafeInteger(generation) ||
|
||||
generation !== snapshot.generation ||
|
||||
entry !== snapshotName(snapshot.generation)
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'publisher trust snapshot filename is invalid',
|
||||
);
|
||||
}
|
||||
return snapshot;
|
||||
})
|
||||
.sort((left, right) => left.generation - right.generation);
|
||||
for (const [index, snapshot] of snapshots.entries()) {
|
||||
const previous = snapshots[index - 1];
|
||||
if (
|
||||
snapshot.generation !== index + 1 ||
|
||||
(previous === undefined
|
||||
? snapshot.mode !== 'provision' ||
|
||||
snapshot.previousSnapshotDigest !== null ||
|
||||
snapshot.previousTrustDigest !== null
|
||||
: (snapshot.mode !== 'rotate' &&
|
||||
snapshot.mode !== 'retire' &&
|
||||
snapshot.mode !== 'revoke') ||
|
||||
snapshot.previousSnapshotDigest !== previous.snapshotDigest ||
|
||||
snapshot.previousTrustDigest !== previous.trustDigest)
|
||||
) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'publisher trust snapshot chain is invalid',
|
||||
);
|
||||
}
|
||||
}
|
||||
const retirements = loadRetirements(root, snapshots);
|
||||
const revocations = loadRevocations(root, snapshots);
|
||||
if (retirements.pending && revocations.pending) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'trust root has conflicting pending key lifecycle mutations',
|
||||
);
|
||||
}
|
||||
const currentPath = path.join(root.path, CURRENT_FILE);
|
||||
const current = root.entries.includes(CURRENT_FILE)
|
||||
? (() => {
|
||||
const trust = normalizeLocalPluginPackagePublisherTrustDocument(
|
||||
readPrivateJson(currentPath, root.uid),
|
||||
);
|
||||
return Object.freeze({
|
||||
trust,
|
||||
digest: digest(canonicalTrust(trust)),
|
||||
});
|
||||
})()
|
||||
: undefined;
|
||||
if (snapshots.length === 0) {
|
||||
if (current !== undefined) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'publisher trust current file has no immutable snapshot',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
root,
|
||||
snapshots: Object.freeze([]),
|
||||
current: undefined,
|
||||
committed: undefined,
|
||||
pending: undefined,
|
||||
retirementIntents: retirements.intents,
|
||||
retirementReceipts: retirements.receipts,
|
||||
pendingRetirement: retirements.pending,
|
||||
revocationProposals: revocations.proposals,
|
||||
revocationReceipts: revocations.receipts,
|
||||
pendingRevocation: revocations.pending,
|
||||
});
|
||||
}
|
||||
const latest = snapshots.at(-1)!;
|
||||
if (current?.digest === latest.trustDigest) {
|
||||
return Object.freeze({
|
||||
root,
|
||||
snapshots: Object.freeze(snapshots),
|
||||
current,
|
||||
committed: latest,
|
||||
pending: undefined,
|
||||
retirementIntents: retirements.intents,
|
||||
retirementReceipts: retirements.receipts,
|
||||
pendingRetirement: retirements.pending,
|
||||
revocationProposals: revocations.proposals,
|
||||
revocationReceipts: revocations.receipts,
|
||||
pendingRevocation: revocations.pending,
|
||||
});
|
||||
}
|
||||
const previous = snapshots.at(-2);
|
||||
if (
|
||||
(previous === undefined && current === undefined) ||
|
||||
current?.digest === previous?.trustDigest
|
||||
) {
|
||||
return Object.freeze({
|
||||
root,
|
||||
snapshots: Object.freeze(snapshots),
|
||||
current,
|
||||
committed: previous,
|
||||
pending: latest,
|
||||
retirementIntents: retirements.intents,
|
||||
retirementReceipts: retirements.receipts,
|
||||
pendingRetirement: retirements.pending,
|
||||
revocationProposals: revocations.proposals,
|
||||
revocationReceipts: revocations.receipts,
|
||||
pendingRevocation: revocations.pending,
|
||||
});
|
||||
}
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'publisher trust current file does not match its snapshot chain',
|
||||
);
|
||||
}
|
||||
|
||||
export function writePrivateTemporary(
|
||||
root: DirectoryIdentity,
|
||||
contents: string,
|
||||
): string {
|
||||
if (Buffer.byteLength(contents, 'utf8') > MAX_FILE_BYTES) {
|
||||
throw new LocalPluginPackagePublisherTrustConfigurationError(
|
||||
'publisher trust file exceeds its byte bound',
|
||||
);
|
||||
}
|
||||
const temporaryPath = path.join(
|
||||
root.path,
|
||||
`.qlpkg-trust-${randomBytes(16).toString('hex')}.tmp`,
|
||||
);
|
||||
const descriptor = fs.openSync(
|
||||
temporaryPath,
|
||||
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL,
|
||||
0o600,
|
||||
);
|
||||
try {
|
||||
fs.writeFileSync(descriptor, contents, 'utf8');
|
||||
fs.fsyncSync(descriptor);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
return temporaryPath;
|
||||
}
|
||||
|
||||
export function publishSnapshot(
|
||||
root: DirectoryIdentity,
|
||||
snapshot: Readonly<TrustSnapshot>,
|
||||
): void {
|
||||
const targetPath = path.join(root.path, snapshotName(snapshot.generation));
|
||||
const temporaryPath = writePrivateTemporary(
|
||||
root,
|
||||
`${JSON.stringify(snapshot)}\n`,
|
||||
);
|
||||
try {
|
||||
try {
|
||||
fs.linkSync(temporaryPath, targetPath);
|
||||
syncDirectory(root.path);
|
||||
} catch (error) {
|
||||
if (
|
||||
!error ||
|
||||
typeof error !== 'object' ||
|
||||
!('code' in error) ||
|
||||
(error as { code?: string }).code !== 'EEXIST'
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
const existing = normalizeSnapshot(readPrivateJson(targetPath, root.uid));
|
||||
if (!sameSnapshot(existing, snapshot)) {
|
||||
throw new LocalPluginPackagePublisherTrustConflictError(
|
||||
'another trust generation won publication',
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
fs.unlinkSync(temporaryPath);
|
||||
syncDirectory(root.path);
|
||||
} catch (error) {
|
||||
if (
|
||||
!error ||
|
||||
typeof error !== 'object' ||
|
||||
!('code' in error) ||
|
||||
(error as { code?: string }).code !== 'ENOENT'
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function publishImmutableDocument(
|
||||
root: DirectoryIdentity,
|
||||
fileName: string,
|
||||
contents: string,
|
||||
normalize: (value: unknown) => Readonly<{ readonly schema: string }>,
|
||||
): void {
|
||||
const targetPath = path.join(root.path, fileName);
|
||||
const temporaryPath = writePrivateTemporary(root, contents);
|
||||
try {
|
||||
try {
|
||||
fs.linkSync(temporaryPath, targetPath);
|
||||
syncDirectory(root.path);
|
||||
} catch (error) {
|
||||
if (
|
||||
!error ||
|
||||
typeof error !== 'object' ||
|
||||
!('code' in error) ||
|
||||
(error as { code?: string }).code !== 'EEXIST'
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
const existing = normalize(readPrivateJson(targetPath, root.uid));
|
||||
if (`${JSON.stringify(existing)}\n` !== contents) {
|
||||
throw new LocalPluginPackagePublisherTrustConflictError(
|
||||
'immutable retirement evidence already has different content',
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
fs.unlinkSync(temporaryPath);
|
||||
syncDirectory(root.path);
|
||||
} catch (error) {
|
||||
if (
|
||||
!error ||
|
||||
typeof error !== 'object' ||
|
||||
!('code' in error) ||
|
||||
(error as { code?: string }).code !== 'ENOENT'
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function promoteCurrent(
|
||||
root: DirectoryIdentity,
|
||||
snapshot: Readonly<TrustSnapshot>,
|
||||
): void {
|
||||
revalidateDirectory(root);
|
||||
const currentPath = path.join(root.path, CURRENT_FILE);
|
||||
if (fs.existsSync(currentPath)) {
|
||||
const current = normalizeLocalPluginPackagePublisherTrustDocument(
|
||||
readPrivateJson(currentPath, root.uid),
|
||||
);
|
||||
const currentDigest = digest(canonicalTrust(current));
|
||||
if (currentDigest === snapshot.trustDigest) return;
|
||||
if (currentDigest !== snapshot.previousTrustDigest) {
|
||||
throw new LocalPluginPackagePublisherTrustConflictError(
|
||||
'current trust changed before promotion',
|
||||
);
|
||||
}
|
||||
} else if (snapshot.previousTrustDigest !== null) {
|
||||
throw new LocalPluginPackagePublisherTrustConflictError(
|
||||
'current trust disappeared before promotion',
|
||||
);
|
||||
}
|
||||
const temporaryPath = writePrivateTemporary(
|
||||
root,
|
||||
canonicalTrust(snapshot.trust),
|
||||
);
|
||||
try {
|
||||
if (snapshot.previousTrustDigest === null) {
|
||||
try {
|
||||
fs.linkSync(temporaryPath, currentPath);
|
||||
} catch (error) {
|
||||
if (
|
||||
!error ||
|
||||
typeof error !== 'object' ||
|
||||
!('code' in error) ||
|
||||
(error as { code?: string }).code !== 'EEXIST'
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
const current = normalizeLocalPluginPackagePublisherTrustDocument(
|
||||
readPrivateJson(currentPath, root.uid),
|
||||
);
|
||||
if (digest(canonicalTrust(current)) !== snapshot.trustDigest) {
|
||||
throw new LocalPluginPackagePublisherTrustConflictError(
|
||||
'another initial trust won publication',
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fs.renameSync(temporaryPath, currentPath);
|
||||
}
|
||||
syncDirectory(root.path);
|
||||
} finally {
|
||||
try {
|
||||
fs.unlinkSync(temporaryPath);
|
||||
syncDirectory(root.path);
|
||||
} catch (error) {
|
||||
if (
|
||||
!error ||
|
||||
typeof error !== 'object' ||
|
||||
!('code' in error) ||
|
||||
(error as { code?: string }).code !== 'ENOENT'
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user