156 lines
5.4 KiB
TypeScript
156 lines
5.4 KiB
TypeScript
import { Buffer } from 'node:buffer';
|
|
import { createHash, createPublicKey, verify } from 'node:crypto';
|
|
import type { KeyObject } from 'node:crypto';
|
|
import {
|
|
isMakeLoreVersionCompatible,
|
|
parsePluginReleaseDescriptor,
|
|
serializePluginReleaseDescriptor,
|
|
type PluginReleaseDescriptor,
|
|
} from './release-descriptor';
|
|
import {
|
|
createCodeOwnedPluginTrustStore,
|
|
type PluginSigningKeyStore,
|
|
} from './trusted-keys';
|
|
|
|
export type PluginSignatureFailureCode =
|
|
| 'plugin_signature_invalid'
|
|
| 'plugin_artifact_invalid'
|
|
| 'plugin_incompatible_client';
|
|
|
|
export type PluginSignatureVerificationResult =
|
|
| { readonly ok: true }
|
|
| { readonly ok: false; readonly code: PluginSignatureFailureCode; readonly message: string };
|
|
|
|
export interface PluginSignatureVerifierOptions {
|
|
readonly keyStore?: PluginSigningKeyStore | ReadonlyMap<string, Uint8Array | string>;
|
|
readonly clientVersion: string;
|
|
}
|
|
|
|
export interface PluginSignatureVerifier {
|
|
verify(input: {
|
|
readonly keyId: string;
|
|
readonly signature: string | Uint8Array;
|
|
readonly descriptor: PluginReleaseDescriptor;
|
|
readonly artifact: Uint8Array;
|
|
}): PluginSignatureVerificationResult;
|
|
}
|
|
|
|
function failure(code: PluginSignatureFailureCode, message: string): PluginSignatureVerificationResult {
|
|
return { ok: false, code, message };
|
|
}
|
|
|
|
function keyFromStore(
|
|
keyStore: PluginSigningKeyStore | ReadonlyMap<string, Uint8Array | string>,
|
|
keyId: string,
|
|
): Uint8Array | string | null {
|
|
const key = keyStore.get(keyId);
|
|
return key === undefined ? null : key;
|
|
}
|
|
|
|
function publicKey(value: Uint8Array | string): KeyObject | null {
|
|
try {
|
|
if (typeof value === 'string' && !value.includes('PUBLIC KEY')) return null;
|
|
const key = typeof value === 'string'
|
|
? createPublicKey(value)
|
|
: createPublicKey({ key: Buffer.from(value), format: 'der', type: 'spki' });
|
|
return key.asymmetricKeyType === 'ed25519' ? key : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function signatureBytes(value: string | Uint8Array): Buffer | null {
|
|
try {
|
|
if (typeof value === 'string'
|
|
&& (value.length !== 86 || !/^[A-Za-z0-9_-]+$/u.test(value))) return null;
|
|
const bytes = typeof value === 'string' ? Buffer.from(value, 'base64url') : Buffer.from(value);
|
|
return bytes.length === 64 ? bytes : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function artifactDigest(artifact: Uint8Array): string {
|
|
return createHash('sha256').update(artifact).digest('hex');
|
|
}
|
|
|
|
export function createPluginSignatureVerifier(
|
|
options: PluginSignatureVerifierOptions,
|
|
): PluginSignatureVerifier {
|
|
const keyStore = options.keyStore ?? createCodeOwnedPluginTrustStore();
|
|
return {
|
|
verify(input) {
|
|
if (!(input.artifact instanceof Uint8Array)) {
|
|
return failure('plugin_artifact_invalid', 'artifact bytes are invalid');
|
|
}
|
|
let descriptorBytes: Uint8Array;
|
|
try {
|
|
descriptorBytes = serializePluginReleaseDescriptor(input.descriptor);
|
|
} catch {
|
|
return failure('plugin_signature_invalid', 'descriptor is invalid');
|
|
}
|
|
if (input.artifact.byteLength !== input.descriptor.artifact.sizeBytes
|
|
|| artifactDigest(input.artifact) !== input.descriptor.artifact.sha256) {
|
|
return failure('plugin_artifact_invalid', 'artifact bytes do not match the descriptor');
|
|
}
|
|
if (!isCompatibleClient(options.clientVersion, input.descriptor)) {
|
|
return failure('plugin_incompatible_client', 'client version is outside the Release range');
|
|
}
|
|
const encodedKey = keyFromStore(keyStore, input.keyId);
|
|
if (encodedKey === null) return failure('plugin_signature_invalid', 'signing key is not trusted');
|
|
const key = publicKey(encodedKey);
|
|
const signature = signatureBytes(input.signature);
|
|
if (!key || !signature || !verify(null, descriptorBytes, key, signature)) {
|
|
return failure('plugin_signature_invalid', 'descriptor signature is invalid');
|
|
}
|
|
return { ok: true };
|
|
},
|
|
};
|
|
}
|
|
|
|
function isCompatibleClient(clientVersion: string, descriptor: PluginReleaseDescriptor): boolean {
|
|
// Kept local to avoid making the verifier infer a range from untrusted fields.
|
|
return isMakeLoreVersionCompatible(
|
|
clientVersion,
|
|
descriptor.minMakeloreVersion,
|
|
descriptor.maxMakeloreVersion,
|
|
);
|
|
}
|
|
|
|
export interface VerifyPluginReleaseSignatureInput {
|
|
readonly verifier?: PluginSignatureVerifier;
|
|
readonly keyId: string;
|
|
readonly signature: string | Uint8Array;
|
|
readonly descriptor: PluginReleaseDescriptor | Uint8Array | string;
|
|
readonly artifact: Uint8Array;
|
|
readonly clientVersion?: string;
|
|
readonly keyStore?: PluginSigningKeyStore | ReadonlyMap<string, Uint8Array | string>;
|
|
}
|
|
|
|
export function verifyPluginReleaseSignature(
|
|
input: VerifyPluginReleaseSignatureInput,
|
|
): PluginSignatureVerificationResult {
|
|
const descriptor = input.descriptor instanceof Uint8Array || typeof input.descriptor === 'string'
|
|
? (() => {
|
|
try {
|
|
return parsePluginReleaseDescriptor(input.descriptor as Uint8Array | string);
|
|
} catch {
|
|
return null;
|
|
}
|
|
})()
|
|
: input.descriptor;
|
|
if (!descriptor) return failure('plugin_signature_invalid', 'descriptor is invalid');
|
|
const verifier = input.verifier ?? createPluginSignatureVerifier({
|
|
keyStore: input.keyStore,
|
|
clientVersion: input.clientVersion ?? '',
|
|
});
|
|
return verifier.verify({
|
|
keyId: input.keyId,
|
|
signature: input.signature,
|
|
descriptor,
|
|
artifact: input.artifact,
|
|
});
|
|
}
|
|
|
|
export const verifyReleaseSignature = verifyPluginReleaseSignature;
|