feat(ql3): establish 3.0 incubation baseline

This commit is contained in:
whyour
2026-08-12 00:25:26 +08:00
parent 4bf92dcfeb
commit c699c32461
2817 changed files with 779642 additions and 653 deletions
@@ -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),
});
}
@@ -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,
});
}
@@ -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);
}
@@ -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,
}),
);
}