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 .
-
- Exit
+
+
+
+ Log Out
);
}
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"