import { isEqual } from 'lodash'; import { UtilsV1 as UtilsV1Pb } from '@superblocksteam/types'; import { ApplicationConfiguration, ApplicationSettings, IApplicationV2Dto } from '../types/application/index.js'; import { AnyPageDSL } from '../types/index.js'; import unreachable from '../utils/unreachable.js'; import { ApplicationSettingsHashes, ApplicationSignatureTree, ApplicationSignatureTreeUpdate, ApplicationSignatureTreeV1, ApplicationSignatureTreeV2 } from './constants.js'; import { sha256Base64 } from './hashing.js'; import { stableJSONStringify } from './JSON/index.js'; import { getEnumProtoName } from './proto.js'; export * from './constants.js'; /** * Returns the SHA-256 hash of the given value, encoded as a base64 string. The hash is stable, meaning that the same * object will always produce the same hash. * @param value The value to hash. Must be JSON-serializable. * @returns The SHA-256 hash of the given value, encoded as a base64 string. */ export async function sha256Object(value: unknown): Promise { return await sha256Base64(stableJSONStringify(value) ?? ''); } function splitApplicationSettings(settings: ApplicationSettings) { const { registeredComponents, sourceFiles, bundledFiles, cliVersion, componentsLastUploaded, profiles, ...rest } = settings; return { components: { registeredComponents, sourceFiles, bundledFiles, cliVersion, componentsLastUploaded }, rest, // We should not include the profiles in the hash profiles }; } export async function hashApplicationSettings(settings: ApplicationSettings): Promise { const { components, rest } = splitApplicationSettings(settings); const [componentsHash, restHash] = await Promise.all([sha256Object(components), sha256Object(rest)]); return { components: componentsHash, rest: restHash }; } async function createApplicationSignatureTreeV1({ pageDsl, appSettings }: { pageDsl: AnyPageDSL; // When we have multi-page apps, we'll pass in multiple page DSLs here appSettings: ApplicationSettings; }): Promise { const hashedSettings = await hashApplicationSettings(appSettings); const hashedPage = await sha256Object(pageDsl); return { v: 1, settings: hashedSettings, page: { version: pageDsl.version, hash: hashedPage } }; } async function createApplicationSignatureTreeV2({ pages, configuration, appSettings }: { pages: Record; configuration: IApplicationV2Dto['configuration'] | ApplicationConfiguration; appSettings: ApplicationSettings; }): Promise { const pagesHashes: ApplicationSignatureTreeV2['pages'] = {}; for (const [pageId, pageDsl] of Object.entries(pages)) { pagesHashes[pageId] = { version: pageDsl.version, hash: await sha256Object(pageDsl) }; } const configurationHash = await sha256Object(configuration); const settingsHash = await hashApplicationSettings(appSettings); return { v: 2, pages: pagesHashes, configuration: { // if configuration.version is not defined, we assume it's 1 version: (configuration?.version as number) ?? 1, hash: configurationHash }, settings: settingsHash }; } // TODO: remove this const produceV1Signatures = false; export async function createApplicationSignatureTree({ pages, configuration, appSettings }: { pages: Record; configuration: Record | ApplicationConfiguration | undefined; appSettings: ApplicationSettings; }): Promise { if (produceV1Signatures) { const pagesList = Object.values(pages); if (pagesList.length !== 1) { throw new Error('Only single page applications are supported in v1 signature tree'); } const pageDsl = pagesList[0]; return createApplicationSignatureTreeV1({ pageDsl, appSettings }); } else { return createApplicationSignatureTreeV2({ pages, configuration, appSettings }); } } /** * Upgrades an application signature tree from v1 to v2. The application must have a single page. */ export async function upgradeApplicationSignatureTree({ currentHashTree, pageIds, configuration }: { currentHashTree: ApplicationSignatureTree; pageIds: string[]; configuration: ApplicationConfiguration; }): Promise { switch (currentHashTree.v) { case 2: return currentHashTree; case 1: { if (pageIds.length !== 1) { throw new Error('Only single page applications are supported in v1 signature tree'); } const configurationHash = await sha256Object(configuration); return { v: 2, pages: { [pageIds[0]]: currentHashTree.page }, configuration: { // if configuration.version is not defined, we assume it's 1 version: configuration.version ?? 1, hash: configurationHash }, settings: currentHashTree.settings }; } default: unreachable(currentHashTree); } } async function verifyApplicationHashTreeV1({ hashTree, pages, appSettings }: { hashTree: ApplicationSignatureTreeV1; pages?: Record; appSettings?: ApplicationSettings; }): Promise { if (pages) { const pagesList = Object.values(pages); if (pagesList.length !== 1) { throw new Error('Only single page applications are supported in v1 signature tree'); } const pageDsl = pagesList[0]; const pageHash = await sha256Object(pageDsl); if (hashTree.page.hash !== pageHash) { return false; } } if (appSettings) { const settingsHash = await hashApplicationSettings(appSettings); if (!isEqual(hashTree.settings, settingsHash)) { return false; } } return true; } async function verifyApplicationHashTreeV2({ hashTree, pages, configuration, appSettings }: { hashTree: ApplicationSignatureTreeV2; pages?: Record; configuration?: ApplicationConfiguration; appSettings?: ApplicationSettings; }): Promise { if (pages) { for (const [pageId, pageDsl] of Object.entries(pages)) { const pageHash = await sha256Object(pageDsl); if (hashTree.pages[pageId]?.hash !== pageHash) { return false; } } } if (configuration) { const configurationHash = await sha256Object(configuration); if (hashTree.configuration.hash !== configurationHash) { return false; } } if (appSettings) { const settingsHash = await hashApplicationSettings(appSettings); if (!isEqual(hashTree.settings, settingsHash)) { return false; } } return true; } /** * Verifies the hashes of the different parts of an application against the given hash tree. If a subset of the parts is * provided, only those parts will be verified. */ export async function verifyApplicationHashTree({ hashTree, pages, configuration, appSettings }: { hashTree: ApplicationSignatureTree; pages?: Record; configuration?: ApplicationConfiguration; appSettings?: ApplicationSettings; }): Promise { switch (hashTree.v) { case 1: return verifyApplicationHashTreeV1({ hashTree, pages, appSettings }); case 2: return verifyApplicationHashTreeV2({ hashTree, pages, configuration, appSettings }); default: unreachable(hashTree); } } export async function updateApplicationHashTree({ currentTree, updates }: { currentTree: ApplicationSignatureTree; updates: ApplicationSignatureTreeUpdate[]; }): Promise { if (currentTree.v !== 2) { throw new Error(`Unsupported signature tree version: ${currentTree.v}`); } else { let hashTree = currentTree; for (const update of updates) { switch (update.type) { case 'page': { const pageHash = await sha256Object(update.pageDsl); hashTree = { ...hashTree, pages: { ...hashTree.pages, [update.pageId]: { version: update.pageDsl.version, hash: pageHash } } }; break; } case 'delete-page': { if (hashTree.pages[update.pageId]) { hashTree = { ...hashTree, pages: { ...hashTree.pages } }; delete hashTree.pages[update.pageId]; } break; } case 'configuration': { const hash = await sha256Object(update.configuration); hashTree = { ...hashTree, configuration: { // if configuration.version is not defined, we assume it's 1 version: update.configuration.version ?? 1, hash } }; break; } case 'settings': { const hash = await hashApplicationSettings(update.settings); hashTree = { ...hashTree, settings: hash }; break; } default: unreachable(update); } } return hashTree; } } export function getAlgorithmName(algorithm: UtilsV1Pb.Signature_Algorithm): string { const protoName = getEnumProtoName(UtilsV1Pb.Signature_Algorithm, algorithm); if (protoName) { return protoName; } return getEnumProtoName(UtilsV1Pb.Signature_Algorithm, UtilsV1Pb.Signature_Algorithm.UNSPECIFIED) ?? ''; }