Files

605 lines
19 KiB
TypeScript

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);
}