5 Security Audit Tips from MSQ Snap and Consensys Diligence

This article results from a collaboration between Consensys Diligence and the MSQ project with support from DFinity Foundation and MetaMask Snaps DAO

by Consensys Diligence & MSQ teamJune 6, 2024
5 Security Audit Tips from MSQ Snap and Consensys Diligence

Consensys Diligence is an auditing branch within Consensys Software Inc., renowned for its unparalleled expertise in conducting meticulous audits across a spectrum encompassing Solidity, Cairo, and an array of ZK libraries. The Diligence team is spearheading auditing and security standards for MetaMask Snaps and works closely with the MetaMask team to ensure the security of the wallet feature. Moreover, the Diligence team pioneers the development of security tooling and cutting-edge continuous fuzzing solutions validated within the most demanding production environments.

The MSQ project was recently launched on the MetaMask Snaps Store. MSQ is a Snap that allows users to interact with the Internet Computer network, manage IC-based tokens, safely authorize within IC-based dapps, and pay for digital goods and services. In essence, it's a crypto wallet integrated with MetaMask, designed specifically for the Internet Computer with distinct features for authorization and payments.

Together, Diligence and MSQ teams identified five key areas to cover in this article—Documentation, Software Design, Code, Supply Chain, and Fixes—providing a list of practices employed by the MSQ team during development, along with commentary from the Diligence team elaborating on the security benefits of these practices, making it a helpful resource for anyone building a Snap for the MetaMask Wallet or any other security-critical system subject to possible security audit. Let’s begin!

1. Create multi-layered documentation


Documentation transforms the concepts in your head into something visible to others. Layered documentation helps the reader understand these concepts the way you intend by gradually immersing them in the context. Obviously, the cleaner the documentation, the wider perspective it can give on your project, and the better others will understand you.

We define documentation layers in reverse order of how a reader would typically access them, with each layer offering a higher level of abstraction. This way, readers starting with the big picture will progressively dive into the details.

1.1 Code

The ultimate source of information about your software is the code itself, which serves as the foundational layer of your documentation. While it contains all necessary information, it is hard to process this information. The cleaner the code, the easier it is to parse the higher-level abstractions from it. But in real systems, these abstractions are hidden behind complex interactions of the system’s components, weeks of technical debt, and quick, dirty solutions.

So, first of all, you want to protect people reading your code from being misled by some complicated parts of your system. The best way to do that is to reduce the cognitive load required to read your code. Some strategies include:

  • Making your code navigable, allowing for easy traversal in IDEs.
  • Using verbose naming to clarify the purpose and functionality of code elements.
  • Employing strongly typed languages to define clear data formats.

The MSQ Snap and the dapp frontends are developed in TypeScript, which has prevented numerous potential errors, contrasting with the pitfalls of plain JavaScript. Similarly, our backend leverages Rust, known for its robust type system. Also, MSQ Snap exports a bunch of methods that can be executed by a third-party app. However, we don’t use strings to identify these methods, instead we use constants. We have a constant object like this:

export const SNAP_METHODS = {
  protected: {
    identity: {
      login: "protected_identity_login",
      ...
    },
    icrc1: {      
	    ...
    },
    ...
  },
  public: {
    identity: {
      sign: "public_identity_sign",			
      ...
    },
    ...
  },
};

In code, this constant object can be used as a guide for accessing the right method, and readers can understand a lot about this method by its identifier in the object. For example, the method SNAP_METHODS.protected.identity.login can only be executed by MSQ's companion dapp and allows the user to log in. This approach enhances code clarity and simplifies refactoring, as changes to method names are centrally managed rather than updated across multiple occurrences.

To match method names, we use a big switch expression with branches like this:

case SNAP_METHODS.protected.identity.login: {
  result = protected_handleIdentityLogin(req.params.body);
  break;
  }

As you can see, it is very hard to misinterpret something while reading here —everything is named precisely, leaving no room for imagination.

Diligence comment
Time is a critical factor. While developers have weeks to understand a codebase, security engineers often have just days to identify risks. This constraint highlights the importance of reducing cognitive load in code—a practice that benefits both developer efficiency and security.

What we can learn from MSQ's codebase is, that, clear, navigable code with descriptive naming and strong typing (like TypeScript) reduces misunderstandings, a common source of vulnerabilities.

But remember, TypeScript type annotations are only checked at compile-time! For any data that is processed or parsed at runtime (RPC, JSON, ...) additional type enforcements, data format and bounds checks are required! For this purpose, MSQ elegantly employs zod, a TypeScript-first schema validation with static type inference library and we would recommend teams to follow that approach.

MSQ hit a sweet spot by making code intentions clear through the right amount of commentary. This clarity helps security researchers quickly grasp the codebase's "red line" and core concepts. By writing code that's easy to understand, developers significantly boost the effectiveness of rapid security reviews.

1.2 Code comments

We believe commenting isn't about covering 100% of your code. It's crucial to annotate parts that are complex or poorly written. Areas like regular expressions, cryptography, and string manipulations particularly benefit from clear comments.

For example, at MSQ, we have a function that escapes possible Markdown control characters in a string. Its body is a single line of code, but there is regular expression matching, so we describe its functionality in a comment:

/**
 * 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));
}

If you don’t mind AI in your workflow, ChatGPT is great at generating good comments for such utility functions quickly (just double-check the output it provides).

Diligence comment
As security reviewers we use function commentary as roadmaps through unfamiliar code. Remember, we only have so much time to review code, and when comments don't match the implementation, it's not just confusing — it's a red flag.

They often reveal that either the function or its description deviates from the spec, signaling potential vulnerabilities. Maybe a function skips promised checks or handles data unexpectedly, which can lead to severe consequences.

Therefore, keep your comments concise and accurate, similar to how MSQ does it in their codebase. Inline comments are not just notes; they're beacons guiding us through your code, and misalignments are clues that something might be amiss.

1.3 System-level documentation

After ensuring your code and its complexities are well-documented, the next step is to outline the system’s architecture. This involves detailing the interactions between components, and the protocols, algorithms, and data structures your software employs. This layer primarily uses human-readable formats, such as text and diagrams, rather than code.

MSQ's system documentation includes an architecture overview and an integration guide. These documents, rich with textual descriptions, examples, and diagrams, clarify how different system parts interact.

Sequence diagrams are invaluable for visualizing component interactions. We recommend using diagrams.net (draw.io), a free tool that has proven versatile across various projects.

Diligence comment
Unlike inline comments that detail functions, system docs provide a 10,000-foot view, revealing an application's entire landscape. This perspective is crucial for spotting cross-component issues or user-flow vulnerabilities that are often invisible at the code level.

Think of a security review like a game of Command & Conquer. You start with a mostly obscured map. As you explore, system docs act like scout units, uncovering the full architecture — permissions, Snap-dApp interactions, user interactions. This unfolding overview lets reviewers trace user journeys, like tracking game unit movements, to spot paths that might bypass security.

Moreover, comprehensive system docs should contain security-relevant remarks and, ideally, output from a threat modeling exercise. Providing a clear overview of the main components, interactions, risks, actors, and typical user flows. This helps reviewers focus on high-priority areas and spot deviations from the specification.

For developers, good system documentation isn't just paperwork; it's a shared map that guides offensive and defensive strategies, transforming security reviews from blind probes into strategic operations.

1.4 Big picture documentation

This layer includes a wide variety of materials, such as articles, landing pages, blog posts, demo videos, and more.

This information is mostly about business stuff, but it makes a great deal of answering the question “What?” while all the previous layers were answering the “How?” question with different levels of detail.

A newcomer would most likely meet your project through such a material, so keeping this layer as simple and visual as possible is a good idea. Also, this kind of material is usually the only one that shows the software in action - how a user could reach from state A to state B using your software. By seeing it in action, people introduce themself to the main idea of your software, allowing them to make guesses on how this software works under the hood.

For example, MSQ’s landing page hero screen says with big letters: “USE THE INTERNET COMPUTER WITH METAMASK”. After reading through this simple sentence, the reader would probably guess: “Ah, so this is a wallet Snap that can store tokens from the Internet Computer blockchain. So it probably works the same way as other Snaps for other blockchains, just using technologies from the IC’s dev ecosystem,” - which is a 100% correct guess.

Diligence comment
Our experience with MSQ and numerous other blockchain projects has consistently shown that clear, high-level documentation is not just beneficial — it's critical for both user adoption and security. During our review of MSQ's Snap, we were positively surprised by their multi-layered documentation approach. A video walkthrough from their developer quickly got us up to speed. We would quickly understand Snap's features even before looking at the code itself. This high-level overview showcased the user interface, complete with commentary on expected behaviors and typical user flows. Such visual aids are invaluable, allowing security experts to grasp the intended "happy paths" encoded into the application much faster and look for vulnerabilities that are often hidden off these paths. This, combined with a live walkthrough of the snap and code, is typically the ideal start for a security review.

2 Restrict design where possible


This part is probably the most important for the security of your project. Your code can be 100% safe from bugs, and your infrastructure can be protected with millions of dollars worth of various measures. But all of those efforts would mean nothing if your design contains some fundamental flaw that can be exploited by malicious actors.

While specific examples vary by use case, we offer several universal tips.

2.1 Develop security guidelines and follow them

At the outset of MSQ, we established core security principles that align with our vision:

  • MSQ team knows nothing: We do not collect any user-sensitive information, including key materials, authorization session data, or account details.
  • Cryptographic material never leaves the Snap: Protected user MetaMask entropy is processed exclusively within the Snap, generated on demand, and discarded immediately after use.
  • MSQ is deterministic: Users can recover all funds, identities, and key pairs deterministically using their root seed phrase without needing additional knowledge.
  • MSQ is privacy-first: Our efforts prioritize user privacy above all else.

These principles are tailored to MSQ's specific needs. However, the MetaMask team also provides a set of security guidelines for Snap developers, which MSQ adheres to, available here.

For example, there is a Minimum permissions principle, which basically means that the fewer permissions your Snap asks from a user, the better its security. MSQ only asks 4 permissions:

  • endowment:rpc
  • snap_dialog
  • snap_manageState
  • snap_getEntropy

We don’t need endowment: network access because MSQ is only responsible for signing transactions, not for delivering them to the IC—the integrating dapp is responsible for the latter.

You should do the same thing in your Snap - take the default guideline from MetaMask, extend it with your specific rules, and follow them.

Diligence comment
We tell our clients, "Security isn't a feature you add; it's a principle you design around." MSQ's establishment of core security principles at the project's outset is what we look for. Too often, projects treat security as an afterthought, bolting on measures after the core architecture is set. This retrofit approach inevitably leaves fundamental flaws that code-level fixes can't truly resolve.

At ConsenSys Diligence, we provide security expert services, tooling, and we advocate for end users' rights. This includes ensuring users' key material stays safe within wallet trust boundaries, never unintentionally exposed outside this zone. We also focus on preserving user privacy, ideally preventing even metadata or side-channel information leaks to third parties. Additionally, we push for snaps to request only the minimum permissions needed for functionality - the principle of least privilege.

MSQ's proactive stance aligns with these principles. By prioritizing security from the start, they're building a stronger foundation — one that's inherently more resistant to vulnerabilities.

2.2 Protect the user at all costs

This is more like a meta-guideline - it is a principle that should be implemented in any software: from online games to banking systems.

The most notable example of this in MSQ is our approach to identity management. As you might know, the majority of wallets out there supply the user with a set of identities (addresses, accounts, etc.). This set can be accessed by any dapp, with the user's consent. For example, to sign some transactions.

This is very useful for integrations between dapps, because any dapp can invoke smart-contract methods of other dapps on behalf of the user and extend each other’s capabilities this way. For example, a crypto payment platform can call Uniswap’s smart-contracts to automatically swap buyer’s tokens so they match what the seller’s expecting to receive as a payment for a particular good or service.

But this is harmful for security, since scam websites can trick people into signing malicious transactions and empty their wallets.

In contrast to that, MSQ adopts identity scoping technique - each dapp (each distinct url origin) has its own separate set of user identities it can interact with, with no ability to interact with user's identities from other dapps. This not only solves the scam websites issue, but also greatly helps with the privacy, because dapps can no longer track the user and see what smart-contracts (which are called canisters in the IC) does they interact with. This method, inspired by Internet Identity and the WebAuthn specification, significantly mitigates security risks.

For example, if a well-known dapp is compromised, traditional wallets would be vulnerable across all dapps. In contrast, MSQ’s identity scoping confines the threat to the compromised dapp, preventing broader damage.

However, this method limits direct integration between dapps, as users have distinct identities on each. MSQ offers solutions for specific scenarios, like allowing identity sharing between dapps with dual consents, useful for situations like domain name changes.

Additionally, MSQ ensures that the data a dapp can access is strictly tied to the relevant identity, further securing user information and maintaining privacy across different applications.

Diligence comment
The trade-off of limited direct dApp integration is a thoughtful one. In our risk assessments, we often discuss the "security budget"—acknowledging that perfect security can hamper functionality. MSQ has spent this budget wisely. By constraining cross-dApp interactions, they've drastically reduced one of the most severe risks (widespread identity abuse) while preserving core wallet functions.

3 Write sound code


We already talked about the code a little, when we discussed the documentation. Here we want to emphasize on a couple of other aspects of code, which are more related to technique other than to readability.

3.1 Your code

"Sound" here means the code behaves predictably, covering all expected and unexpected inputs. For instance, how should your Snap react if given a base64 string when it expects hex? Or if methods are called in an unintended sequence, or an undefined RPC method is invoked?

Addressing these uncertainties requires:

  1. Clear error handling: Ensure every branch and case in your code is checked and handled appropriately. In MSQ’s Snap, we use TypeScript's exhaustive checks in switch-case statements to catch any unexpected method calls, as shown below:
default: {
  err(ErrorCode.INVALID_RPC_METHOD, request.method);
}

This setup captures cases where an unknown RPC method identifier is used.

  1. Input sanitation: Validating input data against expected types and formats can be complex but is essential for security. We utilize the zod library in TypeScript for this, extending TypeScript’s type system to runtime checks. Zod allows detailed schema definitions, ensuring only valid data is processed.

For example, our method SNAP_METHODS.protected.identity.login uses a zod schema to validate input rigorously:

export const ZIdentityLoginRequest = z.object({
  toOrigin: ZOrigin,
  withIdentityId: ZIdentityId,
  withLinkedOrigin: z.optional(ZOrigin),
});

export const ZOrigin = z.string().url();
export const ZIdentityId = z.number().int().nonnegative();

Here, ZOrigin and ZIdentityId are not mere strings or numbers but specifically validated subtypes, ensuring inputs are not just correct in type but also contextually appropriate.

By defining zod schemas as precisely as possible, you can preemptively solve many potential issues. Auditors recommended enforcing strict validation in MSQ, which, upon implementation, identified and rectified several security risks.

Diligence comment
This is not the first time we hear from our clients that implementing stricter validation based on our recommendations for a particular attack scenario revealed several other security risks. This mirrors our experience precisely and that's why we always advocate for strict input validation to reduce attack surface. In many security reviews, we start by scrutinizing input validation. Tightening this often exposes lurking issues — a domino effect where fixing one vulnerability reveals another. It's a clear signal that the project takes our feedback seriously, iteratively hardening their system. With reduced attack surface it gets incrementally harder for attackers to successfully exploit weaknesses.

3.2 Third-party code

At MSQ, we consider it our duty to provide dapp developers with an intuitive library, facilitating correct and efficient interaction with our Snap. This enables not only MSQ but also third-party developers to maintain clean and sound codebases.

Such a library is beneficial for several reasons:

  • Simplifies API complexity: We abstract the Snap's complexities into a user-friendly API. For instance, MSQ’s client library uses CBOR for data encoding, bypassing the constraints of JSON-RPC and sparing developers the hassle of manual data encoding and decoding.
  • Ensures secure and direct interactions: Certain Snap functionalities are exclusive to our companion dapp. To facilitate this, we manage the redirection and data transfer processes, ensuring a seamless flow via postMessage. This internal handling guarantees that third-party developers interact with our Snap through a clear, straightforward API.
  • Enhances semantic clarity: A clean API is more than just a tool; it’s a communication medium, making the code more readable and understandable, free from the clutter of intricate encoding/decoding processes and additional operational layers.

By prioritizing these aspects, MSQ aims to create an ecosystem where both our Snap and the integrating dapps can operate efficiently and securely.

Diligence comment
MSQ’s approach to third-party code integration through their library demonstrates a commitment to simplifying development while maintaining security. While at its core, security must be strictly enforced in the Snap context, a dapp facing library can provide a convenient way for communicating with the Snap. However, it should always be noted that the dapp developer needs to ensure secure interaction with the Snap and safe encoding of data when used within the dapp's context.

4 Protect your supply chain


Recent XZ backdoor another time emphasized how important it is to work against malicious code getting inside some of your dependencies. At MSQ we use pnpm to manage dependencies, but there is actually no difference in what package manager you prefer. There are two things we do at MSQ to protect against such attack vectors:

4.1 Bind packages to specific versions

Using pnpm install --save <package-name> typically installs the latest version of a package, adhering to the semver spec, which allows auto-updates to non-breaking newer versions. This is denoted by the caret (^) symbol in package.json, signifying a range of acceptable versions:

"zod": "^3.22.4" // Installs version 3.22.4 or newer up to 4.0.0

However, to prevent risks from malicious updates, it's safer to lock dependencies to specific versions:

  1. Eliminate the caret (^) from your package.json to fix each dependency version. Remove any existing lock files and node_modules, then execute pnpm install to align with the defined versions strictly.
  2. Use the --save-exact flag for future installations (pnpm install --save-exact <package-name>) to avoid automatic updates.

Trust is a factor in this decision. For example, at MSQ we consider @metamask and @dfinity dependency namespaces safe and allow updates within their semver range. However, while doing so, be cautious of potential vulnerabilities from transitive dependencies.

Diligence comment
Shifting projects to exact dependency versions has prevented countless issues:

  • It's stopped silent updates from reintroducing patched vulnerabilities.
  • It's prevented "version skew" where different modules see different library behaviors.
  • It's been crucial in post-incident investigations, allowing us to replicate the exact environment.

Every next update can be malicious, in any namespace, any package. By reviewing used packages and only binding to known safe versions preventing accidental update the attack surface for supply chain attacks is further reduced. Trusting certain vendors is okay, as long as the new versions are manually verified not to contain malicious code before use. Note, that, especially well known packages used by many projects are prime targets for malicious actors and as the past as shown, any such core library might fall to sophisticated supply chain attacks (see XZ backdoor attack)

4.2 Commit lock files to your repository


To ensure consistency across your team and maintain reproducible builds, commit your lockfiles to the version control system. In MSQ, we leverage `pnpm workspaces` and `turborepo`, necessitating a single root lockfile, `pnpm-lock.yaml`. However, your setup might require maintaining multiple lockfiles; all should be version-controlled to guarantee environment consistency.

Diligence comment
This practice, while sometimes overlooked as a mere development convenience, is in fact a critical component of maintaining a secure and stable codebase. Our years of auditing smart contracts, blockchain components, and now MetaMask Snaps have repeatedly shown that reproducible builds are not just a nice-to-have but a fundamental security requirement. Committing lockfiles is one puzzle piece to reduce attack surface for supply chain attacks. When npm install is not a lottery anymore, concrete versions and checksums leave an audit trail in the repository, builds are more deterministic, and "Works on my Machine" discussions are no more.

5 Fix fast and clean


While this isn’t directly about our Snap, understanding how to address audit findings efficiently is vital.

As soon as the auditors team reports a finding, you should start working on a fix. The fix size and complexity clearly depends on the finding, but there is an advice to make it smaller and cleaner - fix the cause, not the symptom, where possible.

For instance, at MSQ, the auditors discovered that it is possible to inject Markdown control characters in our MetaMask consent messages. Instead of fixing each separate consent message, making multiple changes in different parts of the code, we simply redefined text() and heading() elements (from @metamask/snaps-sdk), so they first pass their content through the escapeMarkdown() function and only then render it. This allowed us to simply replace import expressions in all files with consent messages with our custom elements, instead of fixing each invocation of text() and heading() separately, which was a much cleaner fix.

Effective resolution not only improves the specific issue but also preemptively secures against unseen vulnerabilities.

Diligence comment
Too often, we see projects take a whack-a-mole approach to security, patching each individual issue as it appears without considering the underlying vulnerability class. By addressing the root cause of issues and not holding back from extinguishing vulnerability classes globally, shows that MSQ understands security not as a series of patches but as a holistic discipline. As security experts, we can only recommend our clients to not rush fixes and take enough time for turning a security finding into an event that has a positive effect on the security of the project.

Conclusion


That concludes our insights into successfully navigating a MetaMask Snaps security audit. Following these guidelines helped the MSQ team receive a positive report from the Diligence team. We hope sharing the MSQ experience will assist you in your security audit preparations, ensuring a thorough and effective process.

Consensys Diligence has helped a wide range of projects across the blockchain ecosystem ensure their protocols are ready for launch and built to protect users. We’re happy to check your Snap for these and more vulnerabilities in a custom audit. Hit us up and get a quote!

Receive our Newsletter