In this project, I demonstrate how I built a lightweight, highly secure Secrets Vault. It acts as an API to safely store and retrieve sensitive key-value pairs (like database passwords or API tokens).

For maximum security, this vault implements Zero-Trust principles: it discards traditional password authentication in favor of Mutual TLS (mTLS) for network transit, and it uses AES-256-GCM to ensure all data is encrypted-at-rest on the disk.

---
config:
  theme: 'neutral'
  flowchart:
    padding: 20
    subGraphTitleMargin:
      top: 15
      bottom: 15
---
flowchart LR
    classDef device fill:#272822,stroke:#75715e,stroke-width:2px,color:#f8f8f2,rx:4px,ry:4px
    classDef server fill:#f92672,stroke:#272822,stroke-width:2px,color:#f8f8f2,font-weight:bold,rx:4px,ry:4px
    classDef service fill:#a6e22e,stroke:#272822,stroke-width:2px,color:#272822,font-weight:bold,rx:4px,ry:4px
    classDef storage fill:#66d9ef,stroke:#272822,stroke-width:2px,color:#272822,font-weight:bold,rx:4px,ry:4px

    Client["Laptop
(Admin Client)"]:::device subgraph Raspi ["Raspberry Pi (Server)"] direction TB API{"Spring Boot API
(Port 8443)"}:::server Crypto("CryptoService
(AES-256-GCM)"):::service Disk[("vault-data.json")]:::storage API -- "Plaintext" --> Crypto Crypto -- "Ciphertext" --> Disk end style Raspi rx:8,ry:8 Client -- "mTLS Tunnel
(Client Cert Verified)" --> API

Note that some parts of the process were simplified to focus more on the concept of building this. I have marked the specific sections where this is the case.

Why I Built This

Most backend engineers interact with secrets management tools like HashiCorp Vault or AWS Secrets Manager daily, but few take the time to understand how they work under the hood.

I built this to practically apply advanced security concepts and to create the infrastructure that secures the database credentials.

The Setup

To simulate a real-world, distributed environment, I split this project across two distinct physical machines:

  • The Server: A Raspberry Pi 3B+ running Raspbian, hosting the Spring Boot API.
  • The Client: A laptop (Linux), acting as an authorized administrator.

Architecture & Data Flow

Once the API is fully implemented, the application processes the secret. The following sequence details how the API intercepts the data, handles the encryption, and writes the AES-encrypted ciphertext to the local disk.

---
config:
  theme: 'neutral'
---
sequenceDiagram
    participant C as Client (cURL)
    participant A as SecretsController
    participant S as CryptoService
    participant F as vault-data.json

    C->>A: POST /secrets/db_password (mTLS)
    Note over C,A: TLS Handshake verifies
client.crt against PiVaultCA A->>S: encrypt("SuperSecret...") Note over S: 1. Generate 12-byte IV
2. Encrypt with 256-bit Master Key
3. Generate Auth Tag S-->>A: return "IV:Ciphertext" A->>F: Read current JSON Map A->>F: Write Map + new "IV:Ciphertext" A-->>C: HTTP 200 "Secret successfully encrypted..."

Setting up the Raspberry Pi

First, I prepared the Raspberry Pi with the necessary networking and runtime environments (Java 21).

sudo apt update -y && sudo apt upgrade -y
sudo apt install openjdk-21-jdk -y

Establishing the Public Key Infrastructure (PKI)

To enforce mTLS, the server needs to mathematically verify the identity of the client. This requires building a custom Certificate Authority (CA) to act as our trust anchor.

For simplification, I chose a fictional and unsafe password: pivault.

Using SSH and OpenSSL I can generate the cryptographic materials on the Raspberry Pi.

  1. Create the CA Root Certificate

     # Generate the CA private key
     openssl genrsa -out ca.key 2048
    
     # Create the CA root certificate
     openssl req -new -x509 -days 3650 -key ca.key -out ca.crt -subj "/CN=PiVaultCA"
    
  2. Generate the Server Certificate Because the server is accessed via a local IP, we must define a Subject Alternative Name (SAN) so the client doesn’t reject the connection due to an IP mismatch.

     # Generate the Server private key and CSR
     openssl genrsa -out server.key 2048
     openssl req -new -key server.key -out server.csr -subj "/CN=PiVaultServer"
    
     # Create a config file for the SAN (Replace with your Pi's IP)
     echo "subjectAltName=IP:192.168.178.100" > extfile.cnf
    
     # Sign the Server CSR with the CA
     openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt -days 365 -extfile extfile.cnf
    
  3. Generate the Client Certificate

    A Note on Best Practices: In a true production environment, the client should generate its own private key and CSR locally, and only send the CSR to the CA for signing. This ensures the client’s private key never travels across a network. For the scope of this demonstration, I generated everything on the Pi and copied them to the client.

     # Generate Client key and CSR
     openssl genrsa -out client.key 2048
     openssl req -new -key client.key -out client.csr -subj "/CN=ClientAdmin"
    
     # Sign the Client CSR with the CA
     openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out client.crt -days 365
    

Preparing Certificates for Spring Boot

Spring Boot handles SSL/TLS natively, but it expects certificates to be bundled in specific formats like PKCS12 rather than raw OpenSSL .crt and .key files.

# Package the Server Cert and Key into a PKCS12 Keystore
openssl pkcs12 -export -in server.crt -inkey server.key -out server-keystore.p12 -name pi-vault-server -passout pass:pivault

# Import the CA into a Java Truststore
keytool -import -file ca.crt -alias PiVaultCA -keystore server-truststore.p12 -storepass pivault -storetype PKCS12 -noprompt

Bootstrapping the API

Quickly scaffold the project directly on the Pi using the Spring Initializr API via curl:

curl https://start.spring.io/starter.zip \
    -d dependencies=web \
    -d javaVersion=21 \
    -d type=maven-project \
    -d name=secrets-vault \
    -o secrets-vault.zip

unzip secrets-vault.zip

I moved server-keystore.p12 and server-truststore.p12 into the src/main/resources directory and configured the application.properties to enforce mTLS.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Start the server on port 8443 (standard for alternative HTTPS)
server.port=8443

# Enable SSL
server.ssl.enabled=true

# 1. Server Identity (The server's own certificate)
server.ssl.key-store=classpath:server-keystore.p12
server.ssl.key-store-password=pivault
server.ssl.key-store-type=PKCS12

# 2. Client Trust (Enforcing mTLS)
server.ssl.trust-store=classpath:server-truststore.p12
server.ssl.trust-store-password=pivault
server.ssl.trust-store-type=PKCS12
server.ssl.client-auth=need

Next, I wrote a dummy “Ping” controller to verify the current state is working:

1
2
3
4
5
6
7
8
9
10
11
12
13
package main.java.com.secretsvault.demo;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class SecretsController {

    @GetMapping("/ping")
    public String ping() {
        return "mTLS connection successful! Your client certificate is valid.\n";
    }
}

Start the Server:

./mvnw spring-boot:run

Testing the Zero-Trust Boundary

With the server running, I moved to my laptop (the client). I copied ca.crt, client.crt, and client.key from the Pi to my local machine.

To test the connection, I used curl:

curl --cacert ca.crt \
     --cert client.crt \
     --key client.key \
     https://192.168.178.100:8443/ping

# Result:
mTLS connection successful! Your client certificate is valid.

When trying to connect without the certificates, the result is an immediate handshake failure. This proves the mTLS barrier is active.

Implementing Encryption-at-Rest

Now that the basic test setup works, I need to secure the data on disk. For that, I built a CryptoService utilizing AES-256-GCM. GCM (Galois/Counter Mode) is important because it provides authenticated encryption. That means it mathematically guarantees the ciphertext hasn’t been altered.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
package main.java.com.secretsvault.demo;

import org.springframework.stereotype.Service;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.security.SecureRandom;
import java.util.Base64;

@Service
public class CryptoService {
    // In production, this MUST be injected via environment variables!
    private static final byte[] MASTER_KEY = "12345678901234567890123456789012".getBytes(); 
    private static final int GCM_TAG_LENGTH = 128; 
    private static final int GCM_IV_LENGTH = 12; 

    public String encrypt(String plaintext) throws Exception {
        byte[] iv = new byte[GCM_IV_LENGTH];
        new SecureRandom().nextBytes(iv);

        Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
        SecretKey keySpec = new SecretKeySpec(MASTER_KEY, "AES");
        GCMParameterSpec gcmParameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);

        cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmParameterSpec);
        byte[] cipherText = cipher.doFinal(plaintext.getBytes());

        return Base64.getEncoder().encodeToString(iv) + ":" + Base64.getEncoder().encodeToString(cipherText);
    }

    public String decrypt(String encryptedData) throws Exception {
        String[] parts = encryptedData.split(":");
        if (parts.length != 2) throw new IllegalArgumentException("Invalid encrypted payload");

        byte[] iv = Base64.getDecoder().decode(parts[0]);
        byte[] cipherText = Base64.getDecoder().decode(parts[1]);

        Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
        SecretKey keySpec = new SecretKeySpec(MASTER_KEY, "AES");
        GCMParameterSpec gcmParameterSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);

        cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmParameterSpec);
        byte[] decryptedText = cipher.doFinal(cipherText);

        return new String(decryptedText);
    }
}

Update the SecretsController to utilize this service.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
package main.java.com.secretsvault.demo;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.web.bind.annotation.*;

import java.io.File;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

@RestController
@RequestMapping("/secrets")
public class SecretsController {

    private final CryptoService cryptoService;
    private final ObjectMapper objectMapper = new ObjectMapper();
    private final File vaultFile = new File("vault-data.json");

    public SecretsController(CryptoService cryptoService) {
        this.cryptoService = cryptoService;
    }

    @PostMapping("/{key}")
    public String saveSecret(@PathVariable String key, @RequestBody String plaintextSecret) throws Exception {
        String encryptedData = cryptoService.encrypt(plaintextSecret);
        
        Map<String, String> vault = loadVault();
        vault.put(key, encryptedData);
        saveVault(vault);

        return "Secret successfully encrypted and stored.\n";
    }

    @GetMapping("/{key}")
    public String getSecret(@PathVariable String key) throws Exception {
        Map<String, String> vault = loadVault();
        String encryptedData = vault.get(key);
        
        if (encryptedData == null) {
            return "Secret not found.\n";
        }
        
        return cryptoService.decrypt(encryptedData) + "\n";
    }

    // --- Helper methods for File I/O ---
    private Map<String, String> loadVault() {
        if (!vaultFile.exists()) return new HashMap<>();
        try {
            return objectMapper.readValue(vaultFile, new TypeReference<Map<String, String>>() {});
        } catch (IOException e) {
            throw new RuntimeException("Failed to read vault file", e);
        }
    }

    private void saveVault(Map<String, String> vault) {
        try {
            objectMapper.writerWithDefaultPrettyPrinter().writeValue(vaultFile, vault);
        } catch (IOException e) {
            throw new RuntimeException("Failed to write vault file", e);
        }
    }
}

Add Jackson as a dependency to pom.xml.

...
    <dependencies>
    ...
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
        </dependency>
        ...
    </dependencies>
    ...

(Kill and) start the Server again:

./mvnw spring-boot:run

The Final Test

Send a dummy password to the vault:

curl --cacert ca.crt --cert client.crt --key client.key \
     -X POST -H "Content-Type: text/plain" \
     -d "SuperSecretDatabasePassword123" \
     https://192.168.178.100:8443/secrets/db_password

# Result:
Secret successfully encrypted and stored.

To retrieve it:

curl --cacert ca.crt --cert client.crt --key client.key \
     -X GET https://192.168.178.100:8443/secrets/db_password

# Result:
SuperSecretDatabasePassword123

Check the physical file on the Raspberry Pi’s disk to see how the data is stored:

cat vault-data.json 

# Result:
{
  "db_password" : "8NNefHl8NV3jjB+4:IunsxXprBcTu0PBsn2n6aEcuqZJKH+Q+KHPmBK57+c8vmphpkyW7EF5zzHpjDg=="
}

If an attacker physically stole the Raspberry Pi or compromised the host OS to read the disk, the data is entirely useless without the 256-bit Master Key.

The full source code can be found on my GitHub.

Challenges Faced

Initially, when starting the Spring Boot server, a java.security.InvalidAlgorithmParameterException was thrown.

That was because I tried to generate the .p12 truststore entirely with OpenSSL. However, OpenSSL and Java package certificates slightly differently. When OpenSSL creates a .p12 file containing only a public certificate (the CA) and no private key, it doesn’t explicitly flag the certificate as a trustedCertEntry. Given Java’s strict security manager, it rejected the truststore as “empty” because it lacked this explicit trust anchor flag.

I resolved this by using Java’s native keytool utility to import the OpenSSL-generated ca.crt into the truststore, which perfectly formatted the metadata for Spring Boot.

Limitations & Next Steps

Because this is a simplified Proof of Concept, the file I/O operations in the SecretsController are currently not thread-safe. In a true production environment with multiple concurrent requests, reading and writing to vault-data.json simultaneously could lead to race conditions where data is overwritten. While a quick technical fix would be adding the synchronized keyword to the saveSecret method, a real-world enterprise application would utilize a robust database with proper transaction management.

Technical Stack

Category Technologies & Concepts
Languages & Frameworks Java 21, Spring Boot 3 (Spring Web)
Networking & Security mTLS (Zero-Trust), Public Key Infrastructure (PKI), x.509 Certificates
Cryptography AES-256-GCM (Authenticated Encryption), Initialization Vectors (IV)
Systems & Tools Linux (Raspbian), OpenSSL, Java Keytool, Maven, cURL, ssh