Application Level Encryption

Introduction

Welcome to the final lesson of the "Cryptographic Failures" course! In our previous lessons, we explored the importance of cryptography in securing data and identified common vulnerabilities, such as weak algorithms and hardcoded secrets.

In this lesson, we'll focus on understanding the limitations of automatic database encryption and the importance of application-level encryption. Let's dive in! 🚀

The Encryption Reliance Problem

Many developers assume that enabling database encryption features automatically makes their sensitive data secure. While database encryption protects data at rest (when stored on disk), it doesn't protect data in transit or during processing. When your application queries the database, the data is automatically decrypted and returned in plaintext. This means anyone who can access your application or database through legitimate means can view sensitive data in its unencrypted form.

This automatic decryption creates a significant security risk, especially for sensitive information like credit card numbers, personal identification data, or healthcare records. Let's examine how this endpoint vulnerability manifests in code and learn how to properly secure it using application-level encryption.

Vulnerable: Storing Card Information

Suppose we have an endpoint responsible for adding credit card information. For simplicity, we'll skip JWT authentication in this example.

Here's how the endpoint might look without proper encryption:

@RestController
@RequestMapping("/api/payments")
public class PaymentController {
    private final PaymentRepository payments;
    
    public PaymentController(PaymentRepository payments) {
        this.payments = payments;
    }
    
    @PostMapping("/addCardInfo")
    public ResponseEntity<?> addCardInfo(@RequestBody AddCardRequest request) {
        try {
            // Card data is stored without encryption
            Payment payment = new Payment();
            payment.setUserId(request.userId());
            payment.setCardNumber(request.cardNumber());
            payments.save(payment);
            
            return ResponseEntity.ok(Map.of("success", true));
        } catch (Exception e) {
            System.err.println("Database error: " + e.getMessage());
            return ResponseEntity.internalServerError()
                    .body(Map.of("error", "Internal server error"));
        }
    }
    
    public record AddCardRequest(Integer userId, String cardNumber) {
    }
}

This implementation is problematic because it stores the credit card number in its raw form. Even if the database encrypts data at rest, the number is vulnerable during transmission and processing. Additionally, anyone with access to the application can retrieve the unencrypted card numbers.

Let's see how this vulnerability manifests when retrieving data.

Vulnerable: Retrieving Card Information

The vulnerability becomes even more apparent when retrieving stored card information:

@GetMapping("/getCardInfo")
public ResponseEntity<?> getCardInfo() {
    try {
        List<Payment> allPayments = payments.findAll();
        
        List<Map<String, Object>> results = allPayments.stream()
                .map(payment -> Map.<String, Object>of(
                        "user_id", payment.getUserId(),
                        "card_number", payment.getCardNumber()
                ))
                .collect(Collectors.toList());
        
        return ResponseEntity.ok(results);
    } catch (Exception e) {
        System.err.println("Database error: " + e.getMessage());
        return ResponseEntity.internalServerError()
                .body(Map.of("error", "Internal server error"));
    }
}

