mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 10:32:40 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
+722
@@ -0,0 +1,722 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import type { PluginPackagePublisherSignatureEvidence } from '../pluginPackageBundle';
|
||||
import {
|
||||
SECURITY_SUBJECT_TYPES,
|
||||
type SecuritySubject,
|
||||
} from '../../security/security';
|
||||
|
||||
export const PLUGIN_PACKAGE_PUBLISHER_PROVENANCE_SCHEMA =
|
||||
'qinglong/plugin-package-publisher-provenance@v1' as const;
|
||||
export const PLUGIN_PACKAGE_PUBLISHER_REVOCATION_RECEIPT_SCHEMA =
|
||||
'qinglong/plugin-package-publisher-key-revocation-receipt@v1' as const;
|
||||
export const PLUGIN_PACKAGE_PUBLISHER_REVOCATION_IMPACT_SCHEMA =
|
||||
'qinglong/plugin-package-publisher-key-revocation-impact@v1' as const;
|
||||
export const MAX_PLUGIN_PACKAGE_PUBLISHER_REVOCATION_IMPACT_ITEMS = 4096;
|
||||
|
||||
export interface PluginPackagePublisherProvenance {
|
||||
readonly schema: typeof PLUGIN_PACKAGE_PUBLISHER_PROVENANCE_SCHEMA;
|
||||
readonly projectId: string;
|
||||
readonly packageName: string;
|
||||
readonly installationId: string;
|
||||
readonly lockDigest: string;
|
||||
readonly artifactDigest: string;
|
||||
readonly manifestDigest: string;
|
||||
readonly contentDigest: string;
|
||||
readonly stageEvidenceDigest: string;
|
||||
readonly publisher: string;
|
||||
readonly keyId: string;
|
||||
readonly signatureDigest: string;
|
||||
readonly keyNotBeforeMs: number;
|
||||
readonly keyNotAfterMs: number;
|
||||
readonly verifiedAtMs: number;
|
||||
readonly provenanceDigest: string;
|
||||
}
|
||||
|
||||
export interface CreatePluginPackagePublisherProvenanceInput {
|
||||
readonly projectId: string;
|
||||
readonly packageName: string;
|
||||
readonly installationId: string;
|
||||
readonly lockDigest: string;
|
||||
readonly artifactDigest: string;
|
||||
readonly manifestDigest: string;
|
||||
readonly contentDigest: string;
|
||||
readonly stageEvidenceDigest: string;
|
||||
readonly signature: Readonly<PluginPackagePublisherSignatureEvidence>;
|
||||
}
|
||||
|
||||
export type PluginPackagePublisherRevocationAuthorizationMode =
|
||||
| 'dual_control'
|
||||
| 'break_glass';
|
||||
export type PluginPackagePublisherRevocationReason =
|
||||
| 'suspected_key_compromise'
|
||||
| 'confirmed_key_compromise';
|
||||
|
||||
export interface PluginPackagePublisherRevocationReceipt {
|
||||
readonly schema: typeof PLUGIN_PACKAGE_PUBLISHER_REVOCATION_RECEIPT_SCHEMA;
|
||||
readonly mutationId: string;
|
||||
readonly publisher: string;
|
||||
readonly keyId: string;
|
||||
readonly previousTrustDigest: string;
|
||||
readonly currentTrustDigest: string;
|
||||
readonly proposer: Readonly<SecuritySubject>;
|
||||
readonly confirmer: Readonly<SecuritySubject>;
|
||||
readonly authorizationMode: PluginPackagePublisherRevocationAuthorizationMode;
|
||||
readonly reasonCode: PluginPackagePublisherRevocationReason;
|
||||
readonly revokedAtMs: number;
|
||||
readonly receiptDigest: string;
|
||||
}
|
||||
|
||||
export type CreatePluginPackagePublisherRevocationReceiptInput = Omit<
|
||||
PluginPackagePublisherRevocationReceipt,
|
||||
'receiptDigest' | 'schema'
|
||||
>;
|
||||
|
||||
export interface PluginPackagePublisherRevocationImpactItem {
|
||||
readonly projectId: string;
|
||||
readonly packageName: string;
|
||||
readonly installationId: string;
|
||||
readonly lockDigest: string;
|
||||
readonly provenanceDigest: string;
|
||||
}
|
||||
|
||||
export interface PluginPackagePublisherRevocationImpact {
|
||||
readonly schema: typeof PLUGIN_PACKAGE_PUBLISHER_REVOCATION_IMPACT_SCHEMA;
|
||||
readonly revocationReceiptDigest: string;
|
||||
readonly items: readonly Readonly<PluginPackagePublisherRevocationImpactItem>[];
|
||||
readonly generatedAtMs: number;
|
||||
readonly impactDigest: string;
|
||||
}
|
||||
|
||||
export type CreatePluginPackagePublisherRevocationImpactInput = Omit<
|
||||
PluginPackagePublisherRevocationImpact,
|
||||
'impactDigest' | 'schema'
|
||||
>;
|
||||
|
||||
export class InvalidPluginPackagePublisherProvenanceError extends TypeError {
|
||||
readonly code = 'PLUGIN_PACKAGE_PUBLISHER_PROVENANCE_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Plugin Package publisher provenance is invalid: ${message}`);
|
||||
this.name = 'InvalidPluginPackagePublisherProvenanceError';
|
||||
}
|
||||
}
|
||||
|
||||
export class PluginPackagePublisherProvenanceConflictError extends Error {
|
||||
readonly code = 'PLUGIN_PACKAGE_PUBLISHER_PROVENANCE_CONFLICT';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Plugin Package publisher provenance conflicts with durable state: ${message}`);
|
||||
this.name = 'PluginPackagePublisherProvenanceConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class PluginPackagePublisherProvenanceUnavailableError extends Error {
|
||||
readonly code = 'PLUGIN_PACKAGE_PUBLISHER_PROVENANCE_UNAVAILABLE';
|
||||
|
||||
constructor(options?: ErrorOptions) {
|
||||
super('Plugin Package publisher provenance is unavailable', options);
|
||||
this.name = 'PluginPackagePublisherProvenanceUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const PACKAGE_NAME_PATTERN =
|
||||
/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
const PUBLISHER_PATTERN =
|
||||
/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/;
|
||||
const SUBJECT_CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
||||
const PROVENANCE_DIGEST_DOMAIN =
|
||||
'qinglong/plugin-package-publisher-provenance-digest@v1\0';
|
||||
const REVOCATION_RECEIPT_DIGEST_DOMAIN =
|
||||
'qinglong/plugin-package-publisher-key-revocation-receipt-digest@v1\0';
|
||||
const REVOCATION_IMPACT_DIGEST_DOMAIN =
|
||||
'qinglong/plugin-package-publisher-key-revocation-impact-digest@v1\0';
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new InvalidPluginPackagePublisherProvenanceError(message);
|
||||
}
|
||||
|
||||
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`);
|
||||
}
|
||||
const descriptors = Object.getOwnPropertyDescriptors(value);
|
||||
if (
|
||||
Object.values(descriptors).some(
|
||||
(descriptor) =>
|
||||
descriptor.get !== undefined ||
|
||||
descriptor.set !== undefined ||
|
||||
descriptor.enumerable !== true,
|
||||
)
|
||||
) {
|
||||
return invalid(`${label} must contain enumerable data properties`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactKeys(
|
||||
value: object,
|
||||
expected: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Reflect.ownKeys(value);
|
||||
const canonical = [...expected].sort();
|
||||
if (
|
||||
actual.some((key) => typeof key !== 'string') ||
|
||||
actual.length !== canonical.length ||
|
||||
actual
|
||||
.map(String)
|
||||
.sort()
|
||||
.some((key, index) => key !== canonical[index])
|
||||
) {
|
||||
invalid(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function digest(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function identifier(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !IDENTIFIER_PATTERN.test(value)) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function projectId(value: unknown): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length < 1 ||
|
||||
Buffer.byteLength(value, 'utf8') > 128 ||
|
||||
value.includes('\0')
|
||||
) {
|
||||
return invalid('projectId is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function packageName(value: unknown): string {
|
||||
if (typeof value !== 'string' || !PACKAGE_NAME_PATTERN.test(value)) {
|
||||
return invalid('packageName is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function publisher(value: unknown): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
Buffer.byteLength(value, 'utf8') > 253 ||
|
||||
!PUBLISHER_PATTERN.test(value)
|
||||
) {
|
||||
return invalid('publisher is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function timestamp(value: unknown, label: string): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function subject(
|
||||
value: SecuritySubject,
|
||||
label: string,
|
||||
): Readonly<SecuritySubject> {
|
||||
const record = dataRecord(value, label);
|
||||
exactKeys(record, ['id', 'type'], label);
|
||||
if (
|
||||
typeof value.type !== 'string' ||
|
||||
!SECURITY_SUBJECT_TYPES.includes(
|
||||
value.type as (typeof SECURITY_SUBJECT_TYPES)[number],
|
||||
) ||
|
||||
typeof value.id !== 'string' ||
|
||||
value.id.length < 1 ||
|
||||
Buffer.byteLength(value.id, 'utf8') > 255 ||
|
||||
SUBJECT_CONTROL_PATTERN.test(value.id)
|
||||
) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return Object.freeze({
|
||||
type: value.type as (typeof SECURITY_SUBJECT_TYPES)[number],
|
||||
id: value.id,
|
||||
});
|
||||
}
|
||||
|
||||
function sameSubject(
|
||||
left: Readonly<SecuritySubject>,
|
||||
right: Readonly<SecuritySubject>,
|
||||
): boolean {
|
||||
return left.type === right.type && left.id === right.id;
|
||||
}
|
||||
|
||||
function provenanceFields(
|
||||
value: Omit<PluginPackagePublisherProvenance, 'provenanceDigest'>,
|
||||
): object {
|
||||
return {
|
||||
schema: value.schema,
|
||||
projectId: value.projectId,
|
||||
packageName: value.packageName,
|
||||
installationId: value.installationId,
|
||||
lockDigest: value.lockDigest,
|
||||
artifactDigest: value.artifactDigest,
|
||||
manifestDigest: value.manifestDigest,
|
||||
contentDigest: value.contentDigest,
|
||||
stageEvidenceDigest: value.stageEvidenceDigest,
|
||||
publisher: value.publisher,
|
||||
keyId: value.keyId,
|
||||
signatureDigest: value.signatureDigest,
|
||||
keyNotBeforeMs: value.keyNotBeforeMs,
|
||||
keyNotAfterMs: value.keyNotAfterMs,
|
||||
verifiedAtMs: value.verifiedAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
export function pluginPackagePublisherProvenanceDigest(
|
||||
value: Omit<PluginPackagePublisherProvenance, 'provenanceDigest'>,
|
||||
): string {
|
||||
return createHash('sha256')
|
||||
.update(PROVENANCE_DIGEST_DOMAIN)
|
||||
.update(JSON.stringify(provenanceFields(value)))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
export function normalizePluginPackagePublisherProvenance(
|
||||
value: PluginPackagePublisherProvenance,
|
||||
): Readonly<PluginPackagePublisherProvenance> {
|
||||
const record = dataRecord(value, 'provenance');
|
||||
exactKeys(
|
||||
record,
|
||||
[
|
||||
'artifactDigest',
|
||||
'contentDigest',
|
||||
'installationId',
|
||||
'keyId',
|
||||
'keyNotAfterMs',
|
||||
'keyNotBeforeMs',
|
||||
'lockDigest',
|
||||
'manifestDigest',
|
||||
'packageName',
|
||||
'projectId',
|
||||
'provenanceDigest',
|
||||
'publisher',
|
||||
'schema',
|
||||
'signatureDigest',
|
||||
'stageEvidenceDigest',
|
||||
'verifiedAtMs',
|
||||
],
|
||||
'provenance',
|
||||
);
|
||||
if (value.schema !== PLUGIN_PACKAGE_PUBLISHER_PROVENANCE_SCHEMA) {
|
||||
return invalid('provenance schema is invalid');
|
||||
}
|
||||
const keyNotBeforeMs = timestamp(value.keyNotBeforeMs, 'keyNotBeforeMs');
|
||||
const keyNotAfterMs = timestamp(value.keyNotAfterMs, 'keyNotAfterMs');
|
||||
const verifiedAtMs = timestamp(value.verifiedAtMs, 'verifiedAtMs');
|
||||
if (
|
||||
keyNotAfterMs <= keyNotBeforeMs ||
|
||||
verifiedAtMs < keyNotBeforeMs ||
|
||||
verifiedAtMs >= keyNotAfterMs
|
||||
) {
|
||||
return invalid('signature verification time is outside key validity');
|
||||
}
|
||||
const normalized = Object.freeze({
|
||||
schema: PLUGIN_PACKAGE_PUBLISHER_PROVENANCE_SCHEMA,
|
||||
projectId: projectId(value.projectId),
|
||||
packageName: packageName(value.packageName),
|
||||
installationId: identifier(value.installationId, 'installationId'),
|
||||
lockDigest: digest(value.lockDigest, 'lockDigest'),
|
||||
artifactDigest: digest(value.artifactDigest, 'artifactDigest'),
|
||||
manifestDigest: digest(value.manifestDigest, 'manifestDigest'),
|
||||
contentDigest: digest(value.contentDigest, 'contentDigest'),
|
||||
stageEvidenceDigest: digest(
|
||||
value.stageEvidenceDigest,
|
||||
'stageEvidenceDigest',
|
||||
),
|
||||
publisher: publisher(value.publisher),
|
||||
keyId: identifier(value.keyId, 'keyId'),
|
||||
signatureDigest: digest(value.signatureDigest, 'signatureDigest'),
|
||||
keyNotBeforeMs,
|
||||
keyNotAfterMs,
|
||||
verifiedAtMs,
|
||||
});
|
||||
const provenanceDigest = pluginPackagePublisherProvenanceDigest(normalized);
|
||||
if (value.provenanceDigest !== provenanceDigest) {
|
||||
return invalid('provenanceDigest does not match provenance');
|
||||
}
|
||||
return Object.freeze({ ...normalized, provenanceDigest });
|
||||
}
|
||||
|
||||
export function createPluginPackagePublisherProvenance(
|
||||
input: CreatePluginPackagePublisherProvenanceInput,
|
||||
): Readonly<PluginPackagePublisherProvenance> {
|
||||
const value = dataRecord(input, 'provenance input');
|
||||
exactKeys(
|
||||
value,
|
||||
[
|
||||
'artifactDigest',
|
||||
'contentDigest',
|
||||
'installationId',
|
||||
'lockDigest',
|
||||
'manifestDigest',
|
||||
'packageName',
|
||||
'projectId',
|
||||
'signature',
|
||||
'stageEvidenceDigest',
|
||||
],
|
||||
'provenance input',
|
||||
);
|
||||
const signature = dataRecord(input.signature, 'signature evidence');
|
||||
exactKeys(
|
||||
signature,
|
||||
[
|
||||
'keyId',
|
||||
'keyNotAfterMs',
|
||||
'keyNotBeforeMs',
|
||||
'publisher',
|
||||
'signatureDigest',
|
||||
'verifiedAtMs',
|
||||
],
|
||||
'signature evidence',
|
||||
);
|
||||
const unsigned: Omit<
|
||||
PluginPackagePublisherProvenance,
|
||||
'provenanceDigest'
|
||||
> = {
|
||||
schema: PLUGIN_PACKAGE_PUBLISHER_PROVENANCE_SCHEMA,
|
||||
projectId: projectId(input.projectId),
|
||||
packageName: packageName(input.packageName),
|
||||
installationId: identifier(input.installationId, 'installationId'),
|
||||
lockDigest: digest(input.lockDigest, 'lockDigest'),
|
||||
artifactDigest: digest(input.artifactDigest, 'artifactDigest'),
|
||||
manifestDigest: digest(input.manifestDigest, 'manifestDigest'),
|
||||
contentDigest: digest(input.contentDigest, 'contentDigest'),
|
||||
stageEvidenceDigest: digest(
|
||||
input.stageEvidenceDigest,
|
||||
'stageEvidenceDigest',
|
||||
),
|
||||
publisher: publisher(input.signature.publisher),
|
||||
keyId: identifier(input.signature.keyId, 'keyId'),
|
||||
signatureDigest: digest(
|
||||
input.signature.signatureDigest,
|
||||
'signatureDigest',
|
||||
),
|
||||
keyNotBeforeMs: timestamp(
|
||||
input.signature.keyNotBeforeMs,
|
||||
'keyNotBeforeMs',
|
||||
),
|
||||
keyNotAfterMs: timestamp(
|
||||
input.signature.keyNotAfterMs,
|
||||
'keyNotAfterMs',
|
||||
),
|
||||
verifiedAtMs: timestamp(input.signature.verifiedAtMs, 'verifiedAtMs'),
|
||||
};
|
||||
return normalizePluginPackagePublisherProvenance({
|
||||
...unsigned,
|
||||
provenanceDigest: pluginPackagePublisherProvenanceDigest(unsigned),
|
||||
});
|
||||
}
|
||||
|
||||
function revocationReceiptFields(
|
||||
value: Omit<PluginPackagePublisherRevocationReceipt, 'receiptDigest'>,
|
||||
): object {
|
||||
return {
|
||||
schema: value.schema,
|
||||
mutationId: value.mutationId,
|
||||
publisher: value.publisher,
|
||||
keyId: value.keyId,
|
||||
previousTrustDigest: value.previousTrustDigest,
|
||||
currentTrustDigest: value.currentTrustDigest,
|
||||
proposer: value.proposer,
|
||||
confirmer: value.confirmer,
|
||||
authorizationMode: value.authorizationMode,
|
||||
reasonCode: value.reasonCode,
|
||||
revokedAtMs: value.revokedAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
export function pluginPackagePublisherRevocationReceiptDigest(
|
||||
value: Omit<PluginPackagePublisherRevocationReceipt, 'receiptDigest'>,
|
||||
): string {
|
||||
return createHash('sha256')
|
||||
.update(REVOCATION_RECEIPT_DIGEST_DOMAIN)
|
||||
.update(JSON.stringify(revocationReceiptFields(value)))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
export function normalizePluginPackagePublisherRevocationReceipt(
|
||||
value: PluginPackagePublisherRevocationReceipt,
|
||||
): Readonly<PluginPackagePublisherRevocationReceipt> {
|
||||
const record = dataRecord(value, 'revocation receipt');
|
||||
exactKeys(
|
||||
record,
|
||||
[
|
||||
'authorizationMode',
|
||||
'confirmer',
|
||||
'currentTrustDigest',
|
||||
'keyId',
|
||||
'mutationId',
|
||||
'previousTrustDigest',
|
||||
'proposer',
|
||||
'publisher',
|
||||
'reasonCode',
|
||||
'receiptDigest',
|
||||
'revokedAtMs',
|
||||
'schema',
|
||||
],
|
||||
'revocation receipt',
|
||||
);
|
||||
if (
|
||||
value.schema !== PLUGIN_PACKAGE_PUBLISHER_REVOCATION_RECEIPT_SCHEMA ||
|
||||
(value.authorizationMode !== 'dual_control' &&
|
||||
value.authorizationMode !== 'break_glass') ||
|
||||
(value.reasonCode !== 'suspected_key_compromise' &&
|
||||
value.reasonCode !== 'confirmed_key_compromise')
|
||||
) {
|
||||
return invalid('revocation receipt classification is invalid');
|
||||
}
|
||||
const proposer = subject(value.proposer, 'proposer');
|
||||
const confirmer = subject(value.confirmer, 'confirmer');
|
||||
if (
|
||||
value.authorizationMode === 'dual_control' &&
|
||||
sameSubject(proposer, confirmer)
|
||||
) {
|
||||
return invalid('dual-control requires distinct subjects');
|
||||
}
|
||||
const previousTrustDigest = digest(
|
||||
value.previousTrustDigest,
|
||||
'previousTrustDigest',
|
||||
);
|
||||
const currentTrustDigest = digest(
|
||||
value.currentTrustDigest,
|
||||
'currentTrustDigest',
|
||||
);
|
||||
if (previousTrustDigest === currentTrustDigest) {
|
||||
return invalid('revocation must change the publisher trust digest');
|
||||
}
|
||||
const normalized = Object.freeze({
|
||||
schema: PLUGIN_PACKAGE_PUBLISHER_REVOCATION_RECEIPT_SCHEMA,
|
||||
mutationId: identifier(value.mutationId, 'mutationId'),
|
||||
publisher: publisher(value.publisher),
|
||||
keyId: identifier(value.keyId, 'keyId'),
|
||||
previousTrustDigest,
|
||||
currentTrustDigest,
|
||||
proposer,
|
||||
confirmer,
|
||||
authorizationMode: value.authorizationMode,
|
||||
reasonCode: value.reasonCode,
|
||||
revokedAtMs: timestamp(value.revokedAtMs, 'revokedAtMs'),
|
||||
});
|
||||
const receiptDigest =
|
||||
pluginPackagePublisherRevocationReceiptDigest(normalized);
|
||||
if (value.receiptDigest !== receiptDigest) {
|
||||
return invalid('receiptDigest does not match revocation receipt');
|
||||
}
|
||||
return Object.freeze({ ...normalized, receiptDigest });
|
||||
}
|
||||
|
||||
export function createPluginPackagePublisherRevocationReceipt(
|
||||
input: CreatePluginPackagePublisherRevocationReceiptInput,
|
||||
): Readonly<PluginPackagePublisherRevocationReceipt> {
|
||||
const value = dataRecord(input, 'revocation receipt input');
|
||||
exactKeys(
|
||||
value,
|
||||
[
|
||||
'authorizationMode',
|
||||
'confirmer',
|
||||
'currentTrustDigest',
|
||||
'keyId',
|
||||
'mutationId',
|
||||
'previousTrustDigest',
|
||||
'proposer',
|
||||
'publisher',
|
||||
'reasonCode',
|
||||
'revokedAtMs',
|
||||
],
|
||||
'revocation receipt input',
|
||||
);
|
||||
const unsigned: Omit<
|
||||
PluginPackagePublisherRevocationReceipt,
|
||||
'receiptDigest'
|
||||
> = {
|
||||
schema: PLUGIN_PACKAGE_PUBLISHER_REVOCATION_RECEIPT_SCHEMA,
|
||||
mutationId: input.mutationId,
|
||||
publisher: input.publisher,
|
||||
keyId: input.keyId,
|
||||
previousTrustDigest: input.previousTrustDigest,
|
||||
currentTrustDigest: input.currentTrustDigest,
|
||||
proposer: input.proposer,
|
||||
confirmer: input.confirmer,
|
||||
authorizationMode: input.authorizationMode,
|
||||
reasonCode: input.reasonCode,
|
||||
revokedAtMs: input.revokedAtMs,
|
||||
};
|
||||
return normalizePluginPackagePublisherRevocationReceipt({
|
||||
...unsigned,
|
||||
receiptDigest: pluginPackagePublisherRevocationReceiptDigest(unsigned),
|
||||
});
|
||||
}
|
||||
|
||||
function compareImpactItems(
|
||||
left: Readonly<PluginPackagePublisherRevocationImpactItem>,
|
||||
right: Readonly<PluginPackagePublisherRevocationImpactItem>,
|
||||
): number {
|
||||
return (
|
||||
Buffer.compare(
|
||||
Buffer.from(left.projectId, 'utf8'),
|
||||
Buffer.from(right.projectId, 'utf8'),
|
||||
) ||
|
||||
Buffer.compare(
|
||||
Buffer.from(left.packageName, 'utf8'),
|
||||
Buffer.from(right.packageName, 'utf8'),
|
||||
) ||
|
||||
Buffer.compare(
|
||||
Buffer.from(left.installationId, 'utf8'),
|
||||
Buffer.from(right.installationId, 'utf8'),
|
||||
) ||
|
||||
left.lockDigest.localeCompare(right.lockDigest)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeImpactItem(
|
||||
value: PluginPackagePublisherRevocationImpactItem,
|
||||
): Readonly<PluginPackagePublisherRevocationImpactItem> {
|
||||
const record = dataRecord(value, 'impact item');
|
||||
exactKeys(
|
||||
record,
|
||||
[
|
||||
'installationId',
|
||||
'lockDigest',
|
||||
'packageName',
|
||||
'projectId',
|
||||
'provenanceDigest',
|
||||
],
|
||||
'impact item',
|
||||
);
|
||||
return Object.freeze({
|
||||
projectId: projectId(value.projectId),
|
||||
packageName: packageName(value.packageName),
|
||||
installationId: identifier(value.installationId, 'installationId'),
|
||||
lockDigest: digest(value.lockDigest, 'lockDigest'),
|
||||
provenanceDigest: digest(value.provenanceDigest, 'provenanceDigest'),
|
||||
});
|
||||
}
|
||||
|
||||
function impactFields(
|
||||
value: Omit<PluginPackagePublisherRevocationImpact, 'impactDigest'>,
|
||||
): object {
|
||||
return {
|
||||
schema: value.schema,
|
||||
revocationReceiptDigest: value.revocationReceiptDigest,
|
||||
items: value.items,
|
||||
generatedAtMs: value.generatedAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
export function pluginPackagePublisherRevocationImpactDigest(
|
||||
value: Omit<PluginPackagePublisherRevocationImpact, 'impactDigest'>,
|
||||
): string {
|
||||
return createHash('sha256')
|
||||
.update(REVOCATION_IMPACT_DIGEST_DOMAIN)
|
||||
.update(JSON.stringify(impactFields(value)))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
export function normalizePluginPackagePublisherRevocationImpact(
|
||||
value: PluginPackagePublisherRevocationImpact,
|
||||
): Readonly<PluginPackagePublisherRevocationImpact> {
|
||||
const record = dataRecord(value, 'revocation impact');
|
||||
exactKeys(
|
||||
record,
|
||||
[
|
||||
'generatedAtMs',
|
||||
'impactDigest',
|
||||
'items',
|
||||
'revocationReceiptDigest',
|
||||
'schema',
|
||||
],
|
||||
'revocation impact',
|
||||
);
|
||||
if (
|
||||
value.schema !== PLUGIN_PACKAGE_PUBLISHER_REVOCATION_IMPACT_SCHEMA ||
|
||||
!Array.isArray(value.items) ||
|
||||
value.items.length > MAX_PLUGIN_PACKAGE_PUBLISHER_REVOCATION_IMPACT_ITEMS ||
|
||||
Object.keys(value.items).some((key, index) => key !== String(index))
|
||||
) {
|
||||
return invalid('revocation impact schema or items are invalid');
|
||||
}
|
||||
const items = Object.freeze(value.items.map(normalizeImpactItem));
|
||||
if (
|
||||
items.some(
|
||||
(item, index) =>
|
||||
index > 0 && compareImpactItems(items[index - 1]!, item) >= 0,
|
||||
) ||
|
||||
new Set(items.map((item) => item.provenanceDigest)).size !== items.length ||
|
||||
new Set(items.map((item) => item.installationId)).size !== items.length
|
||||
) {
|
||||
return invalid('revocation impact items must be unique and sorted');
|
||||
}
|
||||
const normalized = Object.freeze({
|
||||
schema: PLUGIN_PACKAGE_PUBLISHER_REVOCATION_IMPACT_SCHEMA,
|
||||
revocationReceiptDigest: digest(
|
||||
value.revocationReceiptDigest,
|
||||
'revocationReceiptDigest',
|
||||
),
|
||||
items,
|
||||
generatedAtMs: timestamp(value.generatedAtMs, 'generatedAtMs'),
|
||||
});
|
||||
const impactDigest =
|
||||
pluginPackagePublisherRevocationImpactDigest(normalized);
|
||||
if (value.impactDigest !== impactDigest) {
|
||||
return invalid('impactDigest does not match revocation impact');
|
||||
}
|
||||
return Object.freeze({ ...normalized, impactDigest });
|
||||
}
|
||||
|
||||
export function createPluginPackagePublisherRevocationImpact(
|
||||
input: CreatePluginPackagePublisherRevocationImpactInput,
|
||||
): Readonly<PluginPackagePublisherRevocationImpact> {
|
||||
const value = dataRecord(input, 'revocation impact input');
|
||||
exactKeys(
|
||||
value,
|
||||
['generatedAtMs', 'items', 'revocationReceiptDigest'],
|
||||
'revocation impact input',
|
||||
);
|
||||
if (
|
||||
!Array.isArray(input.items) ||
|
||||
input.items.length >
|
||||
MAX_PLUGIN_PACKAGE_PUBLISHER_REVOCATION_IMPACT_ITEMS ||
|
||||
Object.keys(input.items).some((key, index) => key !== String(index))
|
||||
) {
|
||||
return invalid('revocation impact items are invalid');
|
||||
}
|
||||
const items = Object.freeze(
|
||||
input.items.map(normalizeImpactItem).sort(compareImpactItems),
|
||||
);
|
||||
const unsigned: Omit<
|
||||
PluginPackagePublisherRevocationImpact,
|
||||
'impactDigest'
|
||||
> = {
|
||||
schema: PLUGIN_PACKAGE_PUBLISHER_REVOCATION_IMPACT_SCHEMA,
|
||||
revocationReceiptDigest: input.revocationReceiptDigest,
|
||||
items,
|
||||
generatedAtMs: input.generatedAtMs,
|
||||
};
|
||||
return normalizePluginPackagePublisherRevocationImpact({
|
||||
...unsigned,
|
||||
impactDigest: pluginPackagePublisherRevocationImpactDigest(unsigned),
|
||||
});
|
||||
}
|
||||
+589
@@ -0,0 +1,589 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import {
|
||||
normalizeApprovedActionDispatchRecord,
|
||||
normalizeApprovedActionFence,
|
||||
type ApprovedActionDispatchRecord,
|
||||
} from '../../approved-action/approvedAction';
|
||||
import {
|
||||
createPluginPackagePublisherRevocationReceipt,
|
||||
type PluginPackagePublisherRevocationAuthorizationMode,
|
||||
type PluginPackagePublisherRevocationReason,
|
||||
type PluginPackagePublisherRevocationReceipt,
|
||||
} from './pluginPackagePublisherProvenance';
|
||||
import {
|
||||
normalizePluginPackagePublisherTrustSnapshot,
|
||||
pluginPackagePublisherTrustRevokedDigest,
|
||||
type PluginPackagePublisherTrustSnapshot,
|
||||
} from './pluginPackagePublisherTrust';
|
||||
import { normalizeProjectPolicySubject } from '../../security/project-policy/projectPolicy';
|
||||
import {
|
||||
SECURITY_AUTHENTICATION_ASSURANCES,
|
||||
type SecurityAuthenticationAssurance,
|
||||
type SecurityPolicyFence,
|
||||
type SecuritySubject,
|
||||
} from '../../security/security';
|
||||
import type { SecurityAuditRecord } from '../../security/audit/securityAudit';
|
||||
|
||||
export const PLUGIN_PACKAGE_PUBLISHER_REVOCATION_PROPOSAL_SCHEMA =
|
||||
'qinglong/plugin-package-publisher-key-revocation-proposal@v1' as const;
|
||||
export const PLUGIN_PACKAGE_PUBLISHER_REVOCATION_ACTION_TYPE =
|
||||
'plugin_package.publisher_key.revoke' as const;
|
||||
export const PLUGIN_PACKAGE_PUBLISHER_REVOCATION_PERMISSION =
|
||||
'package.manage' as const;
|
||||
|
||||
export interface PluginPackagePublisherRevocationActionInput {
|
||||
readonly authorityProjectId: string;
|
||||
readonly trustAuthorityId: string;
|
||||
readonly trustGeneration: number;
|
||||
readonly publisher: string;
|
||||
readonly keyId: string;
|
||||
readonly previousTrustDigest: string;
|
||||
readonly currentTrustDigest: string;
|
||||
readonly authorizationMode: PluginPackagePublisherRevocationAuthorizationMode;
|
||||
readonly reasonCode: PluginPackagePublisherRevocationReason;
|
||||
}
|
||||
|
||||
export interface PluginPackagePublisherRevocationProposal {
|
||||
readonly schema: typeof PLUGIN_PACKAGE_PUBLISHER_REVOCATION_PROPOSAL_SCHEMA;
|
||||
readonly actionRef: string;
|
||||
readonly projectId: string;
|
||||
readonly actionType: typeof PLUGIN_PACKAGE_PUBLISHER_REVOCATION_ACTION_TYPE;
|
||||
readonly permission: typeof PLUGIN_PACKAGE_PUBLISHER_REVOCATION_PERMISSION;
|
||||
readonly actionInput: Readonly<PluginPackagePublisherRevocationActionInput>;
|
||||
readonly actionDigest: string;
|
||||
readonly previewDigest: string;
|
||||
readonly proposedBy: Readonly<SecuritySubject>;
|
||||
readonly proposerAssurance: SecurityAuthenticationAssurance;
|
||||
readonly proposalFence: Readonly<SecurityPolicyFence>;
|
||||
readonly createdAtMs: number;
|
||||
readonly proposalDigest: string;
|
||||
}
|
||||
|
||||
export interface CreatePluginPackagePublisherRevocationProposalInput {
|
||||
readonly actionRef: string;
|
||||
readonly authorityProjectId: string;
|
||||
readonly trustAuthorityId: string;
|
||||
readonly trustGeneration: number;
|
||||
readonly trustSnapshot: PluginPackagePublisherTrustSnapshot;
|
||||
readonly publisher: string;
|
||||
readonly keyId: string;
|
||||
readonly authorizationMode: PluginPackagePublisherRevocationAuthorizationMode;
|
||||
readonly reasonCode: PluginPackagePublisherRevocationReason;
|
||||
readonly proposedBy: SecuritySubject;
|
||||
readonly proposerAssurance: SecurityAuthenticationAssurance;
|
||||
readonly proposalFence: SecurityPolicyFence;
|
||||
readonly createdAtMs: number;
|
||||
}
|
||||
|
||||
export interface CreatePluginPackagePublisherRevocationProposalCommand {
|
||||
readonly proposal: PluginPackagePublisherRevocationProposal;
|
||||
readonly audit: SecurityAuditRecord;
|
||||
}
|
||||
|
||||
export interface CreatePluginPackagePublisherRevocationProposalResult {
|
||||
readonly status: 'created' | 'existing';
|
||||
readonly proposal: Readonly<PluginPackagePublisherRevocationProposal>;
|
||||
}
|
||||
|
||||
export interface PluginPackagePublisherRevocationProposalRepository {
|
||||
findProposalByActionRef(
|
||||
actionRef: string,
|
||||
): Promise<Readonly<PluginPackagePublisherRevocationProposal> | null>;
|
||||
createProposal(
|
||||
command: CreatePluginPackagePublisherRevocationProposalCommand,
|
||||
): Promise<
|
||||
Readonly<CreatePluginPackagePublisherRevocationProposalResult>
|
||||
>;
|
||||
}
|
||||
|
||||
export class InvalidPluginPackagePublisherRevocationProposalError extends TypeError {
|
||||
readonly code = 'PLUGIN_PACKAGE_PUBLISHER_REVOCATION_PROPOSAL_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(
|
||||
`Plugin Package publisher revocation proposal is invalid: ${message}`,
|
||||
);
|
||||
this.name =
|
||||
'InvalidPluginPackagePublisherRevocationProposalError';
|
||||
}
|
||||
}
|
||||
|
||||
export class PluginPackagePublisherRevocationProposalBindingConflictError extends Error {
|
||||
readonly code =
|
||||
'PLUGIN_PACKAGE_PUBLISHER_REVOCATION_PROPOSAL_BINDING_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super(
|
||||
'Plugin Package publisher revocation proposal does not match its dispatch',
|
||||
);
|
||||
this.name =
|
||||
'PluginPackagePublisherRevocationProposalBindingConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class PluginPackagePublisherRevocationProposalConflictError extends Error {
|
||||
readonly code = 'PLUGIN_PACKAGE_PUBLISHER_REVOCATION_PROPOSAL_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super(
|
||||
'Plugin Package publisher revocation proposal conflicts with durable authority',
|
||||
);
|
||||
this.name =
|
||||
'PluginPackagePublisherRevocationProposalConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class PluginPackagePublisherRevocationProposalUnavailableError extends Error {
|
||||
readonly code =
|
||||
'PLUGIN_PACKAGE_PUBLISHER_REVOCATION_PROPOSAL_UNAVAILABLE';
|
||||
|
||||
constructor(options?: ErrorOptions) {
|
||||
super(
|
||||
'Plugin Package publisher revocation proposal authority is unavailable',
|
||||
options,
|
||||
);
|
||||
this.name =
|
||||
'PluginPackagePublisherRevocationProposalUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
const ACTION_REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}$/;
|
||||
const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const PUBLISHER_PATTERN =
|
||||
/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/;
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new InvalidPluginPackagePublisherRevocationProposalError(message);
|
||||
}
|
||||
|
||||
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`);
|
||||
}
|
||||
const descriptors = Object.getOwnPropertyDescriptors(value);
|
||||
if (
|
||||
Object.values(descriptors).some(
|
||||
(descriptor) =>
|
||||
descriptor.get !== undefined ||
|
||||
descriptor.set !== undefined ||
|
||||
descriptor.enumerable !== true,
|
||||
)
|
||||
) {
|
||||
return invalid(`${label} must contain enumerable data properties`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactKeys(
|
||||
value: object,
|
||||
expected: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Reflect.ownKeys(value);
|
||||
const canonical = [...expected].sort();
|
||||
if (
|
||||
actual.some((key) => typeof key !== 'string') ||
|
||||
actual.length !== canonical.length ||
|
||||
actual
|
||||
.map(String)
|
||||
.sort()
|
||||
.some((key, index) => key !== canonical[index])
|
||||
) {
|
||||
invalid(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function actionRef(value: unknown): string {
|
||||
if (typeof value !== 'string' || !ACTION_REF_PATTERN.test(value)) {
|
||||
return invalid('actionRef is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function identifier(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !IDENTIFIER_PATTERN.test(value)) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function publisher(value: unknown): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
Buffer.byteLength(value, 'utf8') > 253 ||
|
||||
!PUBLISHER_PATTERN.test(value)
|
||||
) {
|
||||
return invalid('publisher is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function projectId(value: unknown): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length < 1 ||
|
||||
Buffer.byteLength(value, 'utf8') > 128 ||
|
||||
value.includes('\0')
|
||||
) {
|
||||
return invalid('authorityProjectId is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function digest(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function timestamp(value: unknown, label: string): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown, label: string): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 1) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function authorizationMode(
|
||||
value: unknown,
|
||||
): PluginPackagePublisherRevocationAuthorizationMode {
|
||||
if (value !== 'dual_control' && value !== 'break_glass') {
|
||||
return invalid('authorizationMode is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function reasonCode(
|
||||
value: unknown,
|
||||
): PluginPackagePublisherRevocationReason {
|
||||
if (
|
||||
value !== 'suspected_key_compromise' &&
|
||||
value !== 'confirmed_key_compromise'
|
||||
) {
|
||||
return invalid('reasonCode is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function assurance(value: unknown): SecurityAuthenticationAssurance {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!SECURITY_AUTHENTICATION_ASSURANCES.includes(
|
||||
value as SecurityAuthenticationAssurance,
|
||||
)
|
||||
) {
|
||||
return invalid('proposerAssurance is invalid');
|
||||
}
|
||||
return value as SecurityAuthenticationAssurance;
|
||||
}
|
||||
|
||||
function sameSubject(
|
||||
left: Readonly<SecuritySubject>,
|
||||
right: Readonly<SecuritySubject>,
|
||||
): boolean {
|
||||
return left.type === right.type && left.id === right.id;
|
||||
}
|
||||
|
||||
function contractDigest(domain: string, value: unknown): string {
|
||||
return createHash('sha256')
|
||||
.update(domain)
|
||||
.update('\0')
|
||||
.update(JSON.stringify(value))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function normalizeActionInput(
|
||||
value: PluginPackagePublisherRevocationActionInput,
|
||||
): Readonly<PluginPackagePublisherRevocationActionInput> {
|
||||
const record = dataRecord(value, 'action input');
|
||||
exactKeys(
|
||||
record,
|
||||
[
|
||||
'authorityProjectId',
|
||||
'trustAuthorityId',
|
||||
'trustGeneration',
|
||||
'publisher',
|
||||
'keyId',
|
||||
'previousTrustDigest',
|
||||
'currentTrustDigest',
|
||||
'authorizationMode',
|
||||
'reasonCode',
|
||||
],
|
||||
'action input',
|
||||
);
|
||||
const previousTrustDigest = digest(
|
||||
value.previousTrustDigest,
|
||||
'previousTrustDigest',
|
||||
);
|
||||
const currentTrustDigest = digest(
|
||||
value.currentTrustDigest,
|
||||
'currentTrustDigest',
|
||||
);
|
||||
if (previousTrustDigest === currentTrustDigest) {
|
||||
return invalid('revocation must change the trust digest');
|
||||
}
|
||||
return Object.freeze({
|
||||
authorityProjectId: projectId(value.authorityProjectId),
|
||||
trustAuthorityId: identifier(
|
||||
value.trustAuthorityId,
|
||||
'trustAuthorityId',
|
||||
),
|
||||
trustGeneration: positiveInteger(
|
||||
value.trustGeneration,
|
||||
'trustGeneration',
|
||||
),
|
||||
publisher: publisher(value.publisher),
|
||||
keyId: identifier(value.keyId, 'keyId'),
|
||||
previousTrustDigest,
|
||||
currentTrustDigest,
|
||||
authorizationMode: authorizationMode(value.authorizationMode),
|
||||
reasonCode: reasonCode(value.reasonCode),
|
||||
});
|
||||
}
|
||||
|
||||
export function pluginPackagePublisherRevocationActionDigest(
|
||||
value: PluginPackagePublisherRevocationActionInput,
|
||||
): string {
|
||||
return contractDigest(
|
||||
'qinglong/plugin-package-publisher-key-revocation-action-digest@v1',
|
||||
normalizeActionInput(value),
|
||||
);
|
||||
}
|
||||
|
||||
export function pluginPackagePublisherRevocationPreviewDigest(
|
||||
value: PluginPackagePublisherRevocationActionInput,
|
||||
): string {
|
||||
const input = normalizeActionInput(value);
|
||||
return contractDigest(
|
||||
'qinglong/plugin-package-publisher-key-revocation-preview-digest@v1',
|
||||
{
|
||||
authorityProjectId: input.authorityProjectId,
|
||||
trustAuthorityId: input.trustAuthorityId,
|
||||
trustGeneration: input.trustGeneration,
|
||||
publisher: input.publisher,
|
||||
keyId: input.keyId,
|
||||
previousTrustDigest: input.previousTrustDigest,
|
||||
currentTrustDigest: input.currentTrustDigest,
|
||||
authorizationMode: input.authorizationMode,
|
||||
reasonCode: input.reasonCode,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function withProposalDigest(
|
||||
value: Omit<
|
||||
PluginPackagePublisherRevocationProposal,
|
||||
'proposalDigest'
|
||||
>,
|
||||
): Readonly<PluginPackagePublisherRevocationProposal> {
|
||||
const normalized = Object.freeze(value);
|
||||
return Object.freeze({
|
||||
...normalized,
|
||||
proposalDigest: contractDigest(
|
||||
'qinglong/plugin-package-publisher-key-revocation-proposal-digest@v1',
|
||||
normalized,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function createPluginPackagePublisherRevocationProposal(
|
||||
inputValue: CreatePluginPackagePublisherRevocationProposalInput,
|
||||
): Readonly<PluginPackagePublisherRevocationProposal> {
|
||||
const input = dataRecord(inputValue, 'proposal input');
|
||||
exactKeys(
|
||||
input,
|
||||
[
|
||||
'actionRef',
|
||||
'authorityProjectId',
|
||||
'trustAuthorityId',
|
||||
'trustGeneration',
|
||||
'trustSnapshot',
|
||||
'publisher',
|
||||
'keyId',
|
||||
'authorizationMode',
|
||||
'reasonCode',
|
||||
'proposedBy',
|
||||
'proposerAssurance',
|
||||
'proposalFence',
|
||||
'createdAtMs',
|
||||
],
|
||||
'proposal input',
|
||||
);
|
||||
const trustSnapshot = normalizePluginPackagePublisherTrustSnapshot(
|
||||
inputValue.trustSnapshot,
|
||||
);
|
||||
const actionInput = normalizeActionInput({
|
||||
authorityProjectId: inputValue.authorityProjectId,
|
||||
trustAuthorityId: inputValue.trustAuthorityId,
|
||||
trustGeneration: inputValue.trustGeneration,
|
||||
publisher: inputValue.publisher,
|
||||
keyId: inputValue.keyId,
|
||||
previousTrustDigest: trustSnapshot.snapshotDigest,
|
||||
currentTrustDigest: pluginPackagePublisherTrustRevokedDigest(
|
||||
trustSnapshot,
|
||||
inputValue.publisher,
|
||||
inputValue.keyId,
|
||||
),
|
||||
authorizationMode: inputValue.authorizationMode,
|
||||
reasonCode: inputValue.reasonCode,
|
||||
});
|
||||
const proposerAssurance = assurance(inputValue.proposerAssurance);
|
||||
if (
|
||||
actionInput.authorizationMode === 'break_glass' &&
|
||||
proposerAssurance !== 'hardware'
|
||||
) {
|
||||
return invalid('break-glass proposer must use hardware assurance');
|
||||
}
|
||||
const proposedBy = normalizeProjectPolicySubject(inputValue.proposedBy);
|
||||
return withProposalDigest({
|
||||
schema: PLUGIN_PACKAGE_PUBLISHER_REVOCATION_PROPOSAL_SCHEMA,
|
||||
actionRef: actionRef(inputValue.actionRef),
|
||||
projectId: actionInput.authorityProjectId,
|
||||
actionType: PLUGIN_PACKAGE_PUBLISHER_REVOCATION_ACTION_TYPE,
|
||||
permission: PLUGIN_PACKAGE_PUBLISHER_REVOCATION_PERMISSION,
|
||||
actionInput,
|
||||
actionDigest:
|
||||
pluginPackagePublisherRevocationActionDigest(actionInput),
|
||||
previewDigest:
|
||||
pluginPackagePublisherRevocationPreviewDigest(actionInput),
|
||||
proposedBy,
|
||||
proposerAssurance,
|
||||
proposalFence: normalizeApprovedActionFence(
|
||||
inputValue.proposalFence,
|
||||
),
|
||||
createdAtMs: timestamp(inputValue.createdAtMs, 'createdAtMs'),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizePluginPackagePublisherRevocationProposal(
|
||||
value: PluginPackagePublisherRevocationProposal,
|
||||
): Readonly<PluginPackagePublisherRevocationProposal> {
|
||||
const record = dataRecord(value, 'proposal');
|
||||
exactKeys(
|
||||
record,
|
||||
[
|
||||
'schema',
|
||||
'actionRef',
|
||||
'projectId',
|
||||
'actionType',
|
||||
'permission',
|
||||
'actionInput',
|
||||
'actionDigest',
|
||||
'previewDigest',
|
||||
'proposedBy',
|
||||
'proposerAssurance',
|
||||
'proposalFence',
|
||||
'createdAtMs',
|
||||
'proposalDigest',
|
||||
],
|
||||
'proposal',
|
||||
);
|
||||
if (
|
||||
value.schema !==
|
||||
PLUGIN_PACKAGE_PUBLISHER_REVOCATION_PROPOSAL_SCHEMA ||
|
||||
value.actionType !==
|
||||
PLUGIN_PACKAGE_PUBLISHER_REVOCATION_ACTION_TYPE ||
|
||||
value.permission !==
|
||||
PLUGIN_PACKAGE_PUBLISHER_REVOCATION_PERMISSION
|
||||
) {
|
||||
return invalid('schema or action authority is invalid');
|
||||
}
|
||||
const actionInput = normalizeActionInput(value.actionInput);
|
||||
const proposedBy = normalizeProjectPolicySubject(value.proposedBy);
|
||||
const proposerAssurance = assurance(value.proposerAssurance);
|
||||
if (
|
||||
actionInput.authorizationMode === 'break_glass' &&
|
||||
proposerAssurance !== 'hardware'
|
||||
) {
|
||||
return invalid('break-glass proposer must use hardware assurance');
|
||||
}
|
||||
const normalized = withProposalDigest({
|
||||
schema: PLUGIN_PACKAGE_PUBLISHER_REVOCATION_PROPOSAL_SCHEMA,
|
||||
actionRef: actionRef(value.actionRef),
|
||||
projectId: projectId(value.projectId),
|
||||
actionType: PLUGIN_PACKAGE_PUBLISHER_REVOCATION_ACTION_TYPE,
|
||||
permission: PLUGIN_PACKAGE_PUBLISHER_REVOCATION_PERMISSION,
|
||||
actionInput,
|
||||
actionDigest:
|
||||
pluginPackagePublisherRevocationActionDigest(actionInput),
|
||||
previewDigest:
|
||||
pluginPackagePublisherRevocationPreviewDigest(actionInput),
|
||||
proposedBy,
|
||||
proposerAssurance,
|
||||
proposalFence: normalizeApprovedActionFence(value.proposalFence),
|
||||
createdAtMs: timestamp(value.createdAtMs, 'createdAtMs'),
|
||||
});
|
||||
if (
|
||||
normalized.projectId !== actionInput.authorityProjectId ||
|
||||
digest(value.actionDigest, 'actionDigest') !==
|
||||
normalized.actionDigest ||
|
||||
digest(value.previewDigest, 'previewDigest') !==
|
||||
normalized.previewDigest ||
|
||||
digest(value.proposalDigest, 'proposalDigest') !==
|
||||
normalized.proposalDigest
|
||||
) {
|
||||
return invalid('proposal digest or derived binding is invalid');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function resolvePluginPackagePublisherRevocationProposal(
|
||||
proposalValue: PluginPackagePublisherRevocationProposal,
|
||||
dispatchValue: ApprovedActionDispatchRecord,
|
||||
revokedAtMsValue: number,
|
||||
): Readonly<PluginPackagePublisherRevocationReceipt> {
|
||||
const proposal =
|
||||
normalizePluginPackagePublisherRevocationProposal(proposalValue);
|
||||
const dispatch = normalizeApprovedActionDispatchRecord(dispatchValue);
|
||||
const revokedAtMs = timestamp(revokedAtMsValue, 'revokedAtMs');
|
||||
const action = proposal.actionInput;
|
||||
if (
|
||||
dispatch.projectId !== proposal.projectId ||
|
||||
dispatch.action.actionRef !== proposal.actionRef ||
|
||||
dispatch.action.actionType !== proposal.actionType ||
|
||||
dispatch.action.permission !== proposal.permission ||
|
||||
dispatch.action.actionDigest !== proposal.actionDigest ||
|
||||
dispatch.action.previewDigest !== proposal.previewDigest ||
|
||||
!sameSubject(dispatch.requestedBy, proposal.proposedBy) ||
|
||||
dispatch.createdAtMs < proposal.createdAtMs ||
|
||||
revokedAtMs < dispatch.createdAtMs ||
|
||||
revokedAtMs >= dispatch.expiresAtMs ||
|
||||
(action.authorizationMode === 'dual_control' &&
|
||||
sameSubject(dispatch.requestedBy, dispatch.approvedBy)) ||
|
||||
(action.authorizationMode === 'break_glass' &&
|
||||
(proposal.proposerAssurance !== 'hardware' ||
|
||||
dispatch.approvalAssurance !== 'hardware'))
|
||||
) {
|
||||
throw new PluginPackagePublisherRevocationProposalBindingConflictError();
|
||||
}
|
||||
return createPluginPackagePublisherRevocationReceipt({
|
||||
mutationId: dispatch.id,
|
||||
publisher: action.publisher,
|
||||
keyId: action.keyId,
|
||||
previousTrustDigest: action.previousTrustDigest,
|
||||
currentTrustDigest: action.currentTrustDigest,
|
||||
proposer: proposal.proposedBy,
|
||||
confirmer: dispatch.approvedBy,
|
||||
authorizationMode: action.authorizationMode,
|
||||
reasonCode: action.reasonCode,
|
||||
revokedAtMs,
|
||||
});
|
||||
}
|
||||
+604
@@ -0,0 +1,604 @@
|
||||
import { createHash, createPublicKey } from 'node:crypto';
|
||||
|
||||
import {
|
||||
MAX_PLUGIN_PACKAGE_PUBLISHER_KEYS,
|
||||
PluginPackagePublisherTrustRegistry,
|
||||
type PluginPackagePublisherKeyDefinition,
|
||||
} from '../pluginPackageBundle';
|
||||
|
||||
export const PLUGIN_PACKAGE_PUBLISHER_TRUST_SNAPSHOT_SCHEMA =
|
||||
'qinglong/plugin-package-publisher-trust-snapshot@v1' as const;
|
||||
export const PLUGIN_PACKAGE_PUBLISHER_TRUST_HEAD_SCHEMA =
|
||||
'qinglong/plugin-package-publisher-trust-head@v1' as const;
|
||||
|
||||
export interface PluginPackagePublisherTrustSnapshotKey {
|
||||
readonly publisher: string;
|
||||
readonly keyId: string;
|
||||
readonly publicKeyDigest: string;
|
||||
readonly notBeforeMs: number;
|
||||
readonly notAfterMs: number;
|
||||
}
|
||||
|
||||
export interface PluginPackagePublisherTrustSnapshot {
|
||||
readonly schema: typeof PLUGIN_PACKAGE_PUBLISHER_TRUST_SNAPSHOT_SCHEMA;
|
||||
readonly keys: readonly Readonly<PluginPackagePublisherTrustSnapshotKey>[];
|
||||
readonly snapshotDigest: string;
|
||||
}
|
||||
|
||||
export interface PluginPackagePublisherTrustKeyRef {
|
||||
readonly publisher: string;
|
||||
readonly keyId: string;
|
||||
}
|
||||
|
||||
export interface PluginPackagePublisherTrustHead {
|
||||
readonly schema: typeof PLUGIN_PACKAGE_PUBLISHER_TRUST_HEAD_SCHEMA;
|
||||
readonly authorityId: string;
|
||||
readonly generation: number;
|
||||
readonly baseSnapshotDigest: string;
|
||||
readonly effectiveTrustDigest: string;
|
||||
readonly updatedAtMs: number;
|
||||
readonly headDigest: string;
|
||||
}
|
||||
|
||||
export interface PluginPackagePublisherTrustAuthorityState {
|
||||
readonly head: Readonly<PluginPackagePublisherTrustHead>;
|
||||
readonly effectiveSnapshot: Readonly<PluginPackagePublisherTrustSnapshot>;
|
||||
}
|
||||
|
||||
export interface ObservePluginPackagePublisherTrustSnapshotInput {
|
||||
readonly authorityId: string;
|
||||
readonly snapshot: PluginPackagePublisherTrustSnapshot;
|
||||
readonly observedBy: string;
|
||||
readonly observedAtMs: number;
|
||||
}
|
||||
|
||||
export interface ObservePluginPackagePublisherTrustSnapshotResult
|
||||
extends PluginPackagePublisherTrustAuthorityState {
|
||||
readonly status: 'created' | 'existing' | 'candidate';
|
||||
}
|
||||
|
||||
export interface PluginPackagePublisherTrustAuthorityRepository {
|
||||
findAuthority(
|
||||
authorityId: string,
|
||||
): Promise<Readonly<PluginPackagePublisherTrustAuthorityState> | null>;
|
||||
observeSnapshot(
|
||||
input: ObservePluginPackagePublisherTrustSnapshotInput,
|
||||
): Promise<Readonly<ObservePluginPackagePublisherTrustSnapshotResult>>;
|
||||
}
|
||||
|
||||
export class InvalidPluginPackagePublisherTrustSnapshotError extends TypeError {
|
||||
readonly code = 'PLUGIN_PACKAGE_PUBLISHER_TRUST_SNAPSHOT_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Plugin Package publisher trust snapshot is invalid: ${message}`);
|
||||
this.name = 'InvalidPluginPackagePublisherTrustSnapshotError';
|
||||
}
|
||||
}
|
||||
|
||||
export class PluginPackagePublisherTrustAuthorityConflictError extends Error {
|
||||
readonly code = 'PLUGIN_PACKAGE_PUBLISHER_TRUST_AUTHORITY_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super(
|
||||
'Plugin Package publisher trust snapshot conflicts with durable authority',
|
||||
);
|
||||
this.name = 'PluginPackagePublisherTrustAuthorityConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class PluginPackagePublisherTrustAuthorityUnavailableError extends Error {
|
||||
readonly code = 'PLUGIN_PACKAGE_PUBLISHER_TRUST_AUTHORITY_UNAVAILABLE';
|
||||
|
||||
constructor(options?: ErrorOptions) {
|
||||
super('Plugin Package publisher trust authority is unavailable', options);
|
||||
this.name = 'PluginPackagePublisherTrustAuthorityUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const PUBLISHER_PATTERN =
|
||||
/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/;
|
||||
const TRUST_DIGEST_DOMAIN =
|
||||
'qinglong/plugin-package-publisher-trust-keyset-digest@v1\0';
|
||||
const TRUST_HEAD_DIGEST_DOMAIN =
|
||||
'qinglong/plugin-package-publisher-trust-head-digest@v1\0';
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new InvalidPluginPackagePublisherTrustSnapshotError(message);
|
||||
}
|
||||
|
||||
function exactObject(
|
||||
value: unknown,
|
||||
keys: readonly string[],
|
||||
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`);
|
||||
}
|
||||
const actual = Reflect.ownKeys(value);
|
||||
const expected = [...keys].sort();
|
||||
if (
|
||||
actual.some((key) => typeof key !== 'string') ||
|
||||
actual.length !== expected.length ||
|
||||
actual
|
||||
.map(String)
|
||||
.sort()
|
||||
.some((key, index) => key !== expected[index])
|
||||
) {
|
||||
return invalid(`${label} shape is invalid`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function publisher(value: unknown): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
Buffer.byteLength(value, 'utf8') > 253 ||
|
||||
!PUBLISHER_PATTERN.test(value)
|
||||
) {
|
||||
return invalid('publisher is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function keyId(value: unknown): string {
|
||||
if (typeof value !== 'string' || !IDENTIFIER_PATTERN.test(value)) {
|
||||
return invalid('keyId is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function timestamp(value: unknown, label: string): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown, label: string): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 1) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function digest(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function compareKeys(
|
||||
left: Readonly<PluginPackagePublisherTrustSnapshotKey>,
|
||||
right: Readonly<PluginPackagePublisherTrustSnapshotKey>,
|
||||
): number {
|
||||
return (
|
||||
Buffer.compare(
|
||||
Buffer.from(left.publisher, 'utf8'),
|
||||
Buffer.from(right.publisher, 'utf8'),
|
||||
) ||
|
||||
Buffer.compare(
|
||||
Buffer.from(left.keyId, 'utf8'),
|
||||
Buffer.from(right.keyId, 'utf8'),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeKey(
|
||||
value: PluginPackagePublisherTrustSnapshotKey,
|
||||
): Readonly<PluginPackagePublisherTrustSnapshotKey> {
|
||||
exactObject(
|
||||
value,
|
||||
[
|
||||
'publisher',
|
||||
'keyId',
|
||||
'publicKeyDigest',
|
||||
'notBeforeMs',
|
||||
'notAfterMs',
|
||||
],
|
||||
'snapshot key',
|
||||
);
|
||||
const notBeforeMs = timestamp(value.notBeforeMs, 'notBeforeMs');
|
||||
const notAfterMs = timestamp(value.notAfterMs, 'notAfterMs');
|
||||
if (notAfterMs <= notBeforeMs) {
|
||||
return invalid('key lifetime is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
publisher: publisher(value.publisher),
|
||||
keyId: keyId(value.keyId),
|
||||
publicKeyDigest: digest(value.publicKeyDigest, 'publicKeyDigest'),
|
||||
notBeforeMs,
|
||||
notAfterMs,
|
||||
});
|
||||
}
|
||||
|
||||
export function pluginPackagePublisherTrustKeysetDigest(
|
||||
keysValue: readonly Readonly<PluginPackagePublisherTrustSnapshotKey>[],
|
||||
): string {
|
||||
if (
|
||||
!Array.isArray(keysValue) ||
|
||||
Object.keys(keysValue).some((key, index) => key !== String(index))
|
||||
) {
|
||||
return invalid('keyset is invalid');
|
||||
}
|
||||
const keys = keysValue.map(normalizeKey);
|
||||
if (
|
||||
keys.some(
|
||||
(key, index) => index > 0 && compareKeys(keys[index - 1]!, key) >= 0,
|
||||
)
|
||||
) {
|
||||
return invalid('keyset must be unique and sorted');
|
||||
}
|
||||
return createHash('sha256')
|
||||
.update(TRUST_DIGEST_DOMAIN)
|
||||
.update(JSON.stringify(keys))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
export function normalizePluginPackagePublisherTrustSnapshot(
|
||||
value: PluginPackagePublisherTrustSnapshot,
|
||||
): Readonly<PluginPackagePublisherTrustSnapshot> {
|
||||
exactObject(value, ['schema', 'keys', 'snapshotDigest'], 'snapshot');
|
||||
if (
|
||||
value.schema !== PLUGIN_PACKAGE_PUBLISHER_TRUST_SNAPSHOT_SCHEMA ||
|
||||
!Array.isArray(value.keys) ||
|
||||
value.keys.length > MAX_PLUGIN_PACKAGE_PUBLISHER_KEYS ||
|
||||
Object.keys(value.keys).some((key, index) => key !== String(index))
|
||||
) {
|
||||
return invalid('snapshot schema or keys are invalid');
|
||||
}
|
||||
const keys = Object.freeze(value.keys.map(normalizeKey));
|
||||
const snapshotDigest = pluginPackagePublisherTrustKeysetDigest(keys);
|
||||
if (value.snapshotDigest !== snapshotDigest) {
|
||||
return invalid('snapshotDigest does not match keys');
|
||||
}
|
||||
return Object.freeze({
|
||||
schema: PLUGIN_PACKAGE_PUBLISHER_TRUST_SNAPSHOT_SCHEMA,
|
||||
keys,
|
||||
snapshotDigest,
|
||||
});
|
||||
}
|
||||
|
||||
export function createPluginPackagePublisherTrustSnapshot(
|
||||
definitions: readonly Readonly<PluginPackagePublisherKeyDefinition>[],
|
||||
): Readonly<PluginPackagePublisherTrustSnapshot> {
|
||||
try {
|
||||
new PluginPackagePublisherTrustRegistry(definitions);
|
||||
} catch {
|
||||
return invalid('publisher key definitions are invalid');
|
||||
}
|
||||
const keys = Object.freeze(
|
||||
definitions
|
||||
.map((definition) => {
|
||||
let publicKeyDigest: string;
|
||||
try {
|
||||
publicKeyDigest = createHash('sha256')
|
||||
.update(
|
||||
createPublicKey(definition.publicKeyPem).export({
|
||||
type: 'spki',
|
||||
format: 'der',
|
||||
}),
|
||||
)
|
||||
.digest('hex');
|
||||
} catch {
|
||||
return invalid('publisher public key is invalid');
|
||||
}
|
||||
return normalizeKey({
|
||||
publisher: definition.publisher,
|
||||
keyId: definition.keyId,
|
||||
publicKeyDigest,
|
||||
notBeforeMs: definition.notBeforeMs,
|
||||
notAfterMs: definition.notAfterMs,
|
||||
});
|
||||
})
|
||||
.sort(compareKeys),
|
||||
);
|
||||
const unsigned = Object.freeze({
|
||||
schema: PLUGIN_PACKAGE_PUBLISHER_TRUST_SNAPSHOT_SCHEMA,
|
||||
keys,
|
||||
});
|
||||
return normalizePluginPackagePublisherTrustSnapshot({
|
||||
...unsigned,
|
||||
snapshotDigest: pluginPackagePublisherTrustKeysetDigest(keys),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Treats the mounted document as public-key material only and lets the durable
|
||||
* effective snapshot decide which identities are trusted. Extra candidate
|
||||
* keys in the document are deliberately excluded.
|
||||
*/
|
||||
export function createPluginPackagePublisherEffectiveTrustRegistry(
|
||||
definitions: readonly Readonly<PluginPackagePublisherKeyDefinition>[],
|
||||
effectiveSnapshotValue: PluginPackagePublisherTrustSnapshot,
|
||||
): PluginPackagePublisherTrustRegistry {
|
||||
const materialSnapshot =
|
||||
createPluginPackagePublisherTrustSnapshot(definitions);
|
||||
const effectiveSnapshot =
|
||||
normalizePluginPackagePublisherTrustSnapshot(effectiveSnapshotValue);
|
||||
const definitionsByIdentity = new Map(
|
||||
definitions.map((definition) => [
|
||||
`${definition.publisher}\0${definition.keyId}`,
|
||||
definition,
|
||||
]),
|
||||
);
|
||||
const materialKeys = new Map(
|
||||
materialSnapshot.keys.map((key) => [
|
||||
`${key.publisher}\0${key.keyId}`,
|
||||
Object.freeze({
|
||||
key,
|
||||
definition: definitionsByIdentity.get(
|
||||
`${key.publisher}\0${key.keyId}`,
|
||||
),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const selected: PluginPackagePublisherKeyDefinition[] = [];
|
||||
for (const effectiveKey of effectiveSnapshot.keys) {
|
||||
const material = materialKeys.get(
|
||||
`${effectiveKey.publisher}\0${effectiveKey.keyId}`,
|
||||
);
|
||||
if (
|
||||
!material ||
|
||||
JSON.stringify(material.key) !== JSON.stringify(effectiveKey) ||
|
||||
!material.definition
|
||||
) {
|
||||
return invalid(
|
||||
'effective trust snapshot is not backed by mounted key material',
|
||||
);
|
||||
}
|
||||
selected.push({ ...material.definition });
|
||||
}
|
||||
if (selected.length < 1) {
|
||||
return invalid('effective trust snapshot must contain one key');
|
||||
}
|
||||
return new PluginPackagePublisherTrustRegistry(selected);
|
||||
}
|
||||
|
||||
function trustSnapshotFromKeys(
|
||||
keys: readonly Readonly<PluginPackagePublisherTrustSnapshotKey>[],
|
||||
): Readonly<PluginPackagePublisherTrustSnapshot> {
|
||||
const canonical = Object.freeze([...keys].sort(compareKeys));
|
||||
return normalizePluginPackagePublisherTrustSnapshot({
|
||||
schema: PLUGIN_PACKAGE_PUBLISHER_TRUST_SNAPSHOT_SCHEMA,
|
||||
keys: canonical,
|
||||
snapshotDigest: pluginPackagePublisherTrustKeysetDigest(canonical),
|
||||
});
|
||||
}
|
||||
|
||||
export function createPluginPackagePublisherTrustOverlapAdditionSnapshot(
|
||||
currentValue: PluginPackagePublisherTrustSnapshot,
|
||||
candidateValue: PluginPackagePublisherTrustSnapshot,
|
||||
publisherValue: string,
|
||||
keyIdValue: string,
|
||||
observedAtMsValue: number,
|
||||
): Readonly<PluginPackagePublisherTrustSnapshot> {
|
||||
const current =
|
||||
normalizePluginPackagePublisherTrustSnapshot(currentValue);
|
||||
const candidate =
|
||||
normalizePluginPackagePublisherTrustSnapshot(candidateValue);
|
||||
const targetPublisher = publisher(publisherValue);
|
||||
const targetKeyId = keyId(keyIdValue);
|
||||
const observedAtMs = timestamp(observedAtMsValue, 'observedAtMs');
|
||||
const currentKeys = new Map(
|
||||
current.keys.map((key) => [`${key.publisher}\0${key.keyId}`, key]),
|
||||
);
|
||||
const added = candidate.keys.filter(
|
||||
(key) => !currentKeys.has(`${key.publisher}\0${key.keyId}`),
|
||||
);
|
||||
if (
|
||||
candidate.keys.length !== current.keys.length + 1 ||
|
||||
current.keys.some((key) => {
|
||||
const next = candidate.keys.find(
|
||||
(candidateKey) =>
|
||||
candidateKey.publisher === key.publisher &&
|
||||
candidateKey.keyId === key.keyId,
|
||||
);
|
||||
return !next || JSON.stringify(next) !== JSON.stringify(key);
|
||||
}) ||
|
||||
added.length !== 1 ||
|
||||
added[0]!.publisher !== targetPublisher ||
|
||||
added[0]!.keyId !== targetKeyId ||
|
||||
added[0]!.notBeforeMs > observedAtMs ||
|
||||
observedAtMs >= added[0]!.notAfterMs
|
||||
) {
|
||||
return invalid(
|
||||
'overlap addition must preserve every key and add one active target',
|
||||
);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export function createPluginPackagePublisherTrustRetirementSnapshot(
|
||||
currentValue: PluginPackagePublisherTrustSnapshot,
|
||||
publisherValue: string,
|
||||
keyIdValue: string,
|
||||
observedAtMsValue: number,
|
||||
): Readonly<PluginPackagePublisherTrustSnapshot> {
|
||||
const current =
|
||||
normalizePluginPackagePublisherTrustSnapshot(currentValue);
|
||||
const targetPublisher = publisher(publisherValue);
|
||||
const targetKeyId = keyId(keyIdValue);
|
||||
const observedAtMs = timestamp(observedAtMsValue, 'observedAtMs');
|
||||
const remaining = current.keys.filter(
|
||||
(key) =>
|
||||
key.publisher !== targetPublisher || key.keyId !== targetKeyId,
|
||||
);
|
||||
if (
|
||||
remaining.length !== current.keys.length - 1 ||
|
||||
!remaining.some(
|
||||
(key) =>
|
||||
key.publisher === targetPublisher &&
|
||||
key.notBeforeMs <= observedAtMs &&
|
||||
observedAtMs < key.notAfterMs,
|
||||
)
|
||||
) {
|
||||
return invalid(
|
||||
'retirement must remove one target and retain an active publisher key',
|
||||
);
|
||||
}
|
||||
return trustSnapshotFromKeys(remaining);
|
||||
}
|
||||
|
||||
export function createPluginPackagePublisherEffectiveTrustSnapshot(
|
||||
snapshotValue: PluginPackagePublisherTrustSnapshot,
|
||||
revokedValue: readonly Readonly<PluginPackagePublisherTrustKeyRef>[],
|
||||
): Readonly<PluginPackagePublisherTrustSnapshot> {
|
||||
const snapshot =
|
||||
normalizePluginPackagePublisherTrustSnapshot(snapshotValue);
|
||||
if (
|
||||
!Array.isArray(revokedValue) ||
|
||||
Object.keys(revokedValue).some((key, index) => key !== String(index))
|
||||
) {
|
||||
return invalid('revoked key references are invalid');
|
||||
}
|
||||
const revoked = new Set<string>();
|
||||
for (const value of revokedValue) {
|
||||
exactObject(value, ['publisher', 'keyId'], 'revoked key reference');
|
||||
const identity = `${publisher(value.publisher)}\0${keyId(value.keyId)}`;
|
||||
if (revoked.has(identity)) {
|
||||
return invalid('revoked key references must be unique');
|
||||
}
|
||||
revoked.add(identity);
|
||||
}
|
||||
const keys = Object.freeze(
|
||||
snapshot.keys.filter(
|
||||
(key) => !revoked.has(`${key.publisher}\0${key.keyId}`),
|
||||
),
|
||||
);
|
||||
return normalizePluginPackagePublisherTrustSnapshot({
|
||||
schema: PLUGIN_PACKAGE_PUBLISHER_TRUST_SNAPSHOT_SCHEMA,
|
||||
keys,
|
||||
snapshotDigest: pluginPackagePublisherTrustKeysetDigest(keys),
|
||||
});
|
||||
}
|
||||
|
||||
function trustHeadDigest(
|
||||
value: Omit<PluginPackagePublisherTrustHead, 'headDigest'>,
|
||||
): string {
|
||||
return createHash('sha256')
|
||||
.update(TRUST_HEAD_DIGEST_DOMAIN)
|
||||
.update(JSON.stringify(value))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
export function normalizePluginPackagePublisherTrustHead(
|
||||
value: PluginPackagePublisherTrustHead,
|
||||
): Readonly<PluginPackagePublisherTrustHead> {
|
||||
exactObject(
|
||||
value,
|
||||
[
|
||||
'schema',
|
||||
'authorityId',
|
||||
'generation',
|
||||
'baseSnapshotDigest',
|
||||
'effectiveTrustDigest',
|
||||
'updatedAtMs',
|
||||
'headDigest',
|
||||
],
|
||||
'trust head',
|
||||
);
|
||||
if (value.schema !== PLUGIN_PACKAGE_PUBLISHER_TRUST_HEAD_SCHEMA) {
|
||||
return invalid('trust head schema is invalid');
|
||||
}
|
||||
const normalized = Object.freeze({
|
||||
schema: PLUGIN_PACKAGE_PUBLISHER_TRUST_HEAD_SCHEMA,
|
||||
authorityId: keyId(value.authorityId),
|
||||
generation: positiveInteger(value.generation, 'generation'),
|
||||
baseSnapshotDigest: digest(
|
||||
value.baseSnapshotDigest,
|
||||
'baseSnapshotDigest',
|
||||
),
|
||||
effectiveTrustDigest: digest(
|
||||
value.effectiveTrustDigest,
|
||||
'effectiveTrustDigest',
|
||||
),
|
||||
updatedAtMs: timestamp(value.updatedAtMs, 'updatedAtMs'),
|
||||
});
|
||||
const headDigest = trustHeadDigest(normalized);
|
||||
if (value.headDigest !== headDigest) {
|
||||
return invalid('headDigest does not match trust head');
|
||||
}
|
||||
return Object.freeze({ ...normalized, headDigest });
|
||||
}
|
||||
|
||||
export function createPluginPackagePublisherTrustHead(
|
||||
authorityIdValue: string,
|
||||
snapshotValue: PluginPackagePublisherTrustSnapshot,
|
||||
updatedAtMsValue: number,
|
||||
): Readonly<PluginPackagePublisherTrustHead> {
|
||||
const snapshot =
|
||||
normalizePluginPackagePublisherTrustSnapshot(snapshotValue);
|
||||
if (snapshot.keys.length < 1) {
|
||||
return invalid('base trust snapshot must contain one key');
|
||||
}
|
||||
const unsigned = Object.freeze({
|
||||
schema: PLUGIN_PACKAGE_PUBLISHER_TRUST_HEAD_SCHEMA,
|
||||
authorityId: keyId(authorityIdValue),
|
||||
generation: 1,
|
||||
baseSnapshotDigest: snapshot.snapshotDigest,
|
||||
effectiveTrustDigest: snapshot.snapshotDigest,
|
||||
updatedAtMs: timestamp(updatedAtMsValue, 'updatedAtMs'),
|
||||
});
|
||||
return normalizePluginPackagePublisherTrustHead({
|
||||
...unsigned,
|
||||
headDigest: trustHeadDigest(unsigned),
|
||||
});
|
||||
}
|
||||
|
||||
export function advancePluginPackagePublisherTrustHead(
|
||||
headValue: PluginPackagePublisherTrustHead,
|
||||
effectiveSnapshotValue: PluginPackagePublisherTrustSnapshot,
|
||||
updatedAtMsValue: number,
|
||||
): Readonly<PluginPackagePublisherTrustHead> {
|
||||
const head = normalizePluginPackagePublisherTrustHead(headValue);
|
||||
const effectiveSnapshot =
|
||||
normalizePluginPackagePublisherTrustSnapshot(effectiveSnapshotValue);
|
||||
const updatedAtMs = timestamp(updatedAtMsValue, 'updatedAtMs');
|
||||
if (
|
||||
effectiveSnapshot.snapshotDigest === head.effectiveTrustDigest ||
|
||||
updatedAtMs < head.updatedAtMs ||
|
||||
head.generation >= 2_147_483_647
|
||||
) {
|
||||
return invalid('trust head transition is invalid');
|
||||
}
|
||||
const unsigned = Object.freeze({
|
||||
schema: PLUGIN_PACKAGE_PUBLISHER_TRUST_HEAD_SCHEMA,
|
||||
authorityId: head.authorityId,
|
||||
generation: head.generation + 1,
|
||||
baseSnapshotDigest: head.baseSnapshotDigest,
|
||||
effectiveTrustDigest: effectiveSnapshot.snapshotDigest,
|
||||
updatedAtMs,
|
||||
});
|
||||
return normalizePluginPackagePublisherTrustHead({
|
||||
...unsigned,
|
||||
headDigest: trustHeadDigest(unsigned),
|
||||
});
|
||||
}
|
||||
|
||||
export function pluginPackagePublisherTrustRevokedDigest(
|
||||
snapshotValue: PluginPackagePublisherTrustSnapshot,
|
||||
publisherValue: string,
|
||||
keyIdValue: string,
|
||||
): string {
|
||||
const snapshot =
|
||||
normalizePluginPackagePublisherTrustSnapshot(snapshotValue);
|
||||
const targetPublisher = publisher(publisherValue);
|
||||
const targetKeyId = keyId(keyIdValue);
|
||||
const remaining = snapshot.keys.filter(
|
||||
(key) =>
|
||||
key.publisher !== targetPublisher || key.keyId !== targetKeyId,
|
||||
);
|
||||
if (remaining.length === snapshot.keys.length) {
|
||||
return invalid('revoked key is absent from the trust snapshot');
|
||||
}
|
||||
return pluginPackagePublisherTrustKeysetDigest(remaining);
|
||||
}
|
||||
+708
@@ -0,0 +1,708 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import {
|
||||
normalizeApprovedActionDispatchRecord,
|
||||
normalizeApprovedActionFence,
|
||||
type ApprovedActionDispatchRecord,
|
||||
} from '../../approved-action/approvedAction';
|
||||
import {
|
||||
createPluginPackagePublisherTrustOverlapAdditionSnapshot,
|
||||
createPluginPackagePublisherTrustRetirementSnapshot,
|
||||
normalizePluginPackagePublisherTrustSnapshot,
|
||||
type PluginPackagePublisherTrustSnapshot,
|
||||
} from './pluginPackagePublisherTrust';
|
||||
import { normalizeProjectPolicySubject } from '../../security/project-policy/projectPolicy';
|
||||
import {
|
||||
type SecurityAuthenticationAssurance,
|
||||
type SecurityPolicyFence,
|
||||
type SecuritySubject,
|
||||
} from '../../security/security';
|
||||
import type { SecurityAuditRecord } from '../../security/audit/securityAudit';
|
||||
|
||||
export const PLUGIN_PACKAGE_PUBLISHER_TRUST_TRANSITION_PROPOSAL_SCHEMA =
|
||||
'qinglong/plugin-package-publisher-trust-transition-proposal@v1' as const;
|
||||
export const PLUGIN_PACKAGE_PUBLISHER_TRUST_TRANSITION_RECEIPT_SCHEMA =
|
||||
'qinglong/plugin-package-publisher-trust-transition-receipt@v1' as const;
|
||||
export const PLUGIN_PACKAGE_PUBLISHER_TRUST_TRANSITION_PERMISSION =
|
||||
'package.manage' as const;
|
||||
export const PLUGIN_PACKAGE_PUBLISHER_TRUST_TRANSITION_ACTION_TYPES =
|
||||
Object.freeze({
|
||||
overlap_add: 'plugin_package.publisher_key.overlap_add',
|
||||
safe_retire: 'plugin_package.publisher_key.safe_retire',
|
||||
} as const);
|
||||
|
||||
export type PluginPackagePublisherTrustTransitionMode =
|
||||
keyof typeof PLUGIN_PACKAGE_PUBLISHER_TRUST_TRANSITION_ACTION_TYPES;
|
||||
export type PluginPackagePublisherTrustTransitionActionType =
|
||||
(typeof PLUGIN_PACKAGE_PUBLISHER_TRUST_TRANSITION_ACTION_TYPES)[PluginPackagePublisherTrustTransitionMode];
|
||||
|
||||
export interface PluginPackagePublisherTrustTransitionActionInput {
|
||||
readonly authorityProjectId: string;
|
||||
readonly trustAuthorityId: string;
|
||||
readonly trustGeneration: number;
|
||||
readonly mode: PluginPackagePublisherTrustTransitionMode;
|
||||
readonly publisher: string;
|
||||
readonly keyId: string;
|
||||
readonly previousTrustDigest: string;
|
||||
readonly currentTrustDigest: string;
|
||||
}
|
||||
|
||||
export interface PluginPackagePublisherTrustTransitionProposal {
|
||||
readonly schema: typeof PLUGIN_PACKAGE_PUBLISHER_TRUST_TRANSITION_PROPOSAL_SCHEMA;
|
||||
readonly actionRef: string;
|
||||
readonly projectId: string;
|
||||
readonly actionType: PluginPackagePublisherTrustTransitionActionType;
|
||||
readonly permission: typeof PLUGIN_PACKAGE_PUBLISHER_TRUST_TRANSITION_PERMISSION;
|
||||
readonly actionInput: Readonly<PluginPackagePublisherTrustTransitionActionInput>;
|
||||
readonly actionDigest: string;
|
||||
readonly previewDigest: string;
|
||||
readonly proposedBy: Readonly<SecuritySubject>;
|
||||
readonly proposerAssurance: Extract<
|
||||
SecurityAuthenticationAssurance,
|
||||
'multi_factor' | 'hardware'
|
||||
>;
|
||||
readonly proposalFence: Readonly<SecurityPolicyFence>;
|
||||
readonly createdAtMs: number;
|
||||
readonly proposalDigest: string;
|
||||
}
|
||||
|
||||
export interface PluginPackagePublisherTrustTransitionReceipt {
|
||||
readonly schema: typeof PLUGIN_PACKAGE_PUBLISHER_TRUST_TRANSITION_RECEIPT_SCHEMA;
|
||||
readonly mutationId: string;
|
||||
readonly proposalDigest: string;
|
||||
readonly trustAuthorityId: string;
|
||||
readonly previousGeneration: number;
|
||||
readonly currentGeneration: number;
|
||||
readonly mode: PluginPackagePublisherTrustTransitionMode;
|
||||
readonly publisher: string;
|
||||
readonly keyId: string;
|
||||
readonly previousTrustDigest: string;
|
||||
readonly currentTrustDigest: string;
|
||||
readonly proposer: Readonly<SecuritySubject>;
|
||||
readonly confirmer: Readonly<SecuritySubject>;
|
||||
readonly retirementMatchingInstallations: 0 | null;
|
||||
readonly executedAtMs: number;
|
||||
readonly receiptDigest: string;
|
||||
}
|
||||
|
||||
export type CreatePluginPackagePublisherTrustTransitionProposalInput =
|
||||
Readonly<{
|
||||
actionRef: string;
|
||||
authorityProjectId: string;
|
||||
trustAuthorityId: string;
|
||||
trustGeneration: number;
|
||||
mode: PluginPackagePublisherTrustTransitionMode;
|
||||
trustSnapshot: PluginPackagePublisherTrustSnapshot;
|
||||
materialSnapshot?: PluginPackagePublisherTrustSnapshot;
|
||||
publisher: string;
|
||||
keyId: string;
|
||||
proposedBy: SecuritySubject;
|
||||
proposerAssurance: SecurityAuthenticationAssurance;
|
||||
proposalFence: SecurityPolicyFence;
|
||||
createdAtMs: number;
|
||||
}>;
|
||||
|
||||
export interface CreatePluginPackagePublisherTrustTransitionProposalCommand {
|
||||
readonly proposal: PluginPackagePublisherTrustTransitionProposal;
|
||||
readonly candidateSnapshot: PluginPackagePublisherTrustSnapshot;
|
||||
readonly audit: SecurityAuditRecord;
|
||||
}
|
||||
|
||||
export interface CreatePluginPackagePublisherTrustTransitionProposalResult {
|
||||
readonly status: 'created' | 'existing';
|
||||
readonly proposal: Readonly<PluginPackagePublisherTrustTransitionProposal>;
|
||||
}
|
||||
|
||||
export interface PluginPackagePublisherTrustTransitionProposalRepository {
|
||||
findProposalByActionRef(
|
||||
actionRef: string,
|
||||
): Promise<Readonly<PluginPackagePublisherTrustTransitionProposal> | null>;
|
||||
createProposal(
|
||||
command: CreatePluginPackagePublisherTrustTransitionProposalCommand,
|
||||
): Promise<
|
||||
Readonly<CreatePluginPackagePublisherTrustTransitionProposalResult>
|
||||
>;
|
||||
}
|
||||
|
||||
export class InvalidPluginPackagePublisherTrustTransitionError extends TypeError {
|
||||
readonly code = 'PLUGIN_PACKAGE_PUBLISHER_TRUST_TRANSITION_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`Plugin Package publisher trust transition is invalid: ${message}`);
|
||||
this.name = 'InvalidPluginPackagePublisherTrustTransitionError';
|
||||
}
|
||||
}
|
||||
|
||||
export class PluginPackagePublisherTrustTransitionBindingConflictError extends Error {
|
||||
readonly code =
|
||||
'PLUGIN_PACKAGE_PUBLISHER_TRUST_TRANSITION_BINDING_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super(
|
||||
'Plugin Package publisher trust transition does not match its dispatch',
|
||||
);
|
||||
this.name =
|
||||
'PluginPackagePublisherTrustTransitionBindingConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class PluginPackagePublisherTrustTransitionConflictError extends Error {
|
||||
readonly code = 'PLUGIN_PACKAGE_PUBLISHER_TRUST_TRANSITION_CONFLICT';
|
||||
|
||||
constructor() {
|
||||
super(
|
||||
'Plugin Package publisher trust transition conflicts with durable authority',
|
||||
);
|
||||
this.name = 'PluginPackagePublisherTrustTransitionConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class PluginPackagePublisherTrustTransitionUnavailableError extends Error {
|
||||
readonly code =
|
||||
'PLUGIN_PACKAGE_PUBLISHER_TRUST_TRANSITION_UNAVAILABLE';
|
||||
|
||||
constructor(options?: ErrorOptions) {
|
||||
super(
|
||||
'Plugin Package publisher trust transition authority is unavailable',
|
||||
options,
|
||||
);
|
||||
this.name = 'PluginPackagePublisherTrustTransitionUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
const ACTION_REF_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}$/;
|
||||
const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const PUBLISHER_PATTERN =
|
||||
/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/;
|
||||
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
|
||||
function invalid(message: string): never {
|
||||
throw new InvalidPluginPackagePublisherTrustTransitionError(message);
|
||||
}
|
||||
|
||||
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`);
|
||||
}
|
||||
const descriptors = Object.getOwnPropertyDescriptors(value);
|
||||
if (
|
||||
Object.values(descriptors).some(
|
||||
({ get, set, enumerable }) =>
|
||||
get !== undefined || set !== undefined || enumerable !== true,
|
||||
)
|
||||
) {
|
||||
return invalid(`${label} must contain enumerable data properties`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function exactKeys(
|
||||
value: object,
|
||||
expected: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Reflect.ownKeys(value);
|
||||
const canonical = [...expected].sort();
|
||||
if (
|
||||
actual.some((key) => typeof key !== 'string') ||
|
||||
actual.length !== canonical.length ||
|
||||
actual
|
||||
.map(String)
|
||||
.sort()
|
||||
.some((key, index) => key !== canonical[index])
|
||||
) {
|
||||
invalid(`${label} shape is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function actionRef(value: unknown): string {
|
||||
if (typeof value !== 'string' || !ACTION_REF_PATTERN.test(value)) {
|
||||
return invalid('actionRef is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function identifier(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !IDENTIFIER_PATTERN.test(value)) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function publisher(value: unknown): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
Buffer.byteLength(value, 'utf8') > 253 ||
|
||||
!PUBLISHER_PATTERN.test(value)
|
||||
) {
|
||||
return invalid('publisher is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function projectId(value: unknown): string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length < 1 ||
|
||||
Buffer.byteLength(value, 'utf8') > 128 ||
|
||||
value.includes('\0')
|
||||
) {
|
||||
return invalid('authorityProjectId is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function digest(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function timestamp(value: unknown, label: string): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown, label: string): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 1) {
|
||||
return invalid(`${label} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function mode(value: unknown): PluginPackagePublisherTrustTransitionMode {
|
||||
if (value !== 'overlap_add' && value !== 'safe_retire') {
|
||||
return invalid('mode is invalid');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function strongAssurance(
|
||||
value: unknown,
|
||||
): 'multi_factor' | 'hardware' {
|
||||
if (value !== 'multi_factor' && value !== 'hardware') {
|
||||
return invalid('proposerAssurance must be multi_factor or hardware');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function userSubject(
|
||||
value: SecuritySubject,
|
||||
label: string,
|
||||
): Readonly<SecuritySubject> {
|
||||
const normalized = normalizeProjectPolicySubject(value);
|
||||
if (normalized.type !== 'user') {
|
||||
return invalid(`${label} must be a User`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function sameSubject(
|
||||
left: Readonly<SecuritySubject>,
|
||||
right: Readonly<SecuritySubject>,
|
||||
): boolean {
|
||||
return left.type === right.type && left.id === right.id;
|
||||
}
|
||||
|
||||
function contractDigest(domain: string, value: unknown): string {
|
||||
return createHash('sha256')
|
||||
.update(domain)
|
||||
.update('\0')
|
||||
.update(JSON.stringify(value))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function normalizeActionInput(
|
||||
value: PluginPackagePublisherTrustTransitionActionInput,
|
||||
): Readonly<PluginPackagePublisherTrustTransitionActionInput> {
|
||||
const record = dataRecord(value, 'action input');
|
||||
exactKeys(
|
||||
record,
|
||||
[
|
||||
'authorityProjectId',
|
||||
'trustAuthorityId',
|
||||
'trustGeneration',
|
||||
'mode',
|
||||
'publisher',
|
||||
'keyId',
|
||||
'previousTrustDigest',
|
||||
'currentTrustDigest',
|
||||
],
|
||||
'action input',
|
||||
);
|
||||
const previousTrustDigest = digest(
|
||||
value.previousTrustDigest,
|
||||
'previousTrustDigest',
|
||||
);
|
||||
const currentTrustDigest = digest(
|
||||
value.currentTrustDigest,
|
||||
'currentTrustDigest',
|
||||
);
|
||||
if (previousTrustDigest === currentTrustDigest) {
|
||||
return invalid('transition must change the trust digest');
|
||||
}
|
||||
return Object.freeze({
|
||||
authorityProjectId: projectId(value.authorityProjectId),
|
||||
trustAuthorityId: identifier(
|
||||
value.trustAuthorityId,
|
||||
'trustAuthorityId',
|
||||
),
|
||||
trustGeneration: positiveInteger(
|
||||
value.trustGeneration,
|
||||
'trustGeneration',
|
||||
),
|
||||
mode: mode(value.mode),
|
||||
publisher: publisher(value.publisher),
|
||||
keyId: identifier(value.keyId, 'keyId'),
|
||||
previousTrustDigest,
|
||||
currentTrustDigest,
|
||||
});
|
||||
}
|
||||
|
||||
export function pluginPackagePublisherTrustTransitionActionDigest(
|
||||
value: PluginPackagePublisherTrustTransitionActionInput,
|
||||
): string {
|
||||
return contractDigest(
|
||||
'qinglong/plugin-package-publisher-trust-transition-action-digest@v1',
|
||||
normalizeActionInput(value),
|
||||
);
|
||||
}
|
||||
|
||||
export function pluginPackagePublisherTrustTransitionPreviewDigest(
|
||||
value: PluginPackagePublisherTrustTransitionActionInput,
|
||||
): string {
|
||||
return contractDigest(
|
||||
'qinglong/plugin-package-publisher-trust-transition-preview-digest@v1',
|
||||
normalizeActionInput(value),
|
||||
);
|
||||
}
|
||||
|
||||
function withProposalDigest(
|
||||
value: Omit<
|
||||
PluginPackagePublisherTrustTransitionProposal,
|
||||
'proposalDigest'
|
||||
>,
|
||||
): Readonly<PluginPackagePublisherTrustTransitionProposal> {
|
||||
const normalized = Object.freeze(value);
|
||||
return Object.freeze({
|
||||
...normalized,
|
||||
proposalDigest: contractDigest(
|
||||
'qinglong/plugin-package-publisher-trust-transition-proposal-digest@v1',
|
||||
normalized,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function createPluginPackagePublisherTrustTransitionProposal(
|
||||
inputValue: CreatePluginPackagePublisherTrustTransitionProposalInput,
|
||||
): Readonly<{
|
||||
proposal: Readonly<PluginPackagePublisherTrustTransitionProposal>;
|
||||
candidateSnapshot: Readonly<PluginPackagePublisherTrustSnapshot>;
|
||||
}> {
|
||||
const input = dataRecord(inputValue, 'proposal input');
|
||||
const optional = Object.hasOwn(input, 'materialSnapshot')
|
||||
? ['materialSnapshot']
|
||||
: [];
|
||||
exactKeys(
|
||||
input,
|
||||
[
|
||||
'actionRef',
|
||||
'authorityProjectId',
|
||||
'trustAuthorityId',
|
||||
'trustGeneration',
|
||||
'mode',
|
||||
'trustSnapshot',
|
||||
'publisher',
|
||||
'keyId',
|
||||
'proposedBy',
|
||||
'proposerAssurance',
|
||||
'proposalFence',
|
||||
'createdAtMs',
|
||||
...optional,
|
||||
],
|
||||
'proposal input',
|
||||
);
|
||||
const transitionMode = mode(inputValue.mode);
|
||||
const trustSnapshot = normalizePluginPackagePublisherTrustSnapshot(
|
||||
inputValue.trustSnapshot,
|
||||
);
|
||||
const candidateSnapshot =
|
||||
transitionMode === 'overlap_add'
|
||||
? createPluginPackagePublisherTrustOverlapAdditionSnapshot(
|
||||
trustSnapshot,
|
||||
inputValue.materialSnapshot ??
|
||||
invalid('overlap addition requires materialSnapshot'),
|
||||
inputValue.publisher,
|
||||
inputValue.keyId,
|
||||
inputValue.createdAtMs,
|
||||
)
|
||||
: (() => {
|
||||
if (inputValue.materialSnapshot !== undefined) {
|
||||
return invalid('retirement cannot accept materialSnapshot');
|
||||
}
|
||||
return createPluginPackagePublisherTrustRetirementSnapshot(
|
||||
trustSnapshot,
|
||||
inputValue.publisher,
|
||||
inputValue.keyId,
|
||||
inputValue.createdAtMs,
|
||||
);
|
||||
})();
|
||||
const actionInput = normalizeActionInput({
|
||||
authorityProjectId: inputValue.authorityProjectId,
|
||||
trustAuthorityId: inputValue.trustAuthorityId,
|
||||
trustGeneration: inputValue.trustGeneration,
|
||||
mode: transitionMode,
|
||||
publisher: inputValue.publisher,
|
||||
keyId: inputValue.keyId,
|
||||
previousTrustDigest: trustSnapshot.snapshotDigest,
|
||||
currentTrustDigest: candidateSnapshot.snapshotDigest,
|
||||
});
|
||||
const proposal = withProposalDigest({
|
||||
schema: PLUGIN_PACKAGE_PUBLISHER_TRUST_TRANSITION_PROPOSAL_SCHEMA,
|
||||
actionRef: actionRef(inputValue.actionRef),
|
||||
projectId: actionInput.authorityProjectId,
|
||||
actionType:
|
||||
PLUGIN_PACKAGE_PUBLISHER_TRUST_TRANSITION_ACTION_TYPES[
|
||||
transitionMode
|
||||
],
|
||||
permission: PLUGIN_PACKAGE_PUBLISHER_TRUST_TRANSITION_PERMISSION,
|
||||
actionInput,
|
||||
actionDigest:
|
||||
pluginPackagePublisherTrustTransitionActionDigest(actionInput),
|
||||
previewDigest:
|
||||
pluginPackagePublisherTrustTransitionPreviewDigest(actionInput),
|
||||
proposedBy: userSubject(inputValue.proposedBy, 'proposedBy'),
|
||||
proposerAssurance: strongAssurance(inputValue.proposerAssurance),
|
||||
proposalFence: normalizeApprovedActionFence(inputValue.proposalFence),
|
||||
createdAtMs: timestamp(inputValue.createdAtMs, 'createdAtMs'),
|
||||
});
|
||||
return Object.freeze({ proposal, candidateSnapshot });
|
||||
}
|
||||
|
||||
export function normalizePluginPackagePublisherTrustTransitionProposal(
|
||||
value: PluginPackagePublisherTrustTransitionProposal,
|
||||
): Readonly<PluginPackagePublisherTrustTransitionProposal> {
|
||||
const record = dataRecord(value, 'proposal');
|
||||
exactKeys(
|
||||
record,
|
||||
[
|
||||
'schema',
|
||||
'actionRef',
|
||||
'projectId',
|
||||
'actionType',
|
||||
'permission',
|
||||
'actionInput',
|
||||
'actionDigest',
|
||||
'previewDigest',
|
||||
'proposedBy',
|
||||
'proposerAssurance',
|
||||
'proposalFence',
|
||||
'createdAtMs',
|
||||
'proposalDigest',
|
||||
],
|
||||
'proposal',
|
||||
);
|
||||
const actionInput = normalizeActionInput(value.actionInput);
|
||||
if (
|
||||
value.schema !==
|
||||
PLUGIN_PACKAGE_PUBLISHER_TRUST_TRANSITION_PROPOSAL_SCHEMA ||
|
||||
value.permission !==
|
||||
PLUGIN_PACKAGE_PUBLISHER_TRUST_TRANSITION_PERMISSION ||
|
||||
value.actionType !==
|
||||
PLUGIN_PACKAGE_PUBLISHER_TRUST_TRANSITION_ACTION_TYPES[
|
||||
actionInput.mode
|
||||
]
|
||||
) {
|
||||
return invalid('schema or action authority is invalid');
|
||||
}
|
||||
const normalized = withProposalDigest({
|
||||
schema: PLUGIN_PACKAGE_PUBLISHER_TRUST_TRANSITION_PROPOSAL_SCHEMA,
|
||||
actionRef: actionRef(value.actionRef),
|
||||
projectId: projectId(value.projectId),
|
||||
actionType: value.actionType,
|
||||
permission: PLUGIN_PACKAGE_PUBLISHER_TRUST_TRANSITION_PERMISSION,
|
||||
actionInput,
|
||||
actionDigest:
|
||||
pluginPackagePublisherTrustTransitionActionDigest(actionInput),
|
||||
previewDigest:
|
||||
pluginPackagePublisherTrustTransitionPreviewDigest(actionInput),
|
||||
proposedBy: userSubject(value.proposedBy, 'proposedBy'),
|
||||
proposerAssurance: strongAssurance(value.proposerAssurance),
|
||||
proposalFence: normalizeApprovedActionFence(value.proposalFence),
|
||||
createdAtMs: timestamp(value.createdAtMs, 'createdAtMs'),
|
||||
});
|
||||
if (
|
||||
normalized.projectId !== actionInput.authorityProjectId ||
|
||||
digest(value.actionDigest, 'actionDigest') !==
|
||||
normalized.actionDigest ||
|
||||
digest(value.previewDigest, 'previewDigest') !==
|
||||
normalized.previewDigest ||
|
||||
digest(value.proposalDigest, 'proposalDigest') !==
|
||||
normalized.proposalDigest
|
||||
) {
|
||||
return invalid('proposal digest or derived binding is invalid');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function withReceiptDigest(
|
||||
value: Omit<
|
||||
PluginPackagePublisherTrustTransitionReceipt,
|
||||
'receiptDigest'
|
||||
>,
|
||||
): Readonly<PluginPackagePublisherTrustTransitionReceipt> {
|
||||
const normalized = Object.freeze(value);
|
||||
return Object.freeze({
|
||||
...normalized,
|
||||
receiptDigest: contractDigest(
|
||||
'qinglong/plugin-package-publisher-trust-transition-receipt-digest@v1',
|
||||
normalized,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizePluginPackagePublisherTrustTransitionReceipt(
|
||||
value: PluginPackagePublisherTrustTransitionReceipt,
|
||||
): Readonly<PluginPackagePublisherTrustTransitionReceipt> {
|
||||
const record = dataRecord(value, 'transition receipt');
|
||||
exactKeys(
|
||||
record,
|
||||
[
|
||||
'schema',
|
||||
'mutationId',
|
||||
'proposalDigest',
|
||||
'trustAuthorityId',
|
||||
'previousGeneration',
|
||||
'currentGeneration',
|
||||
'mode',
|
||||
'publisher',
|
||||
'keyId',
|
||||
'previousTrustDigest',
|
||||
'currentTrustDigest',
|
||||
'proposer',
|
||||
'confirmer',
|
||||
'retirementMatchingInstallations',
|
||||
'executedAtMs',
|
||||
'receiptDigest',
|
||||
],
|
||||
'transition receipt',
|
||||
);
|
||||
const transitionMode = mode(value.mode);
|
||||
if (
|
||||
value.schema !==
|
||||
PLUGIN_PACKAGE_PUBLISHER_TRUST_TRANSITION_RECEIPT_SCHEMA ||
|
||||
(transitionMode === 'overlap_add' &&
|
||||
value.retirementMatchingInstallations !== null) ||
|
||||
(transitionMode === 'safe_retire' &&
|
||||
value.retirementMatchingInstallations !== 0)
|
||||
) {
|
||||
return invalid('transition receipt mode or retirement proof is invalid');
|
||||
}
|
||||
const previousGeneration = positiveInteger(
|
||||
value.previousGeneration,
|
||||
'previousGeneration',
|
||||
);
|
||||
const normalized = withReceiptDigest({
|
||||
schema: PLUGIN_PACKAGE_PUBLISHER_TRUST_TRANSITION_RECEIPT_SCHEMA,
|
||||
mutationId: identifier(value.mutationId, 'mutationId'),
|
||||
proposalDigest: digest(value.proposalDigest, 'proposalDigest'),
|
||||
trustAuthorityId: identifier(
|
||||
value.trustAuthorityId,
|
||||
'trustAuthorityId',
|
||||
),
|
||||
previousGeneration,
|
||||
currentGeneration: positiveInteger(
|
||||
value.currentGeneration,
|
||||
'currentGeneration',
|
||||
),
|
||||
mode: transitionMode,
|
||||
publisher: publisher(value.publisher),
|
||||
keyId: identifier(value.keyId, 'keyId'),
|
||||
previousTrustDigest: digest(
|
||||
value.previousTrustDigest,
|
||||
'previousTrustDigest',
|
||||
),
|
||||
currentTrustDigest: digest(
|
||||
value.currentTrustDigest,
|
||||
'currentTrustDigest',
|
||||
),
|
||||
proposer: userSubject(value.proposer, 'proposer'),
|
||||
confirmer: userSubject(value.confirmer, 'confirmer'),
|
||||
retirementMatchingInstallations:
|
||||
value.retirementMatchingInstallations,
|
||||
executedAtMs: timestamp(value.executedAtMs, 'executedAtMs'),
|
||||
});
|
||||
if (
|
||||
normalized.currentGeneration !== previousGeneration + 1 ||
|
||||
normalized.previousTrustDigest === normalized.currentTrustDigest ||
|
||||
sameSubject(normalized.proposer, normalized.confirmer) ||
|
||||
digest(value.receiptDigest, 'receiptDigest') !==
|
||||
normalized.receiptDigest
|
||||
) {
|
||||
return invalid('transition receipt derived binding is invalid');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function resolvePluginPackagePublisherTrustTransitionProposal(
|
||||
proposalValue: PluginPackagePublisherTrustTransitionProposal,
|
||||
dispatchValue: ApprovedActionDispatchRecord,
|
||||
executedAtMsValue: number,
|
||||
retirementMatchingInstallationsValue: 0 | null,
|
||||
): Readonly<PluginPackagePublisherTrustTransitionReceipt> {
|
||||
const proposal =
|
||||
normalizePluginPackagePublisherTrustTransitionProposal(proposalValue);
|
||||
const dispatch = normalizeApprovedActionDispatchRecord(dispatchValue);
|
||||
const executedAtMs = timestamp(executedAtMsValue, 'executedAtMs');
|
||||
const action = proposal.actionInput;
|
||||
if (
|
||||
dispatch.projectId !== proposal.projectId ||
|
||||
dispatch.action.actionRef !== proposal.actionRef ||
|
||||
dispatch.action.actionType !== proposal.actionType ||
|
||||
dispatch.action.permission !== proposal.permission ||
|
||||
dispatch.action.actionDigest !== proposal.actionDigest ||
|
||||
dispatch.action.previewDigest !== proposal.previewDigest ||
|
||||
!sameSubject(dispatch.requestedBy, proposal.proposedBy) ||
|
||||
sameSubject(dispatch.requestedBy, dispatch.approvedBy) ||
|
||||
(dispatch.approvalAssurance !== 'multi_factor' &&
|
||||
dispatch.approvalAssurance !== 'hardware') ||
|
||||
dispatch.createdAtMs < proposal.createdAtMs ||
|
||||
executedAtMs < dispatch.createdAtMs ||
|
||||
executedAtMs >= dispatch.expiresAtMs ||
|
||||
(action.mode === 'overlap_add' &&
|
||||
retirementMatchingInstallationsValue !== null) ||
|
||||
(action.mode === 'safe_retire' &&
|
||||
retirementMatchingInstallationsValue !== 0)
|
||||
) {
|
||||
throw new PluginPackagePublisherTrustTransitionBindingConflictError();
|
||||
}
|
||||
return normalizePluginPackagePublisherTrustTransitionReceipt(
|
||||
withReceiptDigest({
|
||||
schema: PLUGIN_PACKAGE_PUBLISHER_TRUST_TRANSITION_RECEIPT_SCHEMA,
|
||||
mutationId: dispatch.id,
|
||||
proposalDigest: proposal.proposalDigest,
|
||||
trustAuthorityId: action.trustAuthorityId,
|
||||
previousGeneration: action.trustGeneration,
|
||||
currentGeneration: action.trustGeneration + 1,
|
||||
mode: action.mode,
|
||||
publisher: action.publisher,
|
||||
keyId: action.keyId,
|
||||
previousTrustDigest: action.previousTrustDigest,
|
||||
currentTrustDigest: action.currentTrustDigest,
|
||||
proposer: proposal.proposedBy,
|
||||
confirmer: dispatch.approvedBy,
|
||||
retirementMatchingInstallations:
|
||||
retirementMatchingInstallationsValue,
|
||||
executedAtMs,
|
||||
}),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user