UNPKG

@fort-major/msq

Version:

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

806 lines (772 loc) 17.4 kB
import { ErrorCode, SNAP_METHODS, type TIdentityId, type TOrigin, type TProtectedSnapMethodsKind, err, hexToBytes, Principal, IHttpAgentRequest, ICanisterRequest, IReadStateRequest, strToBytes, } from "@fort-major/msq-shared"; import { Secp256k1KeyIdentity } from "@dfinity/identity-secp256k1"; import { IDL, lebEncode } from "@dfinity/candid"; import { Heading, Text, text as snapText, heading as snapHeading } from "@metamask/snaps-sdk"; import { CallRequest, ReadRequest, ReadRequestType, SubmitRequestType } from "@dfinity/agent"; export const ONE_SECOND_MS = 1000; export const ONE_MINUTE_MS = ONE_SECOND_MS * 60; export const ONE_HOUR_MS = ONE_MINUTE_MS * 60; export const ONE_DAY_MS = ONE_HOUR_MS * 24; export const IC_DOMAIN_SEPARATOR = strToBytes("\x0Aic-request"); // this is executed during the 'verify' build step process // when the snap is evaluated in SES // if it passes during the build step, it will also pass in runtime if (process.env.MSQ_SNAP_SITE_ORIGIN === undefined) { throw new Error(`Bad build: snap site origin is '${process.env.MSQ_SNAP_SITE_ORIGIN}'`); } /** * A complete and automatically generated list of all protected methods * * @see {@link SNAP_METHODS} */ const PROTECTED_METHODS = Object.keys(SNAP_METHODS.protected).flatMap((key) => Object.values(SNAP_METHODS.protected[key as TProtectedSnapMethodsKind]), ); /** * ## Checks if the method is protected - can only be called from the MSQ website * * If not - throws an error * * @param method * @param origin * @returns */ export function guardMethods(method: string, origin: TOrigin): void { // let other methods pass if (!PROTECTED_METHODS.includes(method)) { return; } // validate origin to be MSQ website if (!isMsq(origin)) { err( ErrorCode.PROTECTED_METHOD, `Method ${method} can only be executed from the MSQ website ("${origin}" != ${process.env.MSQ_SNAP_SITE_ORIGIN})`, ); } } /** * Checks if the provided origin is of the MSQ website * * @param origin * @returns */ export function isMsq(origin: TOrigin): boolean { return origin === JSON.parse(process.env.MSQ_SNAP_SITE_ORIGIN as string); } /** * Derives a signing key pair from the provided arguments * * The key pair is different for each user, each origin, each user's identity and can be customized with salt * * @see {@link handleIdentitySign} * @see {@link handleIdentityGetPublicKey} * * @param origin * @param identityId * @param salt * @returns */ export async function getSignIdentity( origin: TOrigin, identityId: TIdentityId, salt: Uint8Array = new Uint8Array(), ): Promise<Secp256k1KeyIdentity> { // the MSQ site has constant origin // this will allow us to change the domain name without users losing their funds and accounts const orig = isMsq(origin) ? "https://msq.tech" : origin; // forbid other dapps from using external salt // TODO: better trap here, if the provided salt is not the default one if (!isMsq(origin)) { salt = new Uint8Array(); } // shared prefix may be used in following updates const entropy = await getEntropy(orig, identityId, "identity-sign\nshared", salt); return Secp256k1KeyIdentity.fromSecretKey(entropy); } /** * Retrieves base entropy for a given origin and identity, incorporating an internal salt. * This function generates a unique entropy value by calling the `snap.request` method with * the "snap_getEntropy" method. The entropy is requested with a salt that combines a static * prefix, the origin, the identity ID, and an internal salt, separated by newline characters. * The response is expected to be a hexadecimal string, from which the leading "0x" is removed. * This hexadecimal string is then converted into a Uint8Array representing the entropy bytes. * The process ensures that the entropy is unique to the combination of origin, identity, and * internal salt, which is essential for MSQ's security. * * @param {TOrigin} origin - The origin associated with the entropy request. * @param {TIdentityId} identityId - The identity ID associated with the entropy request. * @param {string} internalSalt - An internal salt to further personalize the entropy. * @returns {Promise<Uint8Array>} A promise that resolves to the entropy value as a Uint8Array. */ async function getBaseEntropy(origin: TOrigin, identityId: TIdentityId, internalSalt: string): Promise<Uint8Array> { const generated: string = await snap.request({ method: "snap_getEntropy", params: { version: 1, salt: `\x0amsq-snap\n${origin}\n${identityId}\n${internalSalt}`, }, }); return hexToBytes(generated.slice(2)); } /** * Generates a final entropy value by combining base entropy with an external salt and hashing the result. * This function first retrieves the base entropy for a given origin, identity ID, and internal salt by calling * `getBaseEntropy`. It then merges this base entropy with an external salt provided as a `Uint8Array` to form * a combined entropy byte array. This combined array is then hashed using the SHA-256 algorithm via the Web * Cryptography API (`crypto.subtle.digest`). The result is a promise that resolves to an `ArrayBuffer` representing * the final hashed entropy. This method provides a secure way to generate a unique and reproducible entropy value * for cryptographic operations or secure random generation, ensuring the entropy is specific to the given origin, * identity, and salts. Double hashing is used to prevent injection attacks. * * @param {TOrigin} origin - The origin associated with the entropy request. * @param {TIdentityId} identityId - The identity ID associated with the entropy request. * @param {string} internalSalt - An internal salt to further personalize the base entropy. * @param {Uint8Array} externalSalt - An external salt to combine with the base entropy before hashing. * @returns {Promise<ArrayBuffer>} A promise that resolves to the hashed entropy as an ArrayBuffer. */ async function getEntropy( origin: TOrigin, identityId: TIdentityId, internalSalt: string, externalSalt: Uint8Array, ): Promise<ArrayBuffer> { const baseEntropy = await getBaseEntropy(origin, identityId, internalSalt); let entropyPreBytes = new Uint8Array([...baseEntropy, ...externalSalt]); return await crypto.subtle.digest("SHA-256", entropyPreBytes); } /** * Escapes Markdown control characters, newline characters, and backticks in a string. * This function takes a string and returns a new string with all Markdown control characters, newline characters, and backticks escaped. * Escaping is done by prefixing each control character and backtick with a backslash (`\`), and newline characters are replaced with `\\n`. * * Control characters include: `{`, `}`, `[`, `]`, `(`, `)`, `#`, `!`, `|`, `\`, and `` ` ``. * Newline characters are replaced with a visible escape sequence (`\\n`) for demonstration or specific formatting needs. * Since all newlines are escaped, list creating characters `-`, `+` and `.` are allowed. * Text styling characters `*`, `_` are also allowed * * @param {string} text The string to be escaped. * @return {string} The escaped string, safe to be used in Markdown without triggering formatting, with newline characters visually represented and backticks escaped. */ export function escapeMarkdown(text: string) { return text.replace(/([{}\[\]()#!|\\`])|\n/g, (match) => (match === "\n" ? "\\n" : "\\" + match)); } /** * An escaped version of a text element from @metamask/snaps-sdk * * @param {string} str The string to render * @returns {Text} The Text node */ export function text(str: string): Text { return snapText(escapeMarkdown(str)); } /** * An escaped version of a heading element from @metamask/snaps-sdk * * @param {string} str The string to render * @returns {Heading} The Heading node */ export function heading(str: string): Heading { return snapHeading(escapeMarkdown(str)); } const CANDID_BLOB = IDL.Vec(IDL.Nat8); export const ICRC1_TRANSFER_ARGS_SCHEMA = IDL.Record({ from_subaccount: IDL.Opt(CANDID_BLOB), to: IDL.Record({ owner: IDL.Principal, subaccount: IDL.Opt(CANDID_BLOB), }), amount: IDL.Nat, fee: IDL.Opt(IDL.Nat), memo: IDL.Opt(CANDID_BLOB), created_at_time: IDL.Opt(IDL.Nat64), }); export type ICRC1TransferArgs = { from_subaccount: [] | [Uint8Array]; to: { owner: Principal; subaccount: [] | [Uint8Array]; }; amount: bigint; fee: [] | [bigint]; memo: [] | [Uint8Array]; created_at_time: [] | [bigint]; }; export class Expiry { constructor(private readonly _value: bigint) {} public toHash(): ArrayBuffer { return lebEncode(this._value); } } export function prepareRequest(req: IHttpAgentRequest): CallRequest | ReadRequest { const sender = typeof req.sender === "string" ? Principal.fromText(req.sender) : (req.sender as Uint8Array); const ingress_expiry = new Expiry(req.ingress_expiry); let request: CallRequest | ReadRequest; if (req.request_type === "call" || req.request_type === "query") { const r = req as ICanisterRequest; request = { ...r, request_type: r.request_type as SubmitRequestType, canister_id: Principal.fromText(r.canister_id), method_name: r.method_name, arg: r.arg, sender, // @ts-expect-error - we have replaced the default Expiry impl, because it does not allow reassembly from the internal value ingress_expiry, }; } else { const r = req as IReadStateRequest; request = { ...r, request_type: r.request_type as ReadRequestType.ReadState, paths: r.paths, sender, // @ts-expect-error - we have replaced the default Expiry impl, because it does not allow reassembly from the internal value ingress_expiry, }; } return request; } /** * Generates a random pseudonym based on two seed values. * This function constructs a pseudonym by selecting an adjective and a noun from predefined lists, * using the provided seed values. The selection process involves taking the modulo of each seed value * with the length of the respective list (ADJECTIVES or NOUNS) to ensure the index falls within the * range of the list. This approach allows for predictable, reproducible pseudonyms from specific seed * values, which can be useful in scenarios where unique, yet deterministic, identifiers are needed. * * @param {number} seed1 - The seed value used to select an adjective. * @param {number} seed2 - The seed value used to select a noun. * @returns {string} The generated pseudonym, composed of an adjective and a noun. */ export function generateRandomPseudonym(seed1: number, seed2: number): string { return `${ADJECTIVES[seed1 % ADJECTIVES.length]} ${NOUNS[seed2 % NOUNS.length]}`; } const ADJECTIVES = [ "Slight", "Rich", "Eventual", "Valid", "Judicial", "Thoughtful", "Developed", "Just", "Outdoor", "Empty", "Raw", "Deliberate", "Competent", "Terrible", "Asian", "Good", "Regional", "Rare", "Short", "Chronic", "Drunk", "Golden", "Particular", "Embarrassed", "Invisible", "Characteristic", "Sorry", "Crooked", "Healthy", "Fierce", "Real", "Dead", "Rapid", "Similar", "Excited", "Poor", "Puny", "Foolish", "Partial", "Junior", "Complicated", "Enchanting", "Wide", "Hollow", "Sore", "Misty", "Pretty", "Lesser", "Religious", "Genuine", "Very", "Mute", "Scientific", "Obnoxious", "Melted", "Electronic", "Passing", "Exclusive", "Victorian", "Gentle", "Kind", "Precise", "Bitter", "Frightened", "Impressive", "Excellent", "Unpleasant", "Regular", "Female", "Semantic", "Stable", "Evil", "Dull", "Melodic", "Happy", "Beautiful", "Managing", "Fresh", "Far", "Fortunate", "Liberal", "Angry", "Reliable", "Greek", "Horrible", "Handsome", "Scottish", "Decent", "Electrical", "Total", "Yellow", "Enthusiastic", "Loose", "Worthy", "Blue", "Dry", "Shallow", "Legitimate", "Calm", "Hot", "Civic", "Wrong", "Numerous", "Supporting", "Doubtful", "Sound", "Sour", "Wet", "Devoted", "Contemporary", "Deaf", "Ultimate", "Scared", "Reasonable", "Elderly", "Noisy", "Historical", "Nearby", "Shaggy", "Smart", "Immense", "Constant", "Past", "Specified", "German", "Upset", "Public", "Spicy", "Informal", "Brief", "Underground", "Busy", "Domestic", "Grim", "Excess", "Bottom", "Hungry", "Roasted", "Brave", "Crazy", "Minor", "Careful", "Prior", "Rough", "Mysterious", "Confused", "Long", "Current", "Lucky", "Vertical", "Bright", "Ministerial", "Fashionable", "Heavy", "Major", "Amateur", "Respective", "Teenage", "Involved", "Frail", "Nice", "Subjective", "Productive", "Jolly", "Nasty", "Intelligent", "Improved", "Dangerous", "Professional", "Accused", "Lovely", "Renewed", "Pleasant", "Various", "Arbitrary", "Incredible", "Cheerful", "Creepy", "Middle-class", "Mammoth", "Unknown", "Shaky", "Puzzled", "Plain", "Elegant", "Typical", "Unable", "Exact", "Filthy", "Keen", "Operational", "Voluntary", "Secure", "Selfish", "Ripe", "Worrying", "Thorough", "Mass", "Afraid", "Sheer", "Intact", "Required", "Acute", "Combative", "Quick", "Unusual", "Odd", "Proud", "Remaining", "Collective", "Blind", "Outstanding", "Clumsy", "Orange", "Neutral", "Fluffy", "Painful", "Crude", "Manual", "Comfortable", "Printed", "Dreadful", "Big", "Witty", "Protestant", "Chilly", "Narrow", "Early", "Safe", "Daily", "Popular", "Modern", "Prospective", "Blushing", "Gradual", "Tender", "Convenient", "Intense", "Spontaneous", "Full-time", "Jealous", "Lengthy", "Upper", "Strategic", "Brown", "Gigantic", "Fast", "Furious", "Absent", "Absolute", "Abstract", "Canceled", "Academic", "Concrete", "Accepted", "Accessible", ]; const NOUNS = [ "Randomisation", "Waist", "Nightingale", "Maiden", "Luttuce", "Cricketer", "Name", "Bike", "Bill", "Company", "Outset", "Bibliography", "Monday", "Sunlamp", "Director", "Ghana", "Jewelry", "Break", "Nudge", "Buckle", "Stick", "Dream", "Astrolabe", "Burma", "Ship", "Throne", "Baobab", "Shaker", "Laparoscope", "Celery", "Slope", "Drink", "Bower", "Seed", "Billboard", "Chit-chat", "Bomb", "Lumber", "Fixture", "Brushfire", "Ranch", "Rail", "Schedule", "Cucumber", "Handmaiden", "Archaeology", "Crib", "Counter", "Steamroller", "Glove", "Gladiolus", "Mood", "Cracker", "Belligerency", "Bit", "Granny", "Impudence", "Microwave", "Galley", "Others", "Hobbit", "Buffet", "Umbrella", "Yacht", "Shelf", "Balloon", "Floozie", "Archeology", "Hostess", "Drunk", "Gaffer", "Square", "Elephant", "Bag", "Vibraphone", "Suburb", "Celsius", "Armoire", "Radish", "Reflection", "Orangutan", "Input", "Scent", "Crest", "Migrant", "Inn", "Driver", "Bin", "Cost", "Purse", "Appendix", "Bass", "Phrase", "Rowboat", "Climb", "Vessel", "Cowbell", "Grey", "Canteen", "Crown", "Drake", "Bacon", "Temper", "Thursday", "Spectacles", "Leo", "Scorn", "Accelerator", "Chord", "Filth", "Discovery", "Millisecond", "Cardigan", "Outside", "Heron", "Scissors", "Tabernacle", "Spectrograph", "Saving", "Ketchup", "Inglenook", "Paleontologist", "Procedure", "Icecream", "Skiing", "Prose", "Tavern", "Brandy", "Tailspin", "Capitulation", "Brush", "Hamster", "Jacket", "Dragonfly", "Steam", "Windage", "East", "Adapter", "Safety", "Sell", "Waste", "Knife", "Chick", "Hydrogen", "Runaway", "Duststorm", "Harbour", "Tunic", "Tempo", "Libra", "Sari", "Road", "Teacher", "Heater", "Senator", "Walker", "Zucchini", "Intelligence", "Lentil", "Mayor", "Train", "Dead", "Disconnection", "Hammock", "Tintype", "Depressive", "Hell", "Gold", "Inquiry", "Dipstick", "Analgesia", "Tsunami", "Blazer", "Mantua", "Stock-in-trade", "Safe", "Discount", "Flintlock", "Titanium", "Gender", "Writing", "Delivery", "Visit", "Hen", "Laundry", "Positive", "Wine", "Bugle", "Enquiry", "Counter-force", "Conference", "Birch", "Assistance", "Spine", "Daniel", "Boatyard", "Twister", "Turret", "Neurobiologist", "Nail", "Tepee", "Collar", "Planter", "Random", "Cheese", "Fruit", "Fifth", "January", "Sabre", "Push", "Chronometer", "Cactus", "Hydrant", "Scallion", "Snow", "Palm", "Permission", "Sing", "Gem", "Extent", "Nightgown", "Cement", "Employ", "Screw-up", "Macaroni", "Doubt", "Hypothermia", "Parsnip", "Antlantida", "Contract", "Manx", "Cowboy", "Corduroy", "Life", "Call", "Vast", "Cornerstone", "Self", "Issue", "Octagon", "Sweat", "Peacoat", "Jury", "Millimeter", "Slip", "September", "Ambassador", "Lead", "Saviour", "Violin", "Witch", "Alphabet", "Breakpoint", "Patient", "Calcification", "Atom", ];