This endpoint retrieves credit card numbers directly from the database. If an attacker gains access to this endpoint or exploits another endpoint via sql injection (we'll cover sql injection in detail next), they can access the exposed information.

Let's see how an attacker might exploit this vulnerability.

Exploiting the Vulnerability

An attacker with access to the api can easily retrieve sensitive card information:

curl http://localhost:3000/api/payments/getCardInfo

# Example Response:
# [
#   {
#     "user_id": 1,
#     "card_number": "4532-7153-3790-4561"
#   },
#   {
#     "user_id": 2,
#     "card_number": "4532-7153-3790-4562"
#   }
# ]

As you can see, the credit card numbers are exposed in plaintext in the api response. This vulnerability exists regardless of database - level encryption because the data is automatically decrypted when queried. Let's look at how to properly secure this sensitive data.

Secure: Adding Card Information

Here's how to properly hash and store sensitive data:

@RestController
@RequestMapping("/api/payments")
public class PaymentController {
    private final PaymentRepository payments;
    private final HashingUtil hashingUtil;
    
    public PaymentController(PaymentRepository payments, HashingUtil hashingUtil) {
        this.payments = payments;
        this.hashingUtil = hashingUtil;
    }
    
    @PostMapping("/addCardInfo")
    public ResponseEntity<?> addCardInfo(@RequestBody AddCardRequest request) {
        try {
            // Store only the hash of the card number for verification
            String cardHash = hashingUtil.hashCardNumber(request.cardNumber());
            
            // Store the last 4 digits for display purposes
            String lastFourDigits = request.cardNumber().substring(
                request.cardNumber().length() - 4
            );
            
            Payment payment = new Payment();
            payment.setUserId(request.userId());
            payment.setCardHash(cardHash);
            payment.setLastFourDigits(lastFourDigits);
            payments.save(payment);
            
            return ResponseEntity.ok(Map.of("success", true));
        } catch (Exception e) {
            System.err.println("Database error: " + e.getMessage());
            return ResponseEntity.internalServerError()
                    .body(Map.of("error", "Internal server error"));
        }
    }
    
    public record AddCardRequest(Integer userId, String cardNumber) {
    }
}

This secure implementation hashes the credit card number before storing it. We only store the cardHash (for verification purposes) and the lastFourDigits (for display purposes). This way, even if someone accesses the database directly, they cannot recover the original card number. Let's look at how the hashing function works.

Implementing Card Number Hashing

The HashingUtil component uses BCrypt, a strong hashing algorithm specifically designed for passwords and sensitive data:

package com.codesignal.pastebin.util;

import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Component;

@Component
public class HashingUtil {
    private final BCryptPasswordEncoder encoder;

    public HashingUtil() {
        // Use BCrypt with a cost factor of 12
        int saltRounds = 12;
        this.encoder = new BCryptPasswordEncoder(saltRounds);
    }

    public String hashCardNumber(String cardNumber) {
        try {
            return encoder.encode(cardNumber);
        } catch (Exception e) {
            System.err.println("Hashing error: " + e.getMessage());
            throw new RuntimeException("Failed to hash card number", e);
        }
    }
}

We use BCrypt because it is specifically designed to be slow and computationally intensive, making it resistant to brute-force attacks. It also automatically handles salt generation and storage, making it a secure choice for hashing sensitive data.

Note: Hashing is a one-way operation - you cannot retrieve the original card number from the hash. In production applications, you would use payment processor tokenization (like Stripe or PayPal) to handle actual card charges. The hash serves to verify cards and detect duplicates without storing the sensitive data itself.

Now, let's see how we can safely retrieve and display card information to users.

Secure: Retrieving Card Information

When displaying card information to users, we only show the last four digits of the card number:

@GetMapping("/getCardInfo")
public ResponseEntity<?> getCardInfo() {
    try {
        List<Payment> allPayments = payments.findAll();
        
        List<Map<String, Object>> maskedResults = allPayments.stream()
                .map(payment -> Map.<String, Object>of(
                        "user_id", payment.getUserId(),
                        "card_number", "****-****-****-" + payment.getLastFourDigits()
                ))
                .collect(Collectors.toList());
        
        return ResponseEntity.ok(maskedResults);
    } catch (Exception e) {
        System.err.println("Database error: " + e.getMessage());
        return ResponseEntity.internalServerError()
                .body(Map.of("error", "Internal server error"));
    }
}

This implementation ensures that users only see masked card numbers with the last four digits visible. The last four digits are sufficient for users to identify their cards, as this is a standard practice in the payment card industry.

For example, if a user has multiple cards, they can easily recognize that ****-****-****-4561 is their Visa card ending in 4561, while ****-****-****-3789 is their Mastercard ending in 3789. This approach provides a balance between security and usability.

Verifying Card Numbers

When you need to verify a card number (for example, during a payment transaction), you can compare it with the stored hash using BCrypt's verification method:

public boolean verifyCardNumber(String cardNumber, String storedHash) {
    try {
        return encoder.matches(cardNumber, storedHash);
    } catch (Exception e) {
        System.err.println("Hash comparison error: " + e.getMessage());
        return false;
    }
}

This method takes the provided cardNumber and the storedHash, then uses BCrypt's matches functionality to verify if they match. The comparison is done securely, protecting against timing attacks.

If there is an error during comparison, the method returns false to ensure no sensitive information is leaked through error messages.

Here's how to use this verification in a controller endpoint:

@PostMapping("/verifyCard")
public ResponseEntity<?> verifyCard(@RequestBody VerifyCardRequest request) {
    try {
        Optional<Payment> paymentOpt = payments.findByUserId(request.userId());
        
        if (paymentOpt.isEmpty()) {
            return ResponseEntity.status(404)
                    .body(Map.of("error", "Card not found"));
        }
        
        Payment payment = paymentOpt.get();
        boolean isValid = hashingUtil.verifyCardNumber(
            request.cardNumber(), 
            payment.getCardHash()
        );
        
        return ResponseEntity.ok(Map.of("isValid", isValid));
    } catch (Exception e) {
        System.err.println("Verification error: " + e.getMessage());
        return ResponseEntity.internalServerError()
                .body(Map.of("error", "Internal server error"));
    }
}

public record VerifyCardRequest(Integer userId, String cardNumber) {
}

Supporting Entity Model

To complete the implementation, you'll need the Payment entity model:

package com.codesignal.pastebin.model;

import jakarta.persistence.*;
import java.time.LocalDateTime;

@Entity
@Table(name = "payments")
public class Payment {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;

    @Column(name = "user_id", nullable = false)
    private Integer userId;

    @Column(name = "card_hash", nullable = false)
    private String cardHash;

    @Column(name = "last_four_digits", nullable = false, length = 4)
    private String lastFourDigits;

    @Column(name = "created_at")
    private LocalDateTime createdAt = LocalDateTime.now();

    public Integer getId() { return id; }
    public void setId(Integer id) { this.id = id; }

    public Integer getUserId() { return userId; }
    public void setUserId(Integer userId) { this.userId = userId; }

    public String getCardHash() { return cardHash; }
    public void setCardHash(String cardHash) { this.cardHash = cardHash; }

    public String getLastFourDigits() { return lastFourDigits; }
    public void setLastFourDigits(String lastFourDigits) { this.lastFourDigits = lastFourDigits; }

    public LocalDateTime getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

And the repository interface:

package com.codesignal.pastebin.repo;

import com.codesignal.pastebin.model.Payment;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface PaymentRepository extends JpaRepository<Payment, Integer> {
    Optional<Payment> findByUserId(Integer userId);
}

Conclusion and Next Steps

In this lesson, we explored why relying solely on database encryption is not sufficient for protecting sensitive data. We learned how to implement proper security measures for handling credit card data by:

  • Storing only hashed values and the last four digits.
  • Never transmitting full card numbers in responses.
  • Using BCrypt for secure hashing.
  • Displaying masked card numbers with the last four digits for user recognition.

As you move on to the practice exercises, you'll have the opportunity to implement these security methods yourself. In the next lesson, we'll continue to build on this knowledge, further enhancing your application security skills. Keep up the great work! 🌟

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal