From e88d2b2b0c80fae92a31cea81ac20746b93d3fca Mon Sep 17 00:00:00 2001 From: Ivan Chub Date: Mon, 18 Dec 2023 17:58:20 -0800 Subject: [PATCH] validate app state before performing potentially destructive actions: sync, save, download, upload (#1348) closes https://github.com/proofcarryingdata/zupass/issues/1341 --- .../passport-client/components/core/index.tsx | 5 + .../components/modals/InfoModal.tsx | 10 +- .../components/modals/InvalidUserModal.tsx | 52 ++- .../components/screens/ServerErrorScreen.tsx | 6 +- .../components/shared/PrivacyNotice.tsx | 5 +- apps/passport-client/package.json | 1 + apps/passport-client/pages/index.tsx | 12 +- apps/passport-client/src/dispatch.ts | 51 ++- apps/passport-client/src/localstorage.ts | 33 +- .../src/useSyncE2EEStorage.tsx | 59 +++- apps/passport-client/src/validateState.ts | 248 ++++++++++++++ .../test/stateValidation.spec.ts | 312 ++++++++++++++++++ yarn.lock | 5 + 13 files changed, 767 insertions(+), 32 deletions(-) create mode 100644 apps/passport-client/src/validateState.ts create mode 100644 apps/passport-client/test/stateValidation.spec.ts diff --git a/apps/passport-client/components/core/index.tsx b/apps/passport-client/components/core/index.tsx index 7a16fc4eb4..2edf7313db 100644 --- a/apps/passport-client/components/core/index.tsx +++ b/apps/passport-client/components/core/index.tsx @@ -1,4 +1,5 @@ import { Spacer } from "@pcd/passport-ui"; +import { ZUPASS_SUPPORT_EMAIL } from "@pcd/util"; import styled from "styled-components"; import { icons } from "../icons"; import { Button } from "./Button"; @@ -95,3 +96,7 @@ export function ZuLogo() { ); } + +export const SupportLink = () => { + return {ZUPASS_SUPPORT_EMAIL}; +}; diff --git a/apps/passport-client/components/modals/InfoModal.tsx b/apps/passport-client/components/modals/InfoModal.tsx index decc25eb81..1a02cd298b 100644 --- a/apps/passport-client/components/modals/InfoModal.tsx +++ b/apps/passport-client/components/modals/InfoModal.tsx @@ -1,5 +1,5 @@ -import { ZUPASS_GITHUB_REPOSITORY_URL, ZUPASS_SUPPORT_EMAIL } from "@pcd/util"; -import { CenterColumn, Spacer, TextCenter } from "../core"; +import { ZUPASS_GITHUB_REPOSITORY_URL } from "@pcd/util"; +import { CenterColumn, Spacer, SupportLink, TextCenter } from "../core"; import { icons } from "../icons"; export function InfoModal() { @@ -20,11 +20,7 @@ export function InfoModal() { - For app support, contact{" "} - - {ZUPASS_SUPPORT_EMAIL} - - . + For app support, contact . diff --git a/apps/passport-client/components/modals/InvalidUserModal.tsx b/apps/passport-client/components/modals/InvalidUserModal.tsx index 6d54040118..40a1f5a17a 100644 --- a/apps/passport-client/components/modals/InvalidUserModal.tsx +++ b/apps/passport-client/components/modals/InvalidUserModal.tsx @@ -2,12 +2,26 @@ import { Spacer } from "@pcd/passport-ui"; import { useCallback } from "react"; import styled from "styled-components"; import { useDispatch } from "../../src/appHooks"; -import { Button, H2 } from "../core"; +import { Button, H2, SupportLink } from "../core"; +import { AccountExportButton } from "../shared/AccountExportButton"; +/** + * A Zupass client can sometimes end up with invalid local state. When that happens, + * we generally set {@link AppState.userInvalid} to true, and display this modal by + * setting {@link AppState.modal} to `{ modalType: "invalid-participant" }`. This modal + * explains what's going on + suggest paths to resolve the problem. + */ export function InvalidUserModal() { const dispatch = useDispatch(); - - const onClick = useCallback(() => { + const onLogoutClick = useCallback(() => { + if ( + !confirm( + "Are you sure you want to log out? " + + "We recommend that you export your account before doing so." + ) + ) { + return; + } dispatch({ type: "reset-passport" }); }, [dispatch]); @@ -15,17 +29,39 @@ export function InvalidUserModal() {

Invalid Zupass

+

Your Zupass is in an invalid state. This can happen when:

+
    +
  • You reset your account on another device.
  • +
  • Zupass application state becomes corrupted on this device.
  • +
+

To resolve this, we recommend you either:

+
    +
  • Reload this page.
  • +
  • + Export your account data, log out of this account, and log in again. +
  • +

- You've reset your Zupass account on another device, invalidating this - one. Click the button below to log out. Then you'll be able to sync your - existing Zupass account onto this device. + If this issue persists, please contact us at .

- - + + + +
); } const Container = styled.div` padding: 24px; + p { + margin-bottom: 8px; + } + ul { + list-style: circle; + margin-bottom: 8px; + li { + margin-left: 32px; + } + } `; diff --git a/apps/passport-client/components/screens/ServerErrorScreen.tsx b/apps/passport-client/components/screens/ServerErrorScreen.tsx index 11ffcd2af0..fb860bebcf 100644 --- a/apps/passport-client/components/screens/ServerErrorScreen.tsx +++ b/apps/passport-client/components/screens/ServerErrorScreen.tsx @@ -1,6 +1,5 @@ -import { ZUPASS_SUPPORT_EMAIL } from "@pcd/util"; import { useSearchParams } from "react-router-dom"; -import { CenterColumn, H1, Spacer, TextCenter } from "../core"; +import { CenterColumn, H1, Spacer, SupportLink, TextCenter } from "../core"; import { LinkButton } from "../core/Button"; import { AppContainer } from "../shared/AppContainer"; @@ -23,8 +22,7 @@ export function ServerErrorScreen() { {description} {!!description && } - For support, please send a message to{" "} - {ZUPASS_SUPPORT_EMAIL}. + For support, please send a message to . Return to Zupass diff --git a/apps/passport-client/components/shared/PrivacyNotice.tsx b/apps/passport-client/components/shared/PrivacyNotice.tsx index 62f373d975..d4c9dcfa84 100644 --- a/apps/passport-client/components/shared/PrivacyNotice.tsx +++ b/apps/passport-client/components/shared/PrivacyNotice.tsx @@ -1,6 +1,6 @@ import { useCallback, useState } from "react"; import styled from "styled-components"; -import { Button, Spacer } from "../core"; +import { Button, Spacer, SupportLink } from "../core"; export function PrivacyNoticeText() { return ( @@ -244,8 +244,7 @@ export function PrivacyNoticeText() {

8. HOW TO CONTACT US

Should you have any questions about our privacy practices or this - Privacy Notice, please email us at{" "} - support@zupass.org. + Privacy Notice, please email us at .

); diff --git a/apps/passport-client/package.json b/apps/passport-client/package.json index b2bf14dd39..105f6a2191 100644 --- a/apps/passport-client/package.json +++ b/apps/passport-client/package.json @@ -84,6 +84,7 @@ "@esbuild-plugins/node-globals-polyfill": "^0.2.3", "@esbuild-plugins/node-modules-polyfill": "^0.2.2", "@pcd/eslint-config-custom": "*", + "@types/chai": "^4.3.11", "@types/email-validator": "^1.0.6", "@types/express": "^4.17.17", "@types/mocha": "^10.0.1", diff --git a/apps/passport-client/pages/index.tsx b/apps/passport-client/pages/index.tsx index 2a03b4b648..f46115e573 100644 --- a/apps/passport-client/pages/index.tsx +++ b/apps/passport-client/pages/index.tsx @@ -67,6 +67,7 @@ import { import { registerServiceWorker } from "../src/registerServiceWorker"; import { AppState, StateEmitter } from "../src/state"; import { pollUser } from "../src/user"; +import { validateAndLogInitialAppState } from "../src/validateState"; class App extends React.Component { state = undefined as AppState | undefined; @@ -402,7 +403,7 @@ async function loadInitialState(): Promise { } const self = loadSelf(); - const pcds = await loadPCDs(); + const pcds = await loadPCDs(self); const encryptionKey = loadEncryptionKey(); const subscriptions = await loadSubscriptions(); const offlineTickets = loadOfflineTickets(); @@ -425,7 +426,7 @@ async function loadInitialState(): Promise { const persistentSyncStatus = loadPersistentSyncStatus(); - return { + const state: AppState = { self, encryptionKey, pcds, @@ -441,6 +442,13 @@ async function loadInitialState(): Promise { serverStorageHash: persistentSyncStatus.serverStorageHash, importScreen: undefined }; + + if (!validateAndLogInitialAppState("loadInitialState", state)) { + state.userInvalid = true; + state.modal = { modalType: "invalid-participant" }; + } + + return state; } registerServiceWorker(); diff --git a/apps/passport-client/src/dispatch.ts b/apps/passport-client/src/dispatch.ts index d27cfb7b77..e29ebb4fa1 100644 --- a/apps/passport-client/src/dispatch.ts +++ b/apps/passport-client/src/dispatch.ts @@ -54,6 +54,7 @@ import { uploadStorage } from "./useSyncE2EEStorage"; import { assertUnreachable } from "./util"; +import { validateAndLogRunningAppState } from "./validateState"; export type Dispatcher = (action: Action) => void; @@ -356,16 +357,21 @@ async function finishAccountCreation( update: ZuUpdate ) { // Verify that the identity is correct. - const { identity } = state; - console.log("[ACCOUNT] Check user", identity, user); - if (identity == null || identity.commitment.toString() !== user.commitment) { + if ( + !validateAndLogRunningAppState( + "finishAccountCreation", + user, + state.identity, + state.pcds + ) + ) { update({ error: { title: "Invalid identity", message: "Something went wrong saving your Zupass. Contact support." } }); - return; // Don't save the bad identity. User must reset account. + return; // Don't save the bad identity. User must reset account. } // Save PCDs to E2EE storage. knownRevision=undefined is the way to create @@ -375,6 +381,7 @@ async function finishAccountCreation( console.log("[ACCOUNT] Upload initial PCDs"); const uploadResult = await uploadStorage( user, + state.identity, state.pcds, state.subscriptions, undefined // knownRevision @@ -385,6 +392,12 @@ async function finishAccountCreation( serverStorageRevision: uploadResult.value.revision, serverStorageHash: uploadResult.value.storageHash }); + } else if ( + !uploadResult.success && + uploadResult.error.name === "ValidationError" + ) { + userInvalid(update); + return; } // Save user to local storage. This is done last because it unblocks @@ -533,6 +546,18 @@ async function loadAfterLogin( SemaphoreIdentityPCDTypeName )[0] as SemaphoreIdentityPCD; + if ( + !validateAndLogRunningAppState( + "loadAfterLogin", + userResponse.value, + identityPCD.claim.identity, + pcds + ) + ) { + userInvalid(update); + return; + } + let modal: AppState["modal"] = { modalType: "none" }; if (!identityPCD) { // TODO: handle error gracefully @@ -705,6 +730,10 @@ async function doSync( console.log("[SYNC] no encryption key, can't sync"); return undefined; } + if (state.userInvalid) { + console.log("[SYNC] userInvalid=true, exiting sync"); + return undefined; + } // If we haven't downloaded from storage, do that first. After that we'll // download again when requested to poll, but only after the first full sync @@ -722,6 +751,7 @@ async function doSync( state.serverStorageRevision, state.serverStorageHash, state.self, + state.identity, state.pcds, state.subscriptions ); @@ -794,9 +824,13 @@ async function doSync( state.pcds, state.subscriptions ); + if (state.serverStorageHash !== appStorage.storageHash) { console.log("[SYNC] sync action: upload"); const upRes = await uploadSerializedStorage( + state.self, + state.identity, + state.pcds, appStorage.serializedStorage, appStorage.storageHash, state.serverStorageRevision @@ -807,6 +841,14 @@ async function doSync( serverStorageHash: upRes.value.storageHash }; } else { + if (upRes.error.name === "ValidationError") { + // early return on upload validation error; this doesn't cause upload + // loop b/c there is an even earlier early return that exits the sync + // code in the case that the userInvalid flag is set + userInvalid(update); + return; + } + // Upload failed. Update AppState if necessary, but not unnecessarily. // AppState updates will trigger another upload attempt. const needExtraDownload = upRes.error.name === "Conflict"; @@ -825,6 +867,7 @@ async function doSync( if (needExtraDownload && !state.extraDownloadRequested) { updates.extraDownloadRequested = true; } + return updates; } } diff --git a/apps/passport-client/src/localstorage.ts b/apps/passport-client/src/localstorage.ts index 2ee70bf98a..323f461d7c 100644 --- a/apps/passport-client/src/localstorage.ts +++ b/apps/passport-client/src/localstorage.ts @@ -12,16 +12,29 @@ import { SemaphoreSignaturePCD } from "@pcd/semaphore-signature-pcd"; import { Identity } from "@semaphore-protocol/identity"; import { z } from "zod"; import { getPackages } from "./pcdPackages"; +import { validateAndLogRunningAppState } from "./validateState"; const OLD_PCDS_KEY = "pcds"; // deprecated const COLLECTION_KEY = "pcd_collection"; export async function savePCDs(pcds: PCDCollection): Promise { + if ( + !validateAndLogRunningAppState("savePCDs", undefined, undefined, pcds, true) + ) { + console.error( + "PCD Collection failed to validate - not writing it to localstorage" + ); + return; + } + const serialized = await pcds.serializeCollection(); window.localStorage[COLLECTION_KEY] = serialized; } -export async function loadPCDs(): Promise { +/** + * {@link self} argument used only to modify validation behavior. + */ +export async function loadPCDs(self?: User): Promise { const oldPCDs = window.localStorage[OLD_PCDS_KEY]; if (oldPCDs) { const collection = new PCDCollection(await getPackages()); @@ -31,10 +44,26 @@ export async function loadPCDs(): Promise { } const serializedCollection = window.localStorage[COLLECTION_KEY]; - return await PCDCollection.deserialize( + const collection = await PCDCollection.deserialize( await getPackages(), serializedCollection ?? "{}" ); + + if ( + !validateAndLogRunningAppState( + "loadPCDs", + undefined, + undefined, + collection, + self !== undefined + ) + ) { + console.error( + "PCD Collection failed to validate when loading from localstorage" + ); + } + + return collection; } export async function saveSubscriptions( diff --git a/apps/passport-client/src/useSyncE2EEStorage.tsx b/apps/passport-client/src/useSyncE2EEStorage.tsx index 6aaf00914f..ee8bd3896b 100644 --- a/apps/passport-client/src/useSyncE2EEStorage.tsx +++ b/apps/passport-client/src/useSyncE2EEStorage.tsx @@ -15,6 +15,7 @@ import { } from "@pcd/passport-interface"; import { PCDCollection } from "@pcd/pcd-collection"; import { PCD } from "@pcd/pcd-types"; +import { Identity } from "@semaphore-protocol/identity"; import stringify from "fast-json-stable-stringify"; import { useCallback, useContext, useEffect } from "react"; import { appConfig } from "./appConfig"; @@ -30,6 +31,7 @@ import { } from "./localstorage"; import { getPackages } from "./pcdPackages"; import { useOnStateChange } from "./subscribe"; +import { validateAndLogRunningAppState } from "./validateState"; export type UpdateBlobKeyStorageInfo = { revision: string; @@ -48,7 +50,7 @@ export async function updateBlobKeyForEncryptedStorage( ): Promise { const oldUser = loadSelf(); const newUser = { ...oldUser, salt: newSalt }; - const pcds = await loadPCDs(); + const pcds = await loadPCDs(oldUser); const subscriptions = await loadSubscriptions(); const { serializedStorage, storageHash } = await serializeStorage( @@ -109,6 +111,7 @@ export type UploadStorageResult = APIResult< */ export async function uploadStorage( user: User, + userIdentity: Identity, pcds: PCDCollection, subscriptions: FeedSubscriptionManager, knownRevision?: string @@ -118,22 +121,54 @@ export async function uploadStorage( pcds, subscriptions ); - return uploadSerializedStorage(serializedStorage, storageHash, knownRevision); + return uploadSerializedStorage( + user, + userIdentity, + pcds, + serializedStorage, + storageHash, + knownRevision + ); } /** * Uploads the state of this passport, in serialied form as produced by * serializeStorage(). * + * The parameters {@link user}, {@link userIdentity}, and + * {@link pcds} are used only to validate the consistency between the three + * before attempting an upload, to help prevent uploading inconsistent state. + * * If knownRevision is specified, it is used to abort the upload in * case of conflict. If it is undefined, the upload will overwrite * any revision. */ export async function uploadSerializedStorage( + user: User, + userIdentity: Identity, + pcds: PCDCollection, serializedStorage: SyncedEncryptedStorage, storageHash: string, knownRevision?: string ): Promise { + if ( + !validateAndLogRunningAppState( + "uploadSerializedStorage", + user, + userIdentity, + pcds + ) + ) { + return { + success: false, + error: { + name: "ValidationError", + detailedMessage: "validation before upload failed", + code: undefined + } + }; + } + const encryptionKey = loadEncryptionKey(); const blobKey = await getHash(encryptionKey); @@ -350,6 +385,7 @@ export async function downloadAndMergeStorage( knownServerRevision: string | undefined, knownServerHash: string | undefined, appSelf: User, + appIdentity: Identity, appPCDs: PCDCollection, appSubscriptions: FeedSubscriptionManager ): Promise { @@ -379,6 +415,8 @@ export async function downloadAndMergeStorage( // Deserialize downloaded storage, which becomes the default new state if no // merge is necessary. const downloaded = await tryDeserializeNewStorage( + appSelf, + appIdentity, storageResult.value.storage ); if (downloaded === undefined) { @@ -438,7 +476,12 @@ export async function downloadAndMergeStorage( }; } +/** + * {@link appSelf} and {@link appIdentity} are used solely for validation purposes. + */ export async function tryDeserializeNewStorage( + appSelf: User, + appIdentity: Identity, storage: SyncedEncryptedStorage ): Promise< | undefined @@ -453,6 +496,18 @@ export async function tryDeserializeNewStorage( storage, await getPackages() ); + + if ( + !validateAndLogRunningAppState( + "downloadStorage", + appSelf, + appIdentity, + pcds + ) + ) { + throw new Error("downloaded e2ee state failed to validate"); + } + return { dlPCDs: pcds, dlSubscriptions: subscriptions, diff --git a/apps/passport-client/src/validateState.ts b/apps/passport-client/src/validateState.ts new file mode 100644 index 0000000000..1a5774c236 --- /dev/null +++ b/apps/passport-client/src/validateState.ts @@ -0,0 +1,248 @@ +import { User, requestLogToServer } from "@pcd/passport-interface"; +import { PCDCollection } from "@pcd/pcd-collection"; +import { + SemaphoreIdentityPCD, + SemaphoreIdentityPCDPackage +} from "@pcd/semaphore-identity-pcd"; +import { Identity } from "@semaphore-protocol/identity"; +import { appConfig } from "./appConfig"; +import { loadSelf } from "./localstorage"; +import { AppState } from "./state"; + +/** + * Returns `true` if {@link validateInitialAppState} returns no errors, and `false` + * otherwise. In the case that errors were encountered, uploads an error report to + * the Zupass backend. + */ +export function validateAndLogInitialAppState( + tag: string, + state: AppState +): boolean { + const validationErrors = validateInitialAppState(tag, state); + + if (validationErrors.errors.length > 0) { + logValidationErrors(validationErrors); + return false; + } + + return true; +} + +/** + * Returns `true` if {@link validateRunningAppState} returns no errors, and `false` + * otherwise. In the case that errors were encountered, uploads an error report to + * the Zupass backend. + */ +export function validateAndLogRunningAppState( + tag: string, + self: User | undefined, + identity: Identity | undefined, + pcds: PCDCollection | undefined, + forceCheckPCDs?: boolean +): boolean { + const validationErrors = validateRunningAppState( + tag, + self, + identity, + pcds, + forceCheckPCDs + ); + + if (validationErrors.errors.length > 0) { + logValidationErrors(validationErrors); + return false; + } + + return true; +} + +/** + * Validates state of a Zupass application that is just starting up (using the + * {@link validateRunningAppState} alongside some extra ones), and returns an {@link ErrorReport}. + */ +export function validateInitialAppState( + tag: string, + appState: AppState | undefined +): ErrorReport { + return { + errors: getInitialAppStateValidationErrors(appState), + userUUID: appState?.self?.uuid, + tag + }; +} + +/** + * Validates state of a running Zupass application and returns an {@link ErrorReport}. + */ +export function validateRunningAppState( + tag: string, + self: User | undefined, + identity: Identity | undefined, + pcds: PCDCollection | undefined, + forceCheckPCDs?: boolean +): ErrorReport { + return { + errors: getRunningAppStateValidationErrors( + self, + identity, + pcds, + forceCheckPCDs + ), + userUUID: self?.uuid, + tag + }; +} + +/** + * Validates state of a Zupass application that is just starting up. Uses + * {@link getRunningAppStateValidationErrors}, and performs some extra checks + * on top of it. + */ +export function getInitialAppStateValidationErrors(state: AppState): string[] { + const errors = [ + ...getRunningAppStateValidationErrors( + state.self, + state.identity, + state.pcds + ) + ]; + + // this case covers a logged in user. the only way the app can get a 'self' + // is by requesting one from the server, to do which one has to be logged in. + if (state.self) { + // TODO: any extra checks that need to happen on immediate app startup should + // be put here. + } + + return errors; +} + +/** + * Validates state of a running Zupass application. Returns a list of errors + * represented by strings. If the state is not invalid, returns an empty list. + */ +export function getRunningAppStateValidationErrors( + self: User | undefined, + identity: Identity | undefined, + pcds: PCDCollection | undefined, + forceCheckPCDs?: boolean +): string[] { + const errors: string[] = []; + const loggedOut = !self; + const identityPCDFromCollection = pcds?.getPCDsByType( + SemaphoreIdentityPCDPackage.name + )?.[0] as SemaphoreIdentityPCD | undefined; + + if (forceCheckPCDs || !loggedOut) { + if (!pcds) { + errors.push("missing 'pcds'"); + } + + if (pcds?.size() === 0) { + errors.push("'pcds' contains no pcds"); + } + + if (!identityPCDFromCollection) { + errors.push("'pcds' field in app state does not contain an identity PCD"); + } + } + + if (loggedOut) { + return errors; + } + + if (!identity) { + errors.push("missing 'identity'"); + } + + const identityFromPCDCollection = identityPCDFromCollection?.claim?.identity; + const commitmentOfIdentityPCDInCollection = + identityFromPCDCollection?.commitment?.toString(); + const commitmentFromSelfField = self?.commitment; + const commitmentFromIdentityField = identity?.commitment?.toString(); + + if (commitmentFromSelfField === undefined) { + errors.push(`'self' missing a commitment`); + } + + if ( + commitmentFromSelfField === undefined || + commitmentOfIdentityPCDInCollection === undefined || + commitmentFromIdentityField === undefined + ) { + // these cases are validated earlier in this function + } else { + // in 'else' block we check that the commitments from all three + // places that the user's commitment exists match - in the self, the + // identity, and in the pcd collection + + if (commitmentOfIdentityPCDInCollection !== commitmentFromSelfField) { + errors.push( + `commitment of identity pcd in collection (${commitmentOfIdentityPCDInCollection})` + + ` does not match commitment in 'self' field of app state (${commitmentFromSelfField})` + ); + } + if (commitmentFromSelfField !== commitmentFromIdentityField) { + errors.push( + `commitment in 'self' field of app state (${commitmentFromSelfField})` + + ` does not match commitment of 'identity' field of app state (${commitmentFromIdentityField})` + ); + } + + if (commitmentFromIdentityField !== commitmentOfIdentityPCDInCollection) { + errors.push( + `commitment of 'identity' field of app state (${commitmentFromIdentityField})` + + ` does not match commitment of identity pcd in collection (${commitmentOfIdentityPCDInCollection})` + ); + } + } + + return errors; +} + +/** + * Logs validation errors to the console, and uploads them to the server so that + * we have records and are able to identify common types of errors. Does not leak + * sensitive information, such as decrypted versions of e2ee storage. + */ +export async function logValidationErrors( + errorReport: ErrorReport +): Promise { + if (errorReport?.errors?.length === 0) { + console.log(`not logging empty error report`); + return; + } + + try { + const user = loadSelf(); + errorReport.userUUID = errorReport.userUUID ?? user?.uuid; + console.log(`encountered state validation errors: `, errorReport); + await requestLogToServer(appConfig.zupassServer, "state-validation-error", { + ...errorReport + }); + } catch (e) { + console.log("error reporting errors", e); + } +} + +/** + * Uploaded to server in case of a state validation error. + */ +export interface ErrorReport { + /** + * Human readable non-sensitive-information-leaking errors. If this array is empty, + * it represents a state that has no errors. + */ + errors: string[]; + + /** + * Used to identify the user on the server-side. + */ + userUUID?: string; + + /** + * Uniquely identifies the location in the code from which this error report + * was initiated. + */ + tag: string; +} diff --git a/apps/passport-client/test/stateValidation.spec.ts b/apps/passport-client/test/stateValidation.spec.ts new file mode 100644 index 0000000000..f9148597ca --- /dev/null +++ b/apps/passport-client/test/stateValidation.spec.ts @@ -0,0 +1,312 @@ +import { EdDSAPCD, EdDSAPCDPackage } from "@pcd/eddsa-pcd"; +import { PCDCrypto } from "@pcd/passport-crypto"; +import { ZupassUserJson } from "@pcd/passport-interface"; +import { PCDCollection } from "@pcd/pcd-collection"; +import { ArgumentTypeName } from "@pcd/pcd-types"; +import { SemaphoreIdentityPCDPackage } from "@pcd/semaphore-identity-pcd"; +import { Identity } from "@semaphore-protocol/identity"; +import { expect } from "chai"; +import { v4 as uuid } from "uuid"; +import { randomEmail } from "../src/util"; +import { ErrorReport, validateRunningAppState } from "../src/validateState"; + +function newEdSAPCD(): Promise { + return EdDSAPCDPackage.prove({ + message: { + value: ["0x12345", "0x54321", "0xdeadbeef"], + argumentType: ArgumentTypeName.StringArray + }, + privateKey: { + value: "0001020304050607080900010203040506070809000102030405060708090001", + argumentType: ArgumentTypeName.String + }, + id: { + value: undefined, + argumentType: ArgumentTypeName.String + } + }); +} + +describe("validateAppState", async function () { + const crypto = await PCDCrypto.newInstance(); + const saltAndEncryptionKey = await crypto.generateSaltAndEncryptionKey( + "testpassword123!@#asdf" + ); + const pcdPackages = [SemaphoreIdentityPCDPackage, EdDSAPCDPackage]; + const identity1 = new Identity( + '["0xaa5fa3165e1ca129bd7a2b3bada18c5f81350faacf2edff59cf44eeba2e2d",' + + '"0xa944ed90153fc2be5759a0d18eda47266885aea0966ef4dbb96ff979c29ed4"]' + ); + const commitment1 = identity1.commitment; + const identity2 = new Identity( + '["0x8526e030dbd593833f24bf73b60f0bcc58690c590b9953acc741f2eb71394d",' + + '"0x520e4ae6f5d5e4526dd517e61defe16f90bd4aef72b41394285e77463e0c69"]' + ); + const commitment2 = identity2.commitment; + const identity3 = new Identity( + '["0x4837c6f88904d1dfefcb7dc6486e95c06cda6eb76d76a9888167c0993e40f0",' + + '"0x956f9e03b0cc324045d24f9b20531e547272fab8e8ee2f96c0cf2e50311468"]' + ); + const commitment3 = identity3.commitment; + + it("logged out ; no errors", async function () { + expect( + validateRunningAppState(TAG_STR, undefined, undefined, undefined) + ).to.deep.eq({ + errors: [], + userUUID: undefined, + ...TAG + } satisfies ErrorReport); + }); + + it("logged out ; forceCheckPCDs=true; test of all error states", async function () { + expect( + validateRunningAppState( + TAG_STR, + undefined, + undefined, + new PCDCollection(pcdPackages), + true + ) + ).to.deep.eq({ + errors: [ + "'pcds' contains no pcds", + "'pcds' field in app state does not contain an identity PCD" + ], + userUUID: undefined, + ...TAG + } satisfies ErrorReport); + + expect( + validateRunningAppState( + TAG_STR, + undefined, + undefined, + await (async () => { + const collection = new PCDCollection(pcdPackages); + collection.add(await newEdSAPCD()); + return collection; + })(), + true + ) + ).to.deep.eq({ + errors: ["'pcds' field in app state does not contain an identity PCD"], + userUUID: undefined, + ...TAG + } satisfies ErrorReport); + + expect( + validateRunningAppState(TAG_STR, undefined, undefined, undefined, true) + ).to.deep.eq({ + errors: [ + "missing 'pcds'", + "'pcds' field in app state does not contain an identity PCD" + ], + userUUID: undefined, + ...TAG + } satisfies ErrorReport); + }); + + it("logged in ; no errors", async function () { + const self: ZupassUserJson = { + commitment: identity1.commitment.toString(), + email: randomEmail(), + salt: saltAndEncryptionKey.salt, + terms_agreed: 1, + uuid: uuid() + }; + const pcds = new PCDCollection(pcdPackages); + pcds.add( + await SemaphoreIdentityPCDPackage.prove({ + identity: identity1 + }) + ); + expect(validateRunningAppState(TAG_STR, self, identity1, pcds)).to.deep.eq({ + userUUID: self.uuid, + errors: [], + ...TAG + } satisfies ErrorReport); + }); + + it("logged in ; empty pcd collection ; errors", async function () { + const self: ZupassUserJson = { + commitment: identity1.commitment.toString(), + email: randomEmail(), + salt: saltAndEncryptionKey.salt, + terms_agreed: 1, + uuid: uuid() + }; + const pcds = new PCDCollection(pcdPackages); + expect(validateRunningAppState(TAG_STR, self, identity1, pcds)).to.deep.eq({ + userUUID: self.uuid, + errors: [ + "'pcds' contains no pcds", + "'pcds' field in app state does not contain an identity PCD" + ], + ...TAG + } satisfies ErrorReport); + }); + + it("logged in ; missing pcd collection ; errors", async function () { + const self: ZupassUserJson = { + commitment: identity1.commitment.toString(), + email: randomEmail(), + salt: saltAndEncryptionKey.salt, + terms_agreed: 1, + uuid: uuid() + }; + expect( + validateRunningAppState(TAG_STR, self, identity1, undefined) + ).to.deep.eq({ + userUUID: self.uuid, + errors: [ + "missing 'pcds'", + "'pcds' field in app state does not contain an identity PCD" + ], + ...TAG + } satisfies ErrorReport); + }); + + it("logged in ; missing identity ; errors", async function () { + const self: ZupassUserJson = { + commitment: identity1.commitment.toString(), + email: randomEmail(), + salt: saltAndEncryptionKey.salt, + terms_agreed: 1, + uuid: uuid() + }; + const pcds = new PCDCollection(pcdPackages); + pcds.add( + await SemaphoreIdentityPCDPackage.prove({ + identity: identity1 + }) + ); + expect(validateRunningAppState(TAG_STR, self, undefined, pcds)).to.deep.eq({ + userUUID: self.uuid, + errors: ["missing 'identity'"], + ...TAG + } satisfies ErrorReport); + }); + + it("logged in ; self missing commitment ; errors", async function () { + const self: ZupassUserJson = { + commitment: identity1.commitment.toString(), + email: randomEmail(), + salt: saltAndEncryptionKey.salt, + terms_agreed: 1, + uuid: uuid() + }; + const pcds = new PCDCollection(pcdPackages); + pcds.add( + await SemaphoreIdentityPCDPackage.prove({ + identity: identity1 + }) + ); + delete self.commitment; + expect(validateRunningAppState(TAG_STR, self, identity1, pcds)).to.deep.eq({ + userUUID: self.uuid, + errors: ["'self' missing a commitment"], + ...TAG + } satisfies ErrorReport); + }); + + it("logged in ; self commitment wrong ; errors", async function () { + const self: ZupassUserJson = { + commitment: identity2.commitment.toString(), + email: randomEmail(), + salt: saltAndEncryptionKey.salt, + terms_agreed: 1, + uuid: uuid() + }; + const pcds = new PCDCollection(pcdPackages); + pcds.add( + await SemaphoreIdentityPCDPackage.prove({ + identity: identity1 + }) + ); + expect(validateRunningAppState(TAG_STR, self, identity1, pcds)).to.deep.eq({ + userUUID: self.uuid, + errors: [ + `commitment of identity pcd in collection (${commitment1}) does not match commitment in 'self' field of app state (${commitment2})`, + `commitment in 'self' field of app state (${commitment2}) does not match commitment of 'identity' field of app state (${commitment1})` + ], + ...TAG + } satisfies ErrorReport); + }); + + it("logged in ; pcd collection identity wrong ; errors", async function () { + const self: ZupassUserJson = { + commitment: identity1.commitment.toString(), + email: randomEmail(), + salt: saltAndEncryptionKey.salt, + terms_agreed: 1, + uuid: uuid() + }; + const pcds = new PCDCollection(pcdPackages); + pcds.add( + await SemaphoreIdentityPCDPackage.prove({ + identity: identity2 + }) + ); + expect(validateRunningAppState(TAG_STR, self, identity1, pcds)).to.deep.eq({ + userUUID: self.uuid, + errors: [ + `commitment of identity pcd in collection (${commitment2}) does not match commitment in 'self' field of app state (${commitment1})`, + `commitment of 'identity' field of app state (${commitment1}) does not match commitment of identity pcd in collection (${commitment2})` + ], + ...TAG + } satisfies ErrorReport); + }); + + it("logged in ; appState identity wrong ; errors", async function () { + const self: ZupassUserJson = { + commitment: identity1.commitment.toString(), + email: randomEmail(), + salt: saltAndEncryptionKey.salt, + terms_agreed: 1, + uuid: uuid() + }; + const pcds = new PCDCollection(pcdPackages); + pcds.add( + await SemaphoreIdentityPCDPackage.prove({ + identity: identity1 + }) + ); + expect(validateRunningAppState(TAG_STR, self, identity2, pcds)).to.deep.eq({ + userUUID: self.uuid, + errors: [ + `commitment in 'self' field of app state (${commitment1}) does not match commitment of 'identity' field of app state (${commitment2})`, + `commitment of 'identity' field of app state (${commitment2}) does not match commitment of identity pcd in collection (${commitment1})` + ], + ...TAG + } satisfies ErrorReport); + }); + + it("logged in ; all identities mistmatched ; errors", async function () { + const self: ZupassUserJson = { + commitment: identity1.commitment.toString(), + email: randomEmail(), + salt: saltAndEncryptionKey.salt, + terms_agreed: 1, + uuid: uuid() + }; + const pcds = new PCDCollection(pcdPackages); + pcds.add( + await SemaphoreIdentityPCDPackage.prove({ + identity: identity2 + }) + ); + expect(validateRunningAppState(TAG_STR, self, identity3, pcds)).to.deep.eq({ + userUUID: self.uuid, + errors: [ + `commitment of identity pcd in collection (${commitment2}) does not match commitment in 'self' field of app state (${commitment1})`, + `commitment in 'self' field of app state (${commitment1}) does not match commitment of 'identity' field of app state (${commitment3})`, + `commitment of 'identity' field of app state (${commitment3}) does not match commitment of identity pcd in collection (${commitment2})` + ], + ...TAG + } satisfies ErrorReport); + }); +}); + +const TAG_STR = "test"; +const TAG = { tag: TAG_STR }; diff --git a/yarn.lock b/yarn.lock index 8419b4b14e..8c319eff4f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4177,6 +4177,11 @@ resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.6.tgz#7b489e8baf393d5dd1266fb203ddd4ea941259e6" integrity sha512-VOVRLM1mBxIRxydiViqPcKn6MIxZytrbMpd6RJLIWKxUNr3zux8no0Oc7kJx0WAPIitgZ0gkrDS+btlqQpubpw== +"@types/chai@^4.3.11": + version "4.3.11" + resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.11.tgz#e95050bf79a932cb7305dd130254ccdf9bde671c" + integrity sha512-qQR1dr2rGIHYlJulmr8Ioq3De0Le9E4MJ5AiaeAETJJpndT1uUNHsGFK3L/UIu+rbkQSdj8J/w2bCsBZc/Y5fQ== + "@types/circomlibjs@0.1.4": version "0.1.4" resolved "https://registry.yarnpkg.com/@types/circomlibjs/-/circomlibjs-0.1.4.tgz#162e6ab09a802c689f304c328e604ae302263811"