Quantum-Safe Cloud -- Part 3
QuantumAPI EaaS: Encrypt Anything
The Problem
Secrets in QuantumVault. Connection strings gone from config. Good start.
But secrets are one problem. Sensitive data is another.
When a user creates an account in your application, their email address, phone number, and date of birth go into your PostgreSQL database. The database connection is encrypted (TLS). The database itself might have disk encryption. But the data inside the tables is stored as plaintext. If an attacker gets read access to your database — a leaked backup, a misconfigured permission, a compromised admin account — they get everyone’s data.
The standard defence is application-level encryption: encrypt sensitive fields before you write them to the database, decrypt after you read them. The data in the database is ciphertext. A backup file is useless without the key.
The problem with application-level encryption is key management. You need an encryption key. Where does it live? How do you rotate it? If you encrypt it with RSA, you’re back to the same harvest-now-decrypt-later problem from article 1. If you store it in a file, you’ve traded a plaintext data problem for a plaintext key problem.
The elegant solution: encryption as a service. Send the plaintext to an API, get back ciphertext. Send ciphertext back to the API, get plaintext. The key never leaves the service. You never manage it. And if the service uses ML-KEM under the hood, the encryption survives quantum computers.
That’s what QuantumAPI EaaS does.
The Solution
Hybrid encryption combines two algorithms:
- ML-KEM-768 (key encapsulation mechanism) — generates a shared secret. This is the post-quantum part.
- AES-256-GCM (symmetric encryption) — encrypts the actual data using that shared secret. This is the fast part.
Why both? ML-KEM works on fixed-size inputs. It’s not designed to encrypt arbitrary data directly. AES-256-GCM is fast and designed for bulk data, but its key needs to come from somewhere secure. Combining them gives you the best of both: post-quantum security for the key exchange, and performance for the data.
Under the hood, when you call POST /api/v1/encrypt:
- quantumAPI generates a fresh shared secret using ML-KEM-768 and the tenant’s public key
- It derives a 32-byte AES key from that shared secret using HKDF-SHA256
- It encrypts your data with AES-256-GCM using that key
- It returns a single opaque blob: the ML-KEM ciphertext + the AES nonce + the AES ciphertext + the authentication tag
You store the blob. You send it back to decrypt. The key material is computed fresh each time from the ML-KEM private key — it’s never stored alongside the data.
The result: your database has ciphertext that requires the ML-KEM private key to decrypt. That key lives in QuantumVault, generated with QRNG, and never leaves quantumAPI.
Execute
We’ll add field-level encryption to the users API from the ATLAS+GOTCHA series. Email and phone number will be encrypted before storage and decrypted on retrieval.
ATLAS for this task
[A] ARCHITECT
Add field-level encryption to the User entity.
Encrypted fields: Email, PhoneNumber.
SearchableEmail: SHA3-256 hash of lowercase email (for lookups).
Out of scope: full-text search on encrypted fields (a separate problem).
[T] TRACE
Create user → encrypt Email + PhoneNumber via EaaS → store ciphertext
Get user → retrieve ciphertext → decrypt via EaaS → return plaintext to caller
Login (lookup by email) → hash incoming email → compare against SearchableEmail
[L] LINK
App → QuantumAPI EaaS: HTTPS, X-Api-Key
EaaS → QuantumVault (internal): key resolution
App → PostgreSQL: Npgsql, connection string from QuantumVault
[A] ASSEMBLE
1. Add IEncryptionService abstraction
2. Implement QuantumApiEncryptionService using the SDK
3. Update User entity: Email → EmailCiphertext, add SearchableEmail
4. Update IUserRepository create/read methods
5. Migration: encrypt existing rows
[S] STRESS-TEST
What if EaaS is unreachable during a write?
→ Don't write the row. Fail fast, let the caller retry.
What if EaaS is unreachable during a read?
→ Return error. Don't return ciphertext to the caller.
What about performance?
→ Batch encrypt: encrypt multiple fields in parallel, not sequentially.
GOTCHA prompt
=== GOALS ===
Add field-level encryption to the User entity in the users-api (.NET 10).
Encrypt Email and PhoneNumber using QuantumAPI EaaS before writing to PostgreSQL.
Store a SHA3-256 hash of the lowercase email for login lookups (SearchableEmail).
Decrypt on read before returning to callers.
=== ORCHESTRATION ===
1. IEncryptionService interface: EncryptAsync(string plaintext) → string ciphertext
DecryptAsync(string ciphertext) → string plaintext
2. QuantumApiEncryptionService: implements IEncryptionService using QuantumApiClient
3. Update User entity: EmailCiphertext (string), PhoneNumberCiphertext (string?),
SearchableEmail (string, indexed), remove plain Email + PhoneNumber columns
4. Update UserRepository.CreateAsync: encrypt before insert
5. Update UserRepository.GetByIdAsync: decrypt after select
6. Update UserRepository.GetByEmailAsync: hash input, query SearchableEmail
=== TOOLS ===
- QuantumAPI.Client NuGet package
- QuantumApiClient.Encryption.EncryptAsync / DecryptAsync
- System.Security.Cryptography.SHA3_256 (.NET 8+)
- Entity Framework Core + Npgsql (existing)
=== CONTEXT ===
- Existing User entity has Email (string), PhoneNumber (string?), PasswordHash (string)
- PasswordHash is already safe — Argon2id, no need to encrypt
- Repository pattern: IUserRepository / UserRepository
- EF Core migrations for the column rename
=== HEURISTICS ===
DO:
- Encrypt in the repository layer, not in the service or controller
- Run EncryptAsync calls in parallel (Task.WhenAll) when encrypting multiple fields
- Use lowercase + trim before hashing email for SearchableEmail
- Inject IEncryptionService — don't instantiate QuantumApiClient directly in the repository
DON'T:
- Don't cache decrypted values in memory longer than the request lifetime
- Don't log decrypted field values
- Don't put the QuantumAPI key in appsettings.json (environment variable only)
=== ARGS ===
Algorithm: ML-KEM-768 (default, no need to specify — use the tenant default key)
SearchableEmail column: indexed, unique
Environment variable: QUANTUMAPI__APIKEY
The implementation
// IEncryptionService.cs
public interface IEncryptionService
{
Task<string> EncryptAsync(string plaintext);
Task<string> DecryptAsync(string ciphertext);
}
// QuantumApiEncryptionService.cs
public class QuantumApiEncryptionService : IEncryptionService
{
private readonly QuantumApiClient _client;
public QuantumApiEncryptionService(QuantumApiClient client)
{
_client = client;
}
public async Task<string> EncryptAsync(string plaintext)
{
var result = await _client.Encryption.EncryptAsync(new EncryptRequest
{
Plaintext = plaintext,
Encoding = "utf8"
});
return result.EncryptedPayload;
}
public async Task<string> DecryptAsync(string ciphertext)
{
var result = await _client.Encryption.DecryptAsync(new DecryptRequest
{
EncryptedPayload = ciphertext
});
return result.Plaintext;
}
}
// User.cs (updated entity)
public class User
{
public Guid Id { get; set; }
public string EmailCiphertext { get; set; } = string.Empty;
public string SearchableEmail { get; set; } = string.Empty; // SHA3-256 hash
public string? PhoneNumberCiphertext { get; set; }
public string PasswordHash { get; set; } = string.Empty;
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
public DateTimeOffset CreatedAt { get; set; }
public bool IsDeleted { get; set; }
}
// UserRepository.cs (relevant methods)
public class UserRepository : IUserRepository
{
private readonly UsersDbContext _db;
private readonly IEncryptionService _encryption;
public UserRepository(UsersDbContext db, IEncryptionService encryption)
{
_db = db;
_encryption = encryption;
}
public async Task<User> CreateAsync(CreateUserDto dto)
{
// Encrypt in parallel — don't await sequentially
var (emailCiphertext, phoneCiphertext) = await EncryptUserFieldsAsync(
dto.Email, dto.PhoneNumber);
var user = new User
{
Id = Guid.NewGuid(),
EmailCiphertext = emailCiphertext,
SearchableEmail = HashEmail(dto.Email),
PhoneNumberCiphertext = phoneCiphertext,
PasswordHash = Argon2id.Hash(dto.Password),
FirstName = dto.FirstName,
LastName = dto.LastName,
CreatedAt = DateTimeOffset.UtcNow
};
_db.Users.Add(user);
await _db.SaveChangesAsync();
return user;
}
public async Task<UserDto?> GetByIdAsync(Guid id)
{
var user = await _db.Users.FirstOrDefaultAsync(u => u.Id == id);
if (user is null) return null;
return await DecryptUserAsync(user);
}
public async Task<UserDto?> GetByEmailAsync(string email)
{
var searchable = HashEmail(email);
var user = await _db.Users.FirstOrDefaultAsync(u => u.SearchableEmail == searchable);
if (user is null) return null;
return await DecryptUserAsync(user);
}
private async Task<(string email, string? phone)> EncryptUserFieldsAsync(
string email, string? phone)
{
var emailTask = _encryption.EncryptAsync(email.ToLowerInvariant().Trim());
var phoneTask = phone is not null
? _encryption.EncryptAsync(phone)
: Task.FromResult<string?>(null);
await Task.WhenAll(emailTask, phoneTask);
return (await emailTask, await phoneTask);
}
private async Task<UserDto> DecryptUserAsync(User user)
{
var emailTask = _encryption.DecryptAsync(user.EmailCiphertext);
var phoneTask = user.PhoneNumberCiphertext is not null
? _encryption.DecryptAsync(user.PhoneNumberCiphertext)
: Task.FromResult<string?>(null);
await Task.WhenAll(emailTask, phoneTask);
return new UserDto(
user.Id,
await emailTask,
await phoneTask,
user.FirstName,
user.LastName,
user.CreatedAt
);
}
private static string HashEmail(string email)
{
var bytes = SHA3_256.HashData(
Encoding.UTF8.GetBytes(email.ToLowerInvariant().Trim()));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
}
Register in Program.cs:
builder.Services.AddQuantumApiClient(options =>
{
options.ApiKey = builder.Configuration["QuantumApi:ApiKey"]!;
});
builder.Services.AddScoped<IEncryptionService, QuantumApiEncryptionService>();
builder.Services.AddScoped<IUserRepository, UserRepository>();
EF Core migration
dotnet ef migrations add EncryptUserFields
dotnet ef database update
The migration renames Email → EmailCiphertext, adds SearchableEmail (with unique index), and renames PhoneNumber → PhoneNumberCiphertext.
For existing rows, you’ll need a one-time data migration script to encrypt the plaintext values. That’s specific to your situation, so it’s not included here — but it follows the same pattern: read rows, encrypt fields, update.
What we adjusted
The generated code was close. Two adjustments:
1. Parallel encryption. The AI encrypted email and phone sequentially (await encrypt(email); await encrypt(phone);). We changed to Task.WhenAll — two HTTP calls in parallel instead of one after the other. On p95 latency, sequential adds ~100ms. Parallel keeps it under 50ms.
2. SHA3-256 for SearchableEmail. The AI used SHA-256. We changed to SHA3_256.HashData — SHA3 is quantum-resistant (Grover’s algorithm on SHA2 halves the effective security, SHA3 was designed to resist this). For email hashing the difference is minor, but it’s a habit worth building.
Template
=== FIELD-LEVEL ENCRYPTION CHECKLIST ===
IDENTIFY FIELDS
[ ] PII: names, email, phone, date of birth, address
[ ] Financial: card numbers (use tokenisation, not encryption), account numbers
[ ] Health: diagnoses, medications, measurements
[ ] Legal: identity document numbers
FOR EACH ENCRYPTED FIELD, DECIDE
[ ] Searchable? → need a deterministic hash (SHA3-256) alongside the ciphertext
[ ] Sortable? → encrypted fields can't be sorted — surface a sortable non-sensitive proxy
[ ] Full-text searchable? → encrypted fields can't be full-text indexed → consider
storing only on the application side, not in the DB
KEY MANAGEMENT
[ ] Using QuantumAPI EaaS: key never leaves the service ✓
[ ] Using local keys: key must be stored in QuantumVault or Azure Key Vault (not config)
MIGRATION
[ ] Encrypt existing rows before deploying new code
[ ] Keep old plaintext column during transition, then drop it
[ ] Test: verify you can decrypt data written before the migration
PERFORMANCE
[ ] Encrypt fields in parallel (Task.WhenAll)
[ ] For read-heavy paths, consider request-scoped caching of decrypted values
[ ] Measure p99 latency before and after — EaaS adds ~30-80ms per call
Challenge
Before article 4, think about authentication. The users API uses JWT tokens signed with a symmetric secret. That secret is now in QuantumVault. Better than before.
But the token itself is still the weak point: if an attacker steals a JWT, they can use it until it expires. There’s no revocation. And the signing algorithm (HS256 or RS256) will be breakable by quantum computers.
Article 4 replaces JWT auth with QuantumID — an OIDC provider that signs tokens with ML-DSA. Tokens that can be revoked, with signatures that survive quantum computers.
If this series helps you, consider buying me a coffee.
Loading comments...