First and foremost: if you want security, don't use MIFARE cards. They aren't the best at security. Even if a card isn't vulnerable to something like a hardnested attack or a brute-force attack, the card's data can still be sniffed over-the-air (which will include the keys or allow keys and data to be easily recovered).
Generally, most "secure" MIFARE cards will leverage multiple systems to detect the authenticity of the card. Consider a two-phased approach:
First off, leverage the builtin sector keys. The sector keys, while six bytes of size, are surprisingly difficult to crack due to how the cards work. There's no way to "dump a hash" of a MIFARE key block, so you basically need to do the cracking over-the-air/on-card. As you're not limited to ASCII, an attacker is still limited to going through a worst-case of 281.4 trillion possible combinations of bits for a single key.
Second, leverage card state verification. There are a lot of ways to do this, but a common solution is to use the value block system to keep track of how many times the card was used. A sample process exists below.
A two-phased approach like this allows you to keep out casual users who are just trying to clone their cards to their phones without any experience. It also allows you to (somewhat) perform authentication on the readers themselves which is better than nothing. If a card is copied/compromised without the attacker knowing how the system works, a copied card can only be used once before the two cards lose sync with your server and risk one of them causing both to be deactivated.
Solutions like encrypting card data, as you mentioned, won't really do anything to stop an attacker beyond making it harder for them to read the data on the card. MIFARE blocks aren't even readable if the key isn't present, so encryption really becomes an unnecessary second step unless you're trying to protect a secret.
In this example system, you have three components: the reader, a backend authentication server, and a card. On the card, sectors 1 and 2 are used by your application's data:
- Sector 1 contains a unique serial number assigned to the card. This serial number is protected by a global static key in Key B. The card is set permission mode
101 to only allow ever reads from Key B. Key A does not need to be set.
- Sector 2 is a bit more complicated, as it contains two blocks. Key B should be randomly generated for each card and Key A can be ignored.
- Block 0 contains a randomly-generated secret value assigned to the card on creation. Permissions are set on this block as
101 similar to the one used in Sector 1.
- Block 1 leverages the Value Block feature of MIFARE cards to track the number of times the card has been used. In this case, the Value Block is initialized at value
2,147,483,647 (the maximum for a signed 32-bit number). This block has permissions set to
001 such that Key B can be used to decrement this counter.
Every time a card checks in, the following process happens:
- The reader uses the static Key B for Sector 1 to grab the card's unique serial number.
- The reader requests card metadata from the authorization server, which responds with:
- The expected value for the card currently
- Whether the card can be accepted at this reader (is this user authorized)
- The (random) Key B for this card
- The secret value in Sector 2 Block 0 for this card
- The reader reads Sector 2 and checks the card's secret value and current counter.
- If either value is wrong, the card's value block is decremented to below zero, bricking the card.
- If both values are correct, the card's value block is decremented by one and the card is granted access.
- The reader sends the server the card's new expected value. If the card was "bricked", it will be marked as disabled and a security alert is generated.
If additional security is required (for example, the readers can't be trusted), the readers can send the card's values back to the server for authorization. Note that this does add some (generally insignificant) delay, but it can be annoying depending on the application.
Also note that such an example system is highly dependent on everything staying online at all times. In the event that a server is unable to be reached, you may want the system to fail "safe" and grant access in certain cases. If this is to be done, readers should cache read events and then submit them once the server is back online. This would require some restructuring of the card verification system to ensure that all reads are "plausible", and would generally need to take place as a scheduled job or similar concept.
Any such system is (extremely) variable and the actual implementation details depend on how secure and usable you want such a system to be in each of the edge cases. Keep in mind that after a certain point, your return on investment into security starts to decrease and additional complication becomes complication for complication's sake.