UNPKG

@fort-major/masquerade

Version:

Privacy-focused MetaMask snap for the Internet Computer (ICP)

495 lines (426 loc) 16.4 kB
import { divider, heading, panel, text } from "@metamask/snaps-ui"; import { type TOrigin, ZIdentityAddRequest, ZIdentityLoginRequest, ZIdentityLinkRequest, ZIdentityUnlinkRequest, fromCBOR, unreacheable, zodParse, originToHostname, err, ErrorCode, ZIdentitySignRequest, type IIdentityGetLoginOptionsResponse, ZIdentityGetLoginOptionsRequest, IIdentityAddRequest, IIdentityLoginRequest, IIdentitySignRequest, IIdentityLinkRequest, IIdentityUnlinkRequest, ZIdentityEditPseudonymRequest, IMask, ZIdentityStopSessionRequest, ZIdentityUnlinkOneRequest, toCBOR, ZIdentityUnlinkAllRequest, ZIdentityGetPublicKeyRequest, EStatisticsKind, debugStringify, } from "@fort-major/masquerade-shared"; import { StateManager } from "../state"; import { getSignIdentity } from "../utils"; /** * ## Creates a new identity (mask) for the user to authorize with on some website * * @param bodyCBOR - {@link IIdentityAddRequest} - origin of the target website * @returns always returns true * * @category Protected * @category Shows Pop-Up */ // eslint-disable-next-line @typescript-eslint/naming-convention export async function protected_handleIdentityAdd(bodyCBOR: string): Promise<IMask | null> { const body: IIdentityAddRequest = zodParse(ZIdentityAddRequest, fromCBOR(bodyCBOR)); const manager = await StateManager.make(); const agreed = await snap.request({ method: "snap_dialog", params: { type: "confirmation", content: panel([ heading("🔒 Confirm Mask Creation 🔒"), text(`Are you sure you want to create another mask for **${originToHostname(body.toOrigin)}**?`), divider(), text("**Confirm?** 🚀"), ]), }, }); if (!agreed) return null; const newMask = await manager.addIdentity(body.toOrigin); manager.incrementStats(EStatisticsKind.MasksCreated); return newMask; } /** * ## Creates a new session on the provided website * * @param bodyCBOR - {@link IIdentityLoginRequest} * @returns always returns true * * @category Protected */ // eslint-disable-next-line @typescript-eslint/naming-convention export async function protected_handleIdentityLogin(bodyCBOR: string): Promise<true> { const body: IIdentityLoginRequest = zodParse(ZIdentityLoginRequest, fromCBOR(bodyCBOR)); const manager = await StateManager.make(); if (body.withLinkedOrigin !== undefined && body.withLinkedOrigin !== body.toOrigin) { if (!manager.linkExists(body.withLinkedOrigin, body.toOrigin)) err(ErrorCode.UNAUTHORIZED, "Unable to login without a link"); } const originData = await manager.getOriginData(body.toOrigin); if (Object.keys(originData.masks).length === 0) { unreacheable("login - no origin data found"); } const timestamp = new Date().getTime(); originData.currentSession = { deriviationOrigin: body.withLinkedOrigin ?? body.toOrigin, identityId: body.withIdentityId, timestampMs: timestamp, }; manager.setOriginData(body.toOrigin, originData); return true; } /** * ## Returns login options of the user for a particular website * * These options always contain at least one way for a user to authorize. * Includes both: options from the target origin and options from all linked origins * * @param bodyCBOR - {@link IIdentityGetLoginOptionsRequest} * @returns - {@link IIdentityGetLoginOptionsResponse} * * @category Protected */ // eslint-disable-next-line @typescript-eslint/naming-convention export async function protected_handleIdentityGetLoginOptions( bodyCBOR: string, ): Promise<IIdentityGetLoginOptionsResponse> { const body = zodParse(ZIdentityGetLoginOptionsRequest, fromCBOR(bodyCBOR)); const manager = await StateManager.make(); const result: IIdentityGetLoginOptionsResponse = []; const originData = await manager.getOriginData(body.forOrigin); result.push([body.forOrigin, Object.values(originData.masks) as IMask[]]); for (const origin of Object.keys(originData.linksFrom)) { const linkedOriginData = await manager.getOriginData(origin); result.push([origin, Object.values(linkedOriginData.masks) as IMask[]]); } return result; } export async function protected_handleIdentityEditPseudonym(bodyCBOR: string): Promise<void> { const body = zodParse(ZIdentityEditPseudonymRequest, fromCBOR(bodyCBOR)); const manager = await StateManager.make(); manager.editPseudonym(body.origin, body.identityId, body.newPseudonym); } export function protected_handleIdentityStopSession(bodyCBOR: string): Promise<boolean> { const body = zodParse(ZIdentityStopSessionRequest, fromCBOR(bodyCBOR)); return handleIdentityLogoutRequest(body.origin); } export function protected_handleIdentityUnlinkOne(bodyCBOR: string): Promise<boolean> { const body = zodParse(ZIdentityUnlinkOneRequest, fromCBOR(bodyCBOR)); return handleIdentityUnlinkRequest(toCBOR({ withOrigin: body.withOrigin } as IIdentityUnlinkRequest), body.origin); } export async function protected_handleIdentityUnlinkAll(bodyCBOR: string): Promise<boolean> { const body = zodParse(ZIdentityUnlinkAllRequest, fromCBOR(bodyCBOR)); const agreed = await snap.request({ method: "snap_dialog", params: { type: "confirmation", content: panel([ heading("🎭 Mask Unlink Request 🎭"), text(`**${originToHostname(body.origin)}** wants you to unlink your masks from **all other websites**.`), divider(), text( `You will no longer be able to log in to **these websites** using masks you use on **${originToHostname( body.origin, )}**.`, ), text( "You will be logged out from **these websites**, if you're currently logged in using one of the linked masks.", ), divider(), text("**Confirm?** 🚀"), ]), }, }); if (!agreed) return false; const manager = await StateManager.make(); const oldLinks = await manager.unlinkAll(body.origin); for (let withOrigin of oldLinks) { const targetOriginData = await manager.getOriginData(withOrigin); if (targetOriginData.currentSession !== undefined) { if (targetOriginData.currentSession.deriviationOrigin === body.origin) { targetOriginData.currentSession = undefined; } } manager.setOriginData(withOrigin, targetOriginData); manager.incrementStats(EStatisticsKind.OriginsUnlinked); } return true; } /** * ## Proposes the user to log out * * Opens a pop-up window for a user to confirm, whether or not they want to log out from the current website. * If the user agrees, then the session is deleted. * * @param origin - {@link TOrigin} * @returns - whether the user did log out * * @category Shows Pop-Up */ export async function handleIdentityLogoutRequest(origin: TOrigin): Promise<boolean> { const manager = await StateManager.make(); const originData = await manager.getOriginData(origin); // if we're not authorized anyway - just return true if (originData.currentSession === undefined) { return true; } // otherwise as the user if they really want to log out const agreed = await snap.request({ method: "snap_dialog", params: { type: "confirmation", content: panel([ heading("🔒 Log out request 🔒"), text(`**${originToHostname(origin)}** wants you to log out.`), divider(), text( `You will become anonymous, but **${originToHostname( origin, )}** may still track your actions! To prevent that, clean your browser's cache right after logging out.`, ), divider(), text("**Confirm?** 🚀"), ]), }, }); // if the user doesn't want to logout - return false if (agreed === false) { return false; } // otherwise, remove the session and return true originData.currentSession = undefined; manager.setOriginData(origin, originData); return true; } /** * ## Signs an arbitrary message with the chosen user key pair * * There is a separate Secp256k1 key pair for each user, for each origin, for each user's identity (mask). In other words, key pairs are scoped. * Moreover, each key pair can be used to derive more signing key pairs for arbitrary purposes. * * Only works if the user is logged in. * * @see {@link handleIdentityGetPublicKey} * @see {@link getSignIdentity} * * @param bodyCBOR - {@link IIdentitySignRequest} * @param origin - {@link TOrigin} * @returns Secp256k1 signature of the provided payload */ export async function handleIdentitySign(bodyCBOR: string, origin: TOrigin): Promise<ArrayBuffer> { const body: IIdentitySignRequest = zodParse(ZIdentitySignRequest, fromCBOR(bodyCBOR)); const manager = await StateManager.make(); let session = (await manager.getOriginData(origin)).currentSession; if (session === undefined) { err(ErrorCode.UNAUTHORIZED, "Log in first"); } const identity = await getSignIdentity(session.deriviationOrigin, session.identityId, body.salt); manager.incrementStats(EStatisticsKind.SignaturesProduced); return await identity.sign(body.challenge); } /** * ## Returns a public key of the corresponding key pair * * There is a separate Secp256k1 key pair for each user, for each origin, for each user's identity (mask). In other words, key pairs are scoped. * Moreover, each key pair can be used to derive more signing key pairs for arbitrary purposes. * * Only works if the user is logged in. * * @see {@link handleIdentitySign} * @see {@link getSignIdentity} * * @param bodyCBOR - {@link IIdentityGetPublicKeyRequest} * @param origin - {@link TOrigin} * @returns Secp256k1 public key in raw format */ export async function handleIdentityGetPublicKey(bodyCBOR: string, origin: TOrigin): Promise<ArrayBuffer> { const body = zodParse(ZIdentityGetPublicKeyRequest, fromCBOR(bodyCBOR)); const manager = await StateManager.make(); let session = (await manager.getOriginData(origin)).currentSession; if (session === undefined) { err(ErrorCode.UNAUTHORIZED, "Log in first"); } const identity = await getSignIdentity(session.deriviationOrigin, session.identityId, body.salt); return identity.getPublicKey().toRaw(); } export async function handleIdentityGetPseudonym(origin: TOrigin): Promise<string> { const manager = await StateManager.make(); let session = (await manager.getOriginData(origin)).currentSession; if (session === undefined) err(ErrorCode.UNAUTHORIZED, "Log in first"); const originData = await manager.getOriginData(session.deriviationOrigin); return originData.masks[session.identityId]?.pseudonym ?? "Unknown Mister"; } /** * ## Proposes the user to link their masks from this website to another website * * One can only propose to link masks __from their own__ website, not the other way. * This is useful in two scenarios: * - domain name migration - when a website is rebranded and moves to another domain, this functionality allows users to continue using their old identities * - website integration - when two websites want their users to interact with them using the same identities * * @see {@link handleIdentityUnlinkRequest} * * @param bodyCBOR - {@link IIdentityLinkRequest} * @param origin - {@link TOrigin} * @returns whether or not the user allowed linking * * @category Shows Pop-Up */ export async function handleIdentityLinkRequest(bodyCBOR: string, origin: TOrigin): Promise<boolean> { const body: IIdentityLinkRequest = zodParse(ZIdentityLinkRequest, fromCBOR(bodyCBOR)); const manager = await StateManager.make(); if (origin === body.withOrigin) { err(ErrorCode.INVALID_INPUT, "Unable to link to itself"); } // if there is already a link exists, just return true as if we did all the rest of the function if (manager.linkExists(origin, body.withOrigin)) { return true; } // otherwise prompt to the user if they want to share const agreed = await snap.request({ method: "snap_dialog", params: { type: "confirmation", content: panel([ heading("🎭 Mask Link Request 🎭"), text( `**${originToHostname(origin)}** wants you to reveal your masks to **${originToHostname(body.withOrigin)}**.`, ), text( `You will be able to log in to **${originToHostname( body.withOrigin, )}** using masks you use on **${originToHostname(origin)}**.`, ), divider(), heading("🚨 BE CAREFUL! 🚨"), text( `**${originToHostname(body.withOrigin)}** will be able to call **${originToHostname( origin, )}**'s canisters on your behalf without notice!`, ), text( `Only proceed if **${originToHostname( origin, )}** recently has updated its domain name and you want to access it using your old masks.`, ), divider(), text("**Confirm?** 🚀"), ]), }, }); // if the user didn't agree, return false if (agreed === false) { return false; } // otherwise update the links list and return true await manager.link(origin, body.withOrigin); manager.incrementStats(EStatisticsKind.OriginsLinked); return true; } /** * ## Proposes the user to unlink their identities on this website from another website * * If the user is logged in to the target website, they will be logged out. * * @see {@link handleIdentityLinkRequest} * * @param bodyCBOR - {@link IIdentityUnlinkRequest} * @param origin - {@link TOrigin} * @returns whether the user agreed to unlink * * @category Shows Pop-Up */ export async function handleIdentityUnlinkRequest(bodyCBOR: string, origin: TOrigin): Promise<boolean> { const body: IIdentityUnlinkRequest = zodParse(ZIdentityUnlinkRequest, fromCBOR(bodyCBOR)); const manager = await StateManager.make(); // if there is already no link exists, just return true as if we did all the rest of the function if (!manager.linkExists(origin, body.withOrigin)) { return true; } // otherwise prompt to the user if they want to share const agreed = await snap.request({ method: "snap_dialog", params: { type: "confirmation", content: panel([ heading("🎭 Mask Unlink Request 🎭"), text( `**${originToHostname(origin)}** wants you to unlink your masks from **${originToHostname( body.withOrigin, )}**.`, ), divider(), text( `You will no longer be able to log in to **${originToHostname( body.withOrigin, )}** using masks you use on **${originToHostname(origin)}**.`, ), text( `You will be logged out from **${originToHostname( body.withOrigin, )}**, if you're currently logged in using one of the linked masks.`, ), divider(), text("**Confirm?** 🚀"), ]), }, }); // if the user didn't agree, return false if (agreed === false) { return false; } // otherwise update the links lists await manager.unlink(origin, body.withOrigin); // and then try de-authorizing from the target origin const targetOriginData = await manager.getOriginData(body.withOrigin); if (targetOriginData.currentSession !== undefined) { if (targetOriginData.currentSession.deriviationOrigin === origin) { targetOriginData.currentSession = undefined; } } manager.setOriginData(body.withOrigin, targetOriginData); manager.incrementStats(EStatisticsKind.OriginsUnlinked); return true; } /** * ## Returns a list of websites with which the user linked their identities from the current website * * @param origin - {@link TOrigin} * @returns array of {@link TOrigin} */ export async function handleIdentityGetLinks(origin: TOrigin): Promise<TOrigin[]> { const manager = await StateManager.make(); const originData = await manager.getOriginData(origin); return Object.keys(originData.linksTo); } /** * Returns `true` if the user is logged in current website * * @param origin - {@link TOrigin} * @returns */ export async function handleIdentitySessionExists(origin: TOrigin): Promise<boolean> { const manager = await StateManager.make(); return (await manager.getOriginData(origin)).currentSession !== undefined; }