Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: handling invalid session state #4139

Merged
merged 4 commits into from
Jan 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 29 additions & 12 deletions packages/sign-client/src/controllers/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,6 @@ export class Engine extends IEngine {
this.registerPairingEvents();
this.client.core.pairing.register({ methods: Object.keys(ENGINE_RPC_OPTS) });
this.initialized = true;

setTimeout(() => {
this.sessionRequestQueue.queue = this.getPendingSessionRequests();
this.processSessionRequestQueue();
Expand Down Expand Up @@ -434,9 +433,15 @@ export class Engine extends IEngine {
params: getSdkError("USER_DISCONNECTED"),
throwOnFailedPublish: true,
});
await this.deleteSession(topic);
} else {
await this.deleteSession({ topic, emitEvent: false });
} else if (this.client.core.pairing.pairings.keys.includes(topic)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice improvement 👍

In retrospect I don't recall why we didn't combine pairing and session handling into this top level disconnect/why it was session-only and pairings had to be handled explicitly via core.pairing.disconnect()

await this.client.core.pairing.disconnect({ topic });
} else {
const { message } = getInternalError(
"MISMATCHED_TOPIC",
`Session or pairing topic not found: ${topic}`,
);
throw new Error(message);
}
};

Expand All @@ -446,7 +451,6 @@ export class Engine extends IEngine {
};

public getPendingSessionRequests: IEngine["getPendingSessionRequests"] = () => {
this.isInitialized();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Por que no? Isn't pendingRequest a store that needs to be initialised during init?

Copy link
Member Author

@ganchoradkov ganchoradkov Jan 17, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this isInitialized refers to engine's initialization and the cleanup is ran before initialized is set to true

return this.client.pendingRequest.getAll();
};

Expand Down Expand Up @@ -479,11 +483,12 @@ export class Engine extends IEngine {
}
};

private deleteSession: EnginePrivate["deleteSession"] = async (topic, expirerHasDeleted) => {
private deleteSession: EnginePrivate["deleteSession"] = async (params) => {
const { topic, expirerHasDeleted = false, emitEvent = true, id = 0 } = params;
const { self } = this.client.session.get(topic);
// Await the unsubscribe first to avoid deleting the symKey too early below.
await this.client.core.relayer.unsubscribe(topic);
this.client.session.delete(topic, getSdkError("USER_DISCONNECTED"));
await this.client.session.delete(topic, getSdkError("USER_DISCONNECTED"));
if (this.client.core.crypto.keychain.has(self.publicKey)) {
await this.client.core.crypto.deleteKeyPair(self.publicKey);
}
Expand All @@ -501,6 +506,7 @@ export class Engine extends IEngine {
this.deletePendingSessionRequest(r.id, getSdkError("USER_DISCONNECTED"));
}
});
if (emitEvent) this.client.events.emit("session_delete", { id, topic });
};

private deleteProposal: EnginePrivate["deleteProposal"] = async (id, expirerHasDeleted) => {
Expand Down Expand Up @@ -612,13 +618,16 @@ export class Engine extends IEngine {
const sessionTopics: string[] = [];
const proposalIds: number[] = [];
this.client.session.getAll().forEach((session) => {
if (isExpired(session.expiry)) sessionTopics.push(session.topic);
let toCleanup = false;
if (isExpired(session.expiry)) toCleanup = true;
if (!this.client.core.crypto.keychain.has(session.topic)) toCleanup = true;
if (toCleanup) sessionTopics.push(session.topic);
});
this.client.proposal.getAll().forEach((proposal) => {
if (isExpired(proposal.expiry)) proposalIds.push(proposal.id);
});
await Promise.all([
...sessionTopics.map((topic) => this.deleteSession(topic)),
...sessionTopics.map((topic) => this.deleteSession({ topic })),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want emitEvent to be true here? May be very noisy if the client cleans up a lot of sessions but not sure if suppressing emit would effectively be a breaking change to current behaviour.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually it makes sense to emit, as expirer can delete sessions while the client is active, and the implementing party might not be subscribed to expirer events thus missing the delete

...proposalIds.map((id) => this.deleteProposal(id)),
]);
};
Expand Down Expand Up @@ -969,12 +978,11 @@ export class Engine extends IEngine {
new Promise((resolve) => {
// RPC request needs to happen before deletion as it utalises session encryption
this.client.core.relayer.once(RELAYER_EVENTS.publish, async () => {
resolve(await this.deleteSession(topic));
resolve(await this.deleteSession({ topic, id }));
});
}),
this.sendResult<"wc_sessionDelete">({ id, topic, result: true }),
]);
this.client.events.emit("session_delete", { id, topic });
} catch (err: any) {
this.client.logger.error(err);
}
Expand Down Expand Up @@ -1081,7 +1089,7 @@ export class Engine extends IEngine {

if (topic) {
if (this.client.session.keys.includes(topic)) {
await this.deleteSession(topic, true);
await this.deleteSession({ topic, expirerHasDeleted: true });
this.client.events.emit("session_expire", { topic });
}
} else if (id) {
Expand Down Expand Up @@ -1163,10 +1171,19 @@ export class Engine extends IEngine {
throw new Error(message);
}
if (isExpired(this.client.session.get(topic).expiry)) {
await this.deleteSession(topic);
await this.deleteSession({ topic });
const { message } = getInternalError("EXPIRED", `session topic: ${topic}`);
throw new Error(message);
}

if (!this.client.core.crypto.keychain.has(topic)) {
const { message } = getInternalError(
"MISSING_OR_INVALID",
`session topic does not exist in keychain: ${topic}`,
);
await this.deleteSession({ topic });
throw new Error(message);
}
}

private async isValidSessionOrPairingTopic(topic: string) {
Expand Down
35 changes: 35 additions & 0 deletions packages/sign-client/test/sdk/client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,41 @@ describe("Sign Client Integration", () => {
await throttle(1000);
await deleteClients(clients);
});
it("should handle invalid session state with missing keychain", async () => {
const {
clients,
sessionA: { topic },
} = await initTwoPairedClients({}, {}, { logger: "error" });
const dapp = clients.A as SignClient;
const sessions = dapp.session.getAll();
expect(sessions.length).to.eq(1);
await dapp.core.crypto.keychain.del(topic);

await Promise.all([
new Promise<void>((resolve) => {
dapp.on("session_delete", async (args) => {
const { topic: sessionTopic } = args;
expect(sessionTopic).to.eq(topic);
resolve();
});
}),
new Promise<void>(async (resolve) => {
try {
await dapp.ping({ topic });
} catch (err) {
expect(err.message).to.eq(
`Missing or invalid. session topic does not exist in keychain: ${topic}`,
);
}
resolve();
}),
]);

const sessionsAfter = dapp.session.getAll();
expect(sessionsAfter.length).to.eq(0);

await deleteClients(clients);
});
});
});
});
Expand Down
7 changes: 6 additions & 1 deletion packages/types/src/sign-client/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,12 @@ export interface EnginePrivate {

onRelayEventUnknownPayload(event: EngineTypes.EventCallback<any>): Promise<void>;

deleteSession(topic: string, expirerHasDeleted?: boolean): Promise<void>;
deleteSession(params: {
topic: string;
expirerHasDeleted?: boolean;
id?: number;
emitEvent?: boolean;
}): Promise<void>;

deleteProposal(id: number, expirerHasDeleted?: boolean): Promise<void>;

Expand Down