Same question asked here in 2014 but since a lot has changed since then, I would like to get feedback from experts on the latest best practices.
Our requirement is to provide the merchants with a card fingerprint which uniquely identifies a card number. By using this fingerprint the merchant could check, for instance, if two of her clients are using the same card, or, more interesting, if someone that is not the known owner of the card is about to use it.
We’re aiming for the same strategy Braintree uses according to John Downey answer (the accepted answer) here, I quote:
“In the Braintree API, we offer a unique number identifier for a credit card. This identifier is a random opaque token that will always be the same for card number stored in our system. The seed for this number is different per merchant in our system so you cannot compare them across merchants.
When a new card comes in, we look it up by comparing it to a hashed + salted column. If it matches that existing column we know we can return the same unique number identifier. If it doesn't match any existing record, we use a cryptographically secure pseudo-random number generator to create a new unique number identifier and ensure it doesn't conflict with an existing one.
This way the hashed + salted value never leaves our backend but we can still provide a way for a merchant to uniquely identify stored credit cards.”
As pointed out by @mentallurg, fingerprint computation should be as quick as possible and contain no secret components. The problem in this scenario is it should also be opaque and secure since we can count only with the card number to compute it (not like with devices where you have several parameters like os, version, brand, mac address, etc.). Card holder name is not always provided and card expiration date could change if the physical card is replaced so we can't use them. So computing a quick fingerprint solely with the card number and providing that fingerprint to the client is not an option.
The remaining option then is to use cryptographic hash functions. By securely hashing the card number we can be certain that two card numbers that generate the same hash are actually the same cards, thus satisfying our requirements. Providing the merchant with the hash itself is still not safe, that's why we are generating a randomly secure string and associating it to the actual hash, that way, when a new card comes in, we hash it and if it matches an already existing one we can return the random fingerprint (from which no card data can be retrieved) to the merchant, without exposing the actual hash. If there are no matches then we generate a new random fingerprint, make sure it doesn't collide with an existing one and return it to the merchant.
Next question that arises is what cryptographic hash function/strategy to use. Salting the card numbers is mandatory to prevent rainbow table attacks, the problem with salts here is that they will cause two instances of the same card number to generate different hashes (this is actually a good thing when you're dealing with passwords since you don't want two equal passwords to generate the same hash) which makes it of no use for our requirements.
The best option that we can think of so far is to use a strong cryptographic hash function (tuned for a proper tradeoff between security and performance) and salt it with a unique HSM-backed secret key (or a per-merchant secret key if we want per-merchant hashes).
We are aware of all the PCI implications of saving cards numbers. So far we’ve considered the following alternatives:
Use HMAC using the merchant specific key as the secret cryptographic key. For the cryptographic hash function we must use a cryptographically secure one so we went for SHA3, but then we realized that SHA3 can act as an HMAC by design so, as explained here there’s no need for the HMAC nested construction.
Hash the card number salted with the merchant specific key using SHA3. This seemed like a good option, as long as one uses the strongest version of SHA3 and a long enough key. But then our concern went against brute force attacks in the event of the database being stolen. After some research we found that re-hashing the resulting hash n times would help make the attackers task slow and in the best case scenario even infeasible as long as it doesn't slow down our service. So we went for it and re-hashed our hash n times. But then we were curious about how exactly re-hashing increases security and found out that, even though re-hashing itself is a good idea, implementing your own non-standard hash schema, without understanding what features such a scheme needs in order to be secure, is not. A better option is to go for a hashing algorithm that does re-hashing by design like PBKDF2, Scrypt or Argon2id.
Use PBKDF2 to derive a key from the card number salted with the merchant specific key, setting a large number of iterations and choosing SHA3 as the hash function.
Use Argon2id, the current winner and recommendation of the password-hashing competition, with proper parameters for time, memory, and threads but we're not sure yet about how to properly tune those parameters. It would be really helpful to read your comments on how to do it.
We are debating between options 3 and 4, on the one hand PBKDF2 seems more widely used but on the other there are several resources claiming that it is no longer secure for today's hardware capacity and strongly recommending the usage of Argon2id.
So, what strategy would you use for this scenario? What flaws do you see in the previous options? Would you go for PBKDF2 or Argon2id? Can you see some other way for securely providing a card fingerprint?