feat(storage): encryption at rest for stored files (per-team envelope encryption) (#7155)

# Description of Changes

PR1 of the encrypt-at-rest initiative: user files stored by Stirling (My
Files, workflow files) are now AES-256 encrypted at rest across all
three storage backends, with keys that never leave the deployment.

**What was changed**

- New `EncryptingStorageProvider` decorator wraps whichever
`StorageProvider` backend is configured (local / database / S3). It
encrypts on `store` (Tink AES-256-GCM streaming AEAD, 1 MiB segments)
and transparently decrypts on `load`; legacy plaintext blobs are
detected by magic sniff and pass through untouched, so mixed state is
safe and no migration is required to enable.
- Envelope-encryption key hierarchy: each blob gets a random per-file
DEK, wrapped by a per-team KEK stored (master-key-wrapped) in a new
`file_encryption_keys` registry table; the master key resolves like the
existing credential key — `stirling.security.fileEncryptionKey`
property, `STIRLING_FILE_ENCRYPTION_KEY` env var, or an auto-generated
owner-only `file-encryption.key` in the config dir (cluster mode
requires an explicit shared key, fail-fast).
- Self-describing blob format (`SPDFEAR1` header) carrying the key id,
plaintext length, and the wrapped DEK; the header prefix is bound as GCM
associated data to both the DEK wrap and the payload, so headers cannot
be transplanted between blobs.
- Enabled via `storage.encryption.enabled=true`, gated on a
Pro/Enterprise licence — **write side only**: decryption activates
whenever key rows exist, so switching the flag off or a lapsed licence
can never make previously encrypted files unreadable.
- Key status lifecycle (`ACTIVE`/`RETIRED`/`DISABLED`): `DISABLED` is a
reversible per-team kill switch that fails closed on read; no API path
deletes key material. A revoked download surfaces as **403 Forbidden**
("access revoked"), not a 500, since it is a deliberate policy state
rather than a fault.
- Startup self-check: a master key that cannot unwrap existing key rows
refuses to boot rather than silently starting a second key hierarchy.
- S3 presigned download URLs are suppressed for decorated storage (they
would serve ciphertext); the controller already falls back to
app-streamed downloads.
- `StoredFile`/`StoredObject` gain a nullable `encryption_key_id`
(ddl-auto, no migration); persisted sizes remain plaintext sizes so
quotas and UI are unchanged.

**Why**

Enterprise security questionnaires (and HIPAA/GDPR/CMMC buyers) require
encryption at rest with documented key management; files were previously
plaintext in every backend. Design doc and vendor/standards research
(Purview, Box KeySafe, Google CSE, ISO 32000-2) informed the approach.

## Manually tested end-to-end

Beyond the automated suite, the full flow was exercised against a
running backend (local provider, `storage.encryption.enabled=true`,
login enabled) via the storage API:

1. **Startup** — master key auto-generated with the "back this up"
warning; logs `master key initialised (AES-256-GCM, fingerprint …)` and
`Storage encryption at rest active (writes encrypted)`.
2. **Encrypted at rest** — uploaded a PDF containing a known marker
string; the blob on disk (371 B vs 219 B plaintext) began with the
`SPDFEAR1` header + key id + ciphertext, contained **no `%PDF` signature
and no marker** — not openable as a PDF straight off disk.
3. **Transparent access** — downloading the file through the API
returned it **byte-identical** to the original, marker intact; stored
`sizeBytes` stayed the plaintext size.
4. **Kill switch + reversibility** — set the team key's status directly
in the DB and restarted:
- `DISABLED` → download **failed closed** (`403`, "access to this
content is revoked"), zero plaintext served.
- `ACTIVE` again → file **fully recovered, byte-identical**. Disabling
is a reversible switch on a preserved key row, not destruction.

(The 403 mapping in step 4 was added in this PR after the manual run
first surfaced it as a generic 500.)

## Coming in later PRs

- **PR2 — ops & lifecycle:** audit events for
encrypt/decrypt/key-lifecycle; admin endpoints for the kill switch
(disable/enable) and key status; a background "encrypt existing files"
migration job for turning the feature on over pre-existing plaintext;
master-key rotation (re-wrap KEK rows). Also plans a
key-backup/fingerprint verification command.
- **PR3 — admin UI:** settings section (status, per-team key list with
disable/enable), encrypted-file badge in My Files, i18n.
- **Later:** per-**source** encryption for the Processor pipeline (the
`SOURCE` key scope is already reserved in the schema); pluggable
external KMS / BYOK master-key backends (Vault, AWS/Azure/GCP KMS);
optional FIPS-validated crypto module build for CMMC; and encrypted
egress (PDF-native AES-256) for files leaving the platform.

**Reviewer notes**

- New dependency: `com.google.crypto.tink:tink:1.23.0` (Apache-2.0, pure
Java — bundled in the boot jar, no Docker changes). Pulls protobuf-java
4.33.6, which clears the Aikido-flagged CVE-2024-7254. `./gradlew
checkLicense --no-parallel` passes.
- The `file-encryption.key` file is generated in the config dir on first
use and must be backed up; losing it makes encrypted files unrecoverable
(loud log warning + fingerprint exposed for backup verification).
- Tests cover round-trips on re-openable and one-shot (S3-style)
backends, multi-segment files, legacy passthrough, decrypt-only mode,
disabled-key fail-closed (now asserting the 403 mapping), header/payload
tamper rejection, key-creation races, and presigned-URL suppression.

---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [x] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [x] I have run `task check` to verify linters, typechecks, and tests
pass
- [x] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
This commit is contained in:
ConnorYoh
2026-07-31 14:57:19 +00:00
committed by GitHub
parent d5e3c3e6e1
commit 8990f55e50
25 changed files with 2338 additions and 38 deletions
@@ -1076,6 +1076,20 @@ public class ApplicationProperties {
private Quotas quotas = new Quotas();
private Sharing sharing = new Sharing();
private Signing signing = new Signing();
private Encryption encryption = new Encryption();
/**
* Encryption at rest for stored files (Pro/Enterprise). Enabling encrypts new writes;
* disabling later only stops encrypting new writes — existing encrypted files keep
* decrypting as long as the key material is present. The master key is resolved like the
* credential key: {@code stirling.security.fileEncryptionKey} property, {@code
* STIRLING_FILE_ENCRYPTION_KEY} env var, or an auto-generated {@code file-encryption.key}
* in the config directory.
*/
@Data
public static class Encryption {
private boolean enabled = false;
}
@Data
public static class Local {
@@ -292,6 +292,26 @@ storage:
linkExpirationDays: 3 # Number of days before share links expire
signing:
enabled: false # set to 'true' to enable group signing workflow (requires storage.enabled) [ALPHA]
# ====================================================================================
# ENCRYPTION AT REST - PRO / ENTERPRISE LICENSE REQUIRED TO ENABLE
# ====================================================================================
# Encrypts stored files (AES-256 envelope encryption, per-team keys). The master key is
# resolved in this order:
# 1. stirling.security.fileEncryptionKey property
# 2. STIRLING_FILE_ENCRYPTION_KEY environment variable
# 3. an auto-generated configs/file-encryption.key (single-node only; cluster mode
# requires an explicitly shared key on every node)
# Generate a key with: openssl rand -base64 32
#
# *** BACK UP THE MASTER KEY. Losing it makes every encrypted stored file ***
# *** permanently unrecoverable. Verify backups against the key fingerprint logged ***
# *** at startup. ***
#
# Enabling encrypts new writes only (existing files stay readable as plaintext).
# Disabling later only stops encrypting new writes - existing encrypted files remain
# readable as long as the key material is present.
encryption:
enabled: false # set to 'true' to encrypt stored files at rest
userListScope: org # Signing user-picker scope: 'org' (default) = whole instance, else caller's team only.
autoPipeline:
outputFolder: "" # Output folder for processed pipeline files (leave empty for default)
+3
View File
@@ -67,6 +67,9 @@ dependencies {
implementation "software.amazon.awssdk:s3:${awsSdkVersion}"
implementation "software.amazon.awssdk:url-connection-client:${awsSdkVersion}"
// Streaming AEAD (AES-GCM-HKDF segments) for storage encryption at rest. Apache-2.0.
implementation "com.google.crypto.tink:tink:${tinkVersion}"
// @DataJpaTest slice (Boot 4 ships test slices as separate starters, like webmvc-test at the
// root) so policy.source repositories can be exercised against embedded H2.
testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'
@@ -6,20 +6,31 @@ import java.nio.file.Path;
import java.util.Locale;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionOperations;
import org.springframework.transaction.support.TransactionTemplate;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.TempFileManager;
import stirling.software.proprietary.cluster.s3.S3Clients;
import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker;
import stirling.software.proprietary.storage.crypto.EncryptingStorageProvider;
import stirling.software.proprietary.storage.crypto.FileEncryptionKeyService;
import stirling.software.proprietary.storage.crypto.FileEncryptionMasterKey;
import stirling.software.proprietary.storage.crypto.StorageEncryptionState;
import stirling.software.proprietary.storage.provider.DatabaseStorageProvider;
import stirling.software.proprietary.storage.provider.LocalStorageProvider;
import stirling.software.proprietary.storage.provider.S3StorageProvider;
import stirling.software.proprietary.storage.provider.StorageProvider;
import stirling.software.proprietary.storage.repository.FileEncryptionKeyRepository;
import stirling.software.proprietary.storage.repository.StoredFileBlobRepository;
@Configuration
@@ -29,10 +40,64 @@ public class StorageProviderConfig {
private final ApplicationProperties applicationProperties;
private final StoredFileBlobRepository storedFileBlobRepository;
private final FileEncryptionKeyRepository fileEncryptionKeyRepository;
private final LicenseKeyChecker licenseKeyChecker;
/**
* The encryption state behind the always-installed decorator. Key machinery is created eagerly
* when the write flag is on (licence-gated) or key rows already exist — so a wrong master key
* fails startup, not the first download — and lazily if encrypted content shows up later
* (config drift on one cluster node must fail loudly, never stream ciphertext). Turning the
* flag off or losing the licence only stops encrypting new writes; decryption stays available.
*/
@Bean
public StorageEncryptionState storageEncryptionState(
@Value("${stirling.security.fileEncryptionKey:}") String configuredFileEncryptionKey,
@Value("${cluster.enabled:false}") boolean clusterEnabled,
PlatformTransactionManager transactionManager) {
boolean writeEnabled = applicationProperties.getStorage().getEncryption().isEnabled();
if (writeEnabled) {
licenseKeyChecker.requireProOrEnterprise("storage.encryption");
}
// Key creation must commit independently of any caller transaction (see
// FileEncryptionKeyService#createActive).
TransactionTemplate requiresNew = new TransactionTemplate(transactionManager);
requiresNew.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
StorageEncryptionState state =
new StorageEncryptionState(
writeEnabled,
() ->
createKeyService(
configuredFileEncryptionKey, clusterEnabled, requiresNew),
fileEncryptionKeyRepository);
if (writeEnabled || fileEncryptionKeyRepository.count() > 0) {
state.initialiseEagerly();
log.info(
"Storage encryption at rest active (writes {})",
writeEnabled ? "encrypted" : "plaintext; decrypt-only mode");
}
return state;
}
private FileEncryptionKeyService createKeyService(
String configuredKey, boolean clusterEnabled, TransactionOperations keyCreationTx) {
FileEncryptionMasterKey masterKey =
new FileEncryptionMasterKey(configuredKey, clusterEnabled);
FileEncryptionKeyService keyService =
new FileEncryptionKeyService(fileEncryptionKeyRepository, masterKey, keyCreationTx);
// Wrong key must fail fast, not silently start a second key hierarchy.
keyService.verifyMasterKey();
return keyService;
}
@Bean(destroyMethod = "close")
public StorageProvider storageProvider() {
public StorageProvider storageProvider(
StorageEncryptionState encryptionState, Optional<TempFileManager> tempFileManager) {
return new EncryptingStorageProvider(
innerStorageProvider(), encryptionState, tempFileManager.orElse(null));
}
private StorageProvider innerStorageProvider() {
boolean storageEnabled = applicationProperties.getStorage().isEnabled();
String providerName =
Optional.ofNullable(applicationProperties.getStorage().getProvider())
@@ -0,0 +1,105 @@
package stirling.software.proprietary.storage.crypto;
import java.nio.ByteBuffer;
import java.util.Arrays;
import java.util.UUID;
/**
* On-disk format for storage-encrypted blobs. The object is self-describing so every {@code
* StorageProvider} backend stores identical bytes and legacy plaintext blobs are recognised by the
* absence of the magic:
*
* <pre>
* offset size field
* 0 8 magic "SPDFEAR1"
* 8 1 format version (1)
* 9 1 cipher suite (1 = AES-256-GCM-HKDF streaming, 1 MiB segments)
* 10 16 key id (file_encryption_keys row that wraps the DEK)
* 26 8 plaintext length
* 34 2 wrapped-DEK length (60 for suite 1)
* 36 60 wrapped DEK = IV(12) || AES-256-GCM(scope KEK, DEK) || tag(16)
* 96 .. payload: streaming-AEAD ciphertext
* </pre>
*
* <p>Bytes 033 are the associated data for both the DEK wrap and the payload AEAD, so neither a
* header transplanted onto another payload nor an altered key id / plaintext length authenticates.
*/
public final class EncryptedFileFormat {
public static final byte[] MAGIC = {'S', 'P', 'D', 'F', 'E', 'A', 'R', '1'};
public static final byte FORMAT_VERSION = 1;
public static final byte SUITE_AES_GCM_HKDF_1MIB = 1;
public static final int SEGMENT_SIZE_BYTES = 1 << 20;
public static final int DEK_LENGTH_BYTES = 32;
public static final int WRAPPED_DEK_LENGTH = 12 + DEK_LENGTH_BYTES + 16;
public static final int HEADER_LENGTH = 8 + 1 + 1 + 16 + 8 + 2 + WRAPPED_DEK_LENGTH;
private static final int AAD_LENGTH = 34;
private EncryptedFileFormat() {}
public record Header(
byte formatVersion,
byte cipherSuite,
UUID keyId,
long plaintextLength,
byte[] wrappedDek) {
public byte[] serialize() {
ByteBuffer buffer = ByteBuffer.allocate(HEADER_LENGTH);
buffer.put(MAGIC);
buffer.put(formatVersion);
buffer.put(cipherSuite);
buffer.putLong(keyId.getMostSignificantBits());
buffer.putLong(keyId.getLeastSignificantBits());
buffer.putLong(plaintextLength);
buffer.putShort((short) wrappedDek.length);
buffer.put(wrappedDek);
return buffer.array();
}
/** The header prefix (everything before the wrapped DEK) used as AEAD associated data. */
public byte[] associatedData() {
return Arrays.copyOfRange(serialize(), 0, AAD_LENGTH);
}
}
/**
* Parses a header from the first {@link #HEADER_LENGTH} bytes of a blob. Returns {@code null}
* when the bytes are not a storage-encrypted object (legacy plaintext passthrough).
*
* @throws StorageEncryptionException when the magic matches but the version/suite is unknown —
* checked so it flows through the callers' IOException error mapping instead of surfacing
* as a bare 500.
*/
public static Header parse(byte[] prefix) throws StorageEncryptionException {
if (prefix == null || prefix.length < HEADER_LENGTH) {
return null;
}
for (int i = 0; i < MAGIC.length; i++) {
if (prefix[i] != MAGIC[i]) {
return null;
}
}
ByteBuffer buffer = ByteBuffer.wrap(prefix, MAGIC.length, HEADER_LENGTH - MAGIC.length);
byte version = buffer.get();
byte suite = buffer.get();
UUID keyId = new UUID(buffer.getLong(), buffer.getLong());
long plaintextLength = buffer.getLong();
int wrappedDekLength = Short.toUnsignedInt(buffer.getShort());
if (version != FORMAT_VERSION
|| suite != SUITE_AES_GCM_HKDF_1MIB
|| plaintextLength < 0
|| wrappedDekLength != WRAPPED_DEK_LENGTH) {
throw new StorageEncryptionException(
"Unsupported storage-encryption header (version="
+ version
+ ", suite="
+ suite
+ "). This build cannot read the file.");
}
byte[] wrappedDek = new byte[wrappedDekLength];
buffer.get(wrappedDek);
return new Header(version, suite, keyId, plaintextLength, wrappedDek);
}
}
@@ -0,0 +1,470 @@
package stirling.software.proprietary.storage.crypto;
import java.io.BufferedOutputStream;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.SequenceInputStream;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import java.time.Duration;
import java.util.Arrays;
import java.util.Optional;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.core.io.AbstractResource;
import org.springframework.core.io.Resource;
import org.springframework.web.multipart.MultipartFile;
import com.google.crypto.tink.subtle.AesGcmHkdfStreaming;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.util.TempFileManager;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.storage.provider.StorageProvider;
import stirling.software.proprietary.storage.provider.StoredObject;
/**
* Envelope-encryption decorator over any {@link StorageProvider}: encrypts on {@code store} (when
* the write flag is on), decrypts on {@code load}, and passes legacy plaintext blobs through
* untouched (detected by the {@link EncryptedFileFormat} magic). Backends store opaque ciphertext
* and need no changes.
*
* <p>The decorator is installed unconditionally (see {@link StorageEncryptionState}), so a node
* whose config lags the cluster decrypts or fails loudly instead of streaming raw ciphertext.
* Decryption keeps working after the feature is switched off; only new writes revert to plaintext.
*
* <p>Presigned download URLs delegate to the backend only while no encrypted content can exist;
* otherwise they are suppressed, because an S3 presigned GET would hand ciphertext straight to the
* browser. Callers already fall back to app-streamed {@link #load} when no URL is offered.
*/
@Slf4j
public class EncryptingStorageProvider implements StorageProvider {
private static final SecureRandom RANDOM = new SecureRandom();
private final StorageProvider delegate;
private final StorageEncryptionState state;
private final TempFileManager tempFileManager;
public EncryptingStorageProvider(StorageProvider delegate, StorageEncryptionState state) {
this(delegate, state, null);
}
public EncryptingStorageProvider(
StorageProvider delegate,
StorageEncryptionState state,
TempFileManager tempFileManager) {
this.delegate = delegate;
this.state = state;
this.tempFileManager = tempFileManager;
}
/** Test convenience mirroring the pre-state constructor shape. */
public EncryptingStorageProvider(
StorageProvider delegate, FileEncryptionKeyService keys, boolean writeEnabled) {
this(delegate, StorageEncryptionState.of(writeEnabled, keys), null);
}
@Override
public StoredObject store(User owner, MultipartFile file) throws IOException {
if (!state.isWriteEnabled()) {
return delegate.store(owner, file);
}
FileEncryptionKeyService.ScopeKek kek = state.keyService().activeKekForOwner(owner);
byte[] dek = new byte[EncryptedFileFormat.DEK_LENGTH_BYTES];
RANDOM.nextBytes(dek);
// Spool ciphertext to a temp file: DatabaseStorageProvider needs getBytes() and
// S3StorageProvider needs an exact Content-Length, so the ciphertext size must be known
// before the delegate reads the upload. TempFileManager-registered so a crash mid-upload
// doesn't orphan the spool forever.
Path spool = createSpoolFile();
try {
EncryptedFileFormat.Header header = buildHeader(kek, dek, file.getSize());
byte[] aad = header.associatedData();
long plaintextBytes;
try (OutputStream out = new BufferedOutputStream(Files.newOutputStream(spool))) {
out.write(header.serialize());
OutputStream encrypting = streamingAead(dek).newEncryptingStream(out, aad);
try (InputStream in = file.getInputStream()) {
plaintextBytes = in.transferTo(encrypting);
}
encrypting.close();
} catch (GeneralSecurityException e) {
throw new StorageEncryptionException("Failed to encrypt upload", e);
}
// The header (and AAD) already carry file.getSize(); a MultipartFile that mis-reports
// would otherwise surface as a Content-Length mismatch, i.e. a truncated or hanging
// download instead of an error.
if (plaintextBytes != file.getSize()) {
throw new StorageEncryptionException(
"Upload reported "
+ file.getSize()
+ " bytes but streamed "
+ plaintextBytes);
}
StoredObject stored = delegate.store(owner, new SpooledUpload(file, spool));
log.debug(
"Encrypted {} under key {} ({} plaintext bytes)",
stored.getStorageKey(),
kek.keyId(),
file.getSize());
return stored.toBuilder()
.sizeBytes(file.getSize())
.encryptionKeyId(kek.keyId().toString())
.build();
} finally {
Arrays.fill(dek, (byte) 0);
Files.deleteIfExists(spool);
}
}
private Path createSpoolFile() throws IOException {
if (tempFileManager != null) {
return tempFileManager.createTempFile(".enc").toPath();
}
return Files.createTempFile("stirling-enc-", ".bin");
}
@Override
public Resource load(String storageKey) throws IOException {
Resource raw = delegate.load(storageKey);
if (raw.isOpen()) {
return wrapOneShot(raw);
}
return wrapReopenable(raw);
}
@Override
public void delete(String storageKey) throws IOException {
delegate.delete(storageKey);
}
@Override
public void close() {
try {
delegate.close();
} catch (Exception e) {
log.warn("Error closing delegate storage provider", e);
}
}
@Override
public Optional<URI> signedDownloadUrl(String storageKey, Duration ttl) throws IOException {
if (state.suppressDirectDownloads()) {
return Optional.empty();
}
return delegate.signedDownloadUrl(storageKey, ttl);
}
@Override
public Optional<URI> signedDownloadUrl(
String storageKey, Duration ttl, boolean inline, String originalFilename)
throws IOException {
if (state.suppressDirectDownloads()) {
return Optional.empty();
}
return delegate.signedDownloadUrl(storageKey, ttl, inline, originalFilename);
}
// ---- store helpers -------------------------------------------------------------------
private EncryptedFileFormat.Header buildHeader(
FileEncryptionKeyService.ScopeKek kek, byte[] dek, long plaintextLength)
throws StorageEncryptionException {
// AAD covers the header prefix, so build a header with a placeholder wrap first to get
// the prefix bytes, then wrap the DEK bound to that prefix.
EncryptedFileFormat.Header prototype =
new EncryptedFileFormat.Header(
EncryptedFileFormat.FORMAT_VERSION,
EncryptedFileFormat.SUITE_AES_GCM_HKDF_1MIB,
kek.keyId(),
plaintextLength,
new byte[EncryptedFileFormat.WRAPPED_DEK_LENGTH]);
byte[] aad = prototype.associatedData();
byte[] wrappedDek = wrapDek(dek, kek.key(), aad);
return new EncryptedFileFormat.Header(
EncryptedFileFormat.FORMAT_VERSION,
EncryptedFileFormat.SUITE_AES_GCM_HKDF_1MIB,
kek.keyId(),
plaintextLength,
wrappedDek);
}
private static byte[] wrapDek(byte[] dek, byte[] kek, byte[] aad)
throws StorageEncryptionException {
try {
byte[] iv = new byte[12];
RANDOM.nextBytes(iv);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(
Cipher.ENCRYPT_MODE,
new SecretKeySpec(kek, "AES"),
new GCMParameterSpec(128, iv));
cipher.updateAAD(aad);
byte[] ct = cipher.doFinal(dek);
byte[] out = new byte[iv.length + ct.length];
System.arraycopy(iv, 0, out, 0, iv.length);
System.arraycopy(ct, 0, out, iv.length, ct.length);
return out;
} catch (GeneralSecurityException e) {
throw new StorageEncryptionException("Failed to wrap file key", e);
}
}
private byte[] unwrapDek(EncryptedFileFormat.Header header) throws IOException {
byte[] kek = state.keyService().kekForDecrypt(header.keyId());
try {
byte[] wrapped = header.wrappedDek();
byte[] iv = Arrays.copyOfRange(wrapped, 0, 12);
byte[] ct = Arrays.copyOfRange(wrapped, 12, wrapped.length);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(
Cipher.DECRYPT_MODE,
new SecretKeySpec(kek, "AES"),
new GCMParameterSpec(128, iv));
cipher.updateAAD(header.associatedData());
return cipher.doFinal(ct);
} catch (GeneralSecurityException e) {
throw new StorageEncryptionException(
"Failed to unwrap file key for key " + header.keyId() + " — tampered header?",
e);
}
}
/**
* Uses Tink's {@code subtle} API directly rather than the keyset/StreamingAead registry:
* keysets would store DEKs in Tink's own serialisation and key-management model, while this
* format keeps the (already-wrapped) DEK in our header. {@code subtle} is outside Tink's
* stability guarantee — the pinned version plus the round-trip tests are what make upgrades
* safe; re-run them on any Tink bump.
*/
private static AesGcmHkdfStreaming streamingAead(byte[] dek) throws GeneralSecurityException {
return new AesGcmHkdfStreaming(
dek,
"HMACSHA256",
EncryptedFileFormat.DEK_LENGTH_BYTES,
EncryptedFileFormat.SEGMENT_SIZE_BYTES,
0);
}
// ---- load helpers --------------------------------------------------------------------
/** Re-openable delegate (local file, DB byte array): sniff via a throwaway stream. */
private Resource wrapReopenable(Resource raw) throws IOException {
byte[] prefix;
try (InputStream in = raw.getInputStream()) {
prefix = in.readNBytes(EncryptedFileFormat.HEADER_LENGTH);
}
EncryptedFileFormat.Header header = EncryptedFileFormat.parse(prefix);
if (header == null) {
return raw;
}
byte[] dek = unwrapDek(header);
return new ReopenableDecryptedResource(raw, header, dek);
}
/**
* One-shot delegate (S3 stream): the sniffed prefix must be replayed or decrypted inline. The
* stream is a live HTTP connection, so every failure path (unknown header version, revoked key,
* tampered wrap) must close it — leaking here would starve the S3 connection pool precisely
* when the kill switch is being exercised.
*/
private Resource wrapOneShot(Resource raw) throws IOException {
InputStream in = raw.getInputStream();
try {
byte[] prefix = in.readNBytes(EncryptedFileFormat.HEADER_LENGTH);
EncryptedFileFormat.Header header = EncryptedFileFormat.parse(prefix);
if (header == null) {
long length;
try {
length = raw.contentLength();
} catch (IOException | RuntimeException e) {
// Stock InputStreamResource refuses contentLength() once the stream is
// partially read; S3's resource reports it from the response header instead.
length = -1;
}
return new OneShotResource(
new SequenceInputStream(new ByteArrayInputStream(prefix), in),
length,
raw.getDescription());
}
byte[] dek = unwrapDek(header);
InputStream decrypting;
try {
decrypting = streamingAead(dek).newDecryptingStream(in, header.associatedData());
} catch (GeneralSecurityException e) {
throw new StorageEncryptionException("Failed to open decrypting stream", e);
}
return new OneShotResource(decrypting, header.plaintextLength(), raw.getDescription());
} catch (IOException | RuntimeException e) {
try {
in.close();
} catch (IOException closeFailure) {
e.addSuppressed(closeFailure);
}
throw e;
}
}
/**
* Fresh decrypting stream per read; supports repeated reads and range-skip consumers. Note a
* range request still decrypts from byte 0 and discards up to the offset — inherent to
* streaming AEAD without a seekable-channel implementation.
*/
private static final class ReopenableDecryptedResource extends AbstractResource {
private final Resource ciphertext;
private final EncryptedFileFormat.Header header;
private final byte[] dek;
private ReopenableDecryptedResource(
Resource ciphertext, EncryptedFileFormat.Header header, byte[] dek) {
this.ciphertext = ciphertext;
this.header = header;
this.dek = dek;
}
@Override
public InputStream getInputStream() throws IOException {
InputStream in = ciphertext.getInputStream();
try {
in.skipNBytes(EncryptedFileFormat.HEADER_LENGTH);
return streamingAead(dek).newDecryptingStream(in, header.associatedData());
} catch (GeneralSecurityException | IOException e) {
in.close();
throw e instanceof IOException io
? io
: new StorageEncryptionException("Failed to open decrypting stream", e);
}
}
@Override
public long contentLength() {
return header.plaintextLength();
}
@Override
public boolean exists() {
return ciphertext.exists();
}
@Override
public String getFilename() {
return ciphertext.getFilename();
}
@Override
public String getDescription() {
return "decrypted " + ciphertext.getDescription();
}
}
/** Single-use resource over an already-open stream (mirrors InputStreamResource semantics). */
private static final class OneShotResource extends AbstractResource {
private final InputStream stream;
private final long contentLength;
private final String description;
private boolean consumed;
private OneShotResource(InputStream stream, long contentLength, String description) {
this.stream = stream;
this.contentLength = contentLength;
this.description = description;
}
@Override
public synchronized InputStream getInputStream() {
if (consumed) {
throw new IllegalStateException(
"InputStream has already been read - do not use OneShotResource twice");
}
consumed = true;
return stream;
}
@Override
public long contentLength() {
return contentLength;
}
@Override
public boolean isOpen() {
return true;
}
@Override
public boolean exists() {
return true;
}
@Override
public String getDescription() {
return "decrypted " + description;
}
}
/** Presents the spooled ciphertext file as the upload the delegate should persist. */
private static final class SpooledUpload implements MultipartFile {
private final MultipartFile original;
private final Path spool;
private SpooledUpload(MultipartFile original, Path spool) {
this.original = original;
this.spool = spool;
}
@Override
public String getName() {
return original.getName();
}
@Override
public String getOriginalFilename() {
return original.getOriginalFilename();
}
@Override
public String getContentType() {
return original.getContentType();
}
@Override
public boolean isEmpty() {
return getSize() == 0;
}
@Override
public long getSize() {
try {
return Files.size(spool);
} catch (IOException e) {
throw new IllegalStateException("Spooled ciphertext unavailable", e);
}
}
@Override
public byte[] getBytes() throws IOException {
return Files.readAllBytes(spool);
}
@Override
public InputStream getInputStream() throws IOException {
return Files.newInputStream(spool);
}
@Override
public void transferTo(File dest) throws IOException {
Files.copy(spool, dest.toPath());
}
}
}
@@ -0,0 +1,214 @@
package stirling.software.proprietary.storage.crypto;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import java.time.Duration;
import java.util.Base64;
import java.util.UUID;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.transaction.support.TransactionOperations;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.storage.model.FileEncryptionKey;
import stirling.software.proprietary.storage.repository.FileEncryptionKeyRepository;
/**
* Resolves and unwraps scope-level KEKs for storage encryption. Per-team scoping by default (files
* keep decrypting after team changes because each blob header pins its exact key id); GLOBAL is the
* fallback for owners without a team; SOURCE is reserved for pipeline encryption (P2).
*
* <p>Unwrapped KEKs are cached briefly so the kill switch (DISABLED status) propagates across
* cluster nodes within {@link #CACHE_TTL} without a per-read DB round-trip. Note the TTL bounds the
* write side too: a key disabled on another node can wrap new blobs for up to the TTL there.
*/
@Slf4j
public class FileEncryptionKeyService {
private static final Duration CACHE_TTL = Duration.ofSeconds(60);
private static final SecureRandom RANDOM = new SecureRandom();
private final FileEncryptionKeyRepository repository;
private final FileEncryptionMasterKey masterKey;
/**
* Runs key-row creation in its own committed transaction (REQUIRES_NEW in production). Callers
* like WorkflowSessionService are {@code @Transactional}, and with an assigned-UUID id the
* INSERT would otherwise defer to the outer commit — the duplicate-key exception would surface
* far from {@link #createActive}'s recovery catch, and the outer transaction would already be
* rollback-only.
*/
private final TransactionOperations keyCreationTx;
private final Cache<UUID, byte[]> unwrapCache =
Caffeine.newBuilder().expireAfterWrite(CACHE_TTL).maximumSize(10_000).build();
private final Cache<String, UUID> activeScopeCache =
Caffeine.newBuilder().expireAfterWrite(CACHE_TTL).maximumSize(10_000).build();
public FileEncryptionKeyService(
FileEncryptionKeyRepository repository, FileEncryptionMasterKey masterKey) {
this(repository, masterKey, TransactionOperations.withoutTransaction());
}
public FileEncryptionKeyService(
FileEncryptionKeyRepository repository,
FileEncryptionMasterKey masterKey,
TransactionOperations keyCreationTx) {
this.repository = repository;
this.masterKey = masterKey;
this.keyCreationTx = keyCreationTx;
}
public record ScopeKek(UUID keyId, byte[] key) {}
/** The ACTIVE KEK for the owner's scope, created on first use. */
public ScopeKek activeKekForOwner(User owner) throws StorageEncryptionException {
FileEncryptionKey.ScopeType scopeType = FileEncryptionKey.ScopeType.GLOBAL;
long scopeId = 0;
Team team = owner != null ? owner.getTeam() : null;
if (team != null && team.getId() != null) {
scopeType = FileEncryptionKey.ScopeType.TEAM;
scopeId = team.getId();
}
UUID cachedId = activeScopeCache.getIfPresent(scopeType + ":" + scopeId);
if (cachedId != null) {
byte[] cachedKey = unwrapCache.getIfPresent(cachedId);
if (cachedKey != null) {
return new ScopeKek(cachedId, cachedKey);
}
}
FileEncryptionKey row = findOrCreateActive(scopeType, scopeId);
byte[] kek = unwrapRow(row);
activeScopeCache.put(scopeType + ":" + scopeId, row.getKeyId());
return new ScopeKek(row.getKeyId(), kek);
}
/** Unwraps the KEK for decrypting an existing blob. Fails closed on DISABLED or missing. */
public byte[] kekForDecrypt(UUID keyId) throws StorageEncryptionException {
byte[] cached = unwrapCache.getIfPresent(keyId);
if (cached != null) {
return cached;
}
FileEncryptionKey row =
repository
.findById(keyId)
.orElseThrow(
() ->
new StorageEncryptionException(
"No encryption key "
+ keyId
+ " — the key registry does not match the"
+ " stored data (restored from an older"
+ " database backup?)"));
if (row.getStatus() == FileEncryptionKey.Status.DISABLED) {
throw new StorageKeyRevokedException(
"Encryption key " + keyId + " is disabled; access to this content is revoked");
}
return unwrapRow(row);
}
/**
* Startup self-check: proves the resolved master key can unwrap an existing row, so a wrong key
* fails fast instead of silently writing new files under a second key hierarchy.
*/
public void verifyMasterKey() {
repository
.findFirstByStatus(FileEncryptionKey.Status.ACTIVE)
.or(() -> repository.findFirstByStatus(FileEncryptionKey.Status.RETIRED))
.ifPresent(
row -> {
try {
unwrapRow(row);
} catch (StorageEncryptionException e) {
throw new IllegalStateException(
"The configured file encryption key (fingerprint "
+ masterKey.fingerprint()
+ ") cannot unwrap existing key "
+ row.getKeyId()
+ ". Refusing to start with a mismatched key —"
+ " restore the original"
+ " STIRLING_FILE_ENCRYPTION_KEY /"
+ " file-encryption.key.",
e);
}
});
}
private FileEncryptionKey findOrCreateActive(
FileEncryptionKey.ScopeType scopeType, long scopeId) throws StorageEncryptionException {
return repository
.findFirstByScopeTypeAndScopeIdAndStatus(
scopeType, scopeId, FileEncryptionKey.Status.ACTIVE)
.orElseGet(() -> createActive(scopeType, scopeId));
}
// Package-private so the @DataJpaTest can drive the duplicate-insert recovery
// deterministically.
FileEncryptionKey createActive(FileEncryptionKey.ScopeType scopeType, long scopeId) {
byte[] kek = new byte[EncryptedFileFormat.DEK_LENGTH_BYTES];
RANDOM.nextBytes(kek);
FileEncryptionKey row = new FileEncryptionKey();
row.setKeyId(UUID.randomUUID());
row.setScopeType(scopeType);
row.setScopeId(scopeId);
int version =
repository
.findFirstByScopeTypeAndScopeIdOrderByKeyVersionDesc(
scopeType, scopeId)
.map(FileEncryptionKey::getKeyVersion)
.orElse(0)
+ 1;
row.setKeyVersion(version);
row.setWrappedKey(
Base64.getEncoder().encodeToString(masterKey.wrap(kek, aadFor(row.getKeyId()))));
row.setMasterKeyVersion(FileEncryptionMasterKey.CURRENT_VERSION);
row.setStatus(FileEncryptionKey.Status.ACTIVE);
try {
// saveAndFlush inside a fresh transaction so a unique-constraint violation surfaces
// right here (not at some outer commit) and the caller's transaction stays healthy.
FileEncryptionKey saved = keyCreationTx.execute(status -> repository.saveAndFlush(row));
log.info(
"Created storage encryption key {} for {}:{}",
saved.getKeyId(),
scopeType,
scopeId);
unwrapCache.put(saved.getKeyId(), kek);
return saved;
} catch (DataIntegrityViolationException raced) {
// Another node created the scope key concurrently; use theirs.
return repository
.findFirstByScopeTypeAndScopeIdAndStatus(
scopeType, scopeId, FileEncryptionKey.Status.ACTIVE)
.orElseThrow(() -> raced);
}
}
private byte[] unwrapRow(FileEncryptionKey row) throws StorageEncryptionException {
try {
byte[] kek =
masterKey.unwrap(
Base64.getDecoder().decode(row.getWrappedKey()),
aadFor(row.getKeyId()));
unwrapCache.put(row.getKeyId(), kek);
return kek;
} catch (GeneralSecurityException e) {
throw new StorageEncryptionException(
"Failed to unwrap encryption key "
+ row.getKeyId()
+ " — master key mismatch or corrupted key row",
e);
}
}
// Binds each wrapped KEK to its row identity so ciphertexts can't be swapped between rows.
private static byte[] aadFor(UUID keyId) {
return keyId.toString().getBytes(StandardCharsets.US_ASCII);
}
}
@@ -0,0 +1,172 @@
package stirling.software.proprietary.storage.crypto;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermission;
import java.nio.file.attribute.PosixFilePermissions;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Base64;
import java.util.EnumSet;
import java.util.HexFormat;
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.InstallationPathConfig;
/**
* Master key-encryption key for storage encryption at rest. Deliberately a separate key from {@code
* credential-encryption.key}: file data and integration secrets have different blast radii and
* rotate independently.
*
* <p>Resolution order mirrors {@code CredentialEncryption}: {@code
* stirling.security.fileEncryptionKey} property, {@code STIRLING_FILE_ENCRYPTION_KEY} env var, then
* an auto-generated owner-only {@code file-encryption.key} in the config dir. Cluster mode requires
* an explicitly shared key.
*/
@Slf4j
public class FileEncryptionMasterKey {
private static final String ALGORITHM = "AES";
private static final String TRANSFORMATION = "AES/GCM/NoPadding";
private static final int GCM_TAG_BITS = 128;
private static final int IV_BYTES = 12;
private static final String KEY_FILE = "file-encryption.key";
private static final SecureRandom RANDOM = new SecureRandom();
/** Bumped when master rotation ships (P2); recorded on every wrapped KEK row. */
public static final int CURRENT_VERSION = 1;
private final SecretKey key;
public FileEncryptionMasterKey(String configuredKey, boolean clusterEnabled) {
this.key = resolveKey(configuredKey, clusterEnabled);
log.info(
"Storage encryption master key initialised (AES-256-GCM, fingerprint {})",
fingerprint());
}
private static SecretKey resolveKey(String configuredKey, boolean clusterEnabled) {
String configured = configuredKey;
String source = "stirling.security.fileEncryptionKey";
if (configured == null || configured.isBlank()) {
configured = System.getenv("STIRLING_FILE_ENCRYPTION_KEY");
source = "STIRLING_FILE_ENCRYPTION_KEY";
}
if (configured != null && !configured.isBlank()) {
return decodeKey(configured, source);
}
if (clusterEnabled) {
throw new IllegalStateException(
"cluster.enabled=true requires a shared file encryption key. Set"
+ " STIRLING_FILE_ENCRYPTION_KEY (or"
+ " stirling.security.fileEncryptionKey) to the same value on every"
+ " node.");
}
return loadOrCreateKeyFile();
}
/**
* Decodes and validates key material: must be valid base64 for exactly 32 bytes. Without the
* length check a short key would silently downgrade to AES-128/192 while the startup log claims
* AES-256.
*/
private static SecretKey decodeKey(String base64, String source) {
byte[] bytes;
try {
bytes = Base64.getDecoder().decode(base64.trim());
} catch (IllegalArgumentException e) {
throw new IllegalStateException(source + " is not valid base64", e);
}
if (bytes.length != 32) {
throw new IllegalStateException(
source
+ " must decode to exactly 32 bytes (a 256-bit AES key), got "
+ bytes.length
+ " bytes. Generate one with: openssl rand -base64 32");
}
return new SecretKeySpec(bytes, ALGORITHM);
}
private static SecretKey loadOrCreateKeyFile() {
Path path = Path.of(InstallationPathConfig.getConfigPath(), KEY_FILE);
try {
if (Files.exists(path)) {
return decodeKey(Files.readString(path), path.toString());
}
KeyGenerator generator = KeyGenerator.getInstance(ALGORITHM);
generator.init(256);
SecretKey generated = generator.generateKey();
Files.createDirectories(path.getParent());
writeOwnerOnly(path, Base64.getEncoder().encodeToString(generated.getEncoded()));
log.warn(
"Generated a new file encryption key at {}. Back this file up: losing it makes"
+ " every encrypted stored file unrecoverable.",
path);
return generated;
} catch (Exception e) {
throw new IllegalStateException("Unable to initialise file encryption key", e);
}
}
private static void writeOwnerOnly(Path path, String content) throws IOException {
EnumSet<PosixFilePermission> ownerOnly =
EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE);
try {
Files.createFile(path, PosixFilePermissions.asFileAttribute(ownerOnly));
} catch (UnsupportedOperationException e) {
// Non-POSIX filesystem (Windows): the config-dir ACL is the protection.
Files.createFile(path);
}
Files.writeString(path, content);
try {
Files.setPosixFilePermissions(path, ownerOnly);
} catch (UnsupportedOperationException ignored) {
}
}
public byte[] wrap(byte[] kek, byte[] associatedData) {
try {
byte[] iv = new byte[IV_BYTES];
RANDOM.nextBytes(iv);
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv));
cipher.updateAAD(associatedData);
byte[] ciphertext = cipher.doFinal(kek);
byte[] combined = new byte[iv.length + ciphertext.length];
System.arraycopy(iv, 0, combined, 0, iv.length);
System.arraycopy(ciphertext, 0, combined, iv.length, ciphertext.length);
return combined;
} catch (GeneralSecurityException e) {
throw new IllegalStateException("Failed to wrap scope key", e);
}
}
public byte[] unwrap(byte[] wrapped, byte[] associatedData) throws GeneralSecurityException {
byte[] iv = Arrays.copyOfRange(wrapped, 0, IV_BYTES);
byte[] ciphertext = Arrays.copyOfRange(wrapped, IV_BYTES, wrapped.length);
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv));
cipher.updateAAD(associatedData);
return cipher.doFinal(ciphertext);
}
/** SHA-256 prefix of the key material so admins can verify backups without exposing it. */
public String fingerprint() {
try {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(key.getEncoded());
return HexFormat.of().formatHex(digest, 0, 8);
} catch (GeneralSecurityException e) {
return "unavailable";
}
}
}
@@ -0,0 +1,15 @@
package stirling.software.proprietary.storage.crypto;
import java.io.IOException;
/** Storage encrypt/decrypt failure (missing/disabled key, master-key mismatch, tampered blob). */
public class StorageEncryptionException extends IOException {
public StorageEncryptionException(String message) {
super(message);
}
public StorageEncryptionException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,112 @@
package stirling.software.proprietary.storage.crypto;
import java.time.Duration;
import java.util.function.Supplier;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.storage.repository.FileEncryptionKeyRepository;
/**
* Holds the storage-encryption machinery for the always-installed {@link EncryptingStorageProvider}
* decorator.
*
* <p>The decorator is unconditional so a node whose config lags the cluster (flag off, rolling
* deploy, config drift) can never stream raw ciphertext: it always sniffs the header and decrypts
* or fails loudly. The expensive parts — resolving the master key (which may generate a key file)
* and the key service — are created lazily: eagerly at startup only when the write flag is on or
* key rows already exist (preserving the fail-fast master-key verification), otherwise on first
* encounter with an encrypted blob.
*/
@Slf4j
public class StorageEncryptionState {
private static final Duration KEYS_EXIST_CACHE_TTL = Duration.ofSeconds(60);
private final boolean writeEnabled;
private final Supplier<FileEncryptionKeyService> keyServiceFactory;
private final FileEncryptionKeyRepository keyRepository;
private volatile FileEncryptionKeyService keyService;
private volatile boolean keysExistEverChecked;
private volatile long keysExistCheckedAtNanos;
private volatile boolean keysExistCached;
public StorageEncryptionState(
boolean writeEnabled,
Supplier<FileEncryptionKeyService> keyServiceFactory,
FileEncryptionKeyRepository keyRepository) {
this.writeEnabled = writeEnabled;
this.keyServiceFactory = keyServiceFactory;
this.keyRepository = keyRepository;
}
/** Test convenience: a pre-materialised state around an existing service. */
public static StorageEncryptionState of(
boolean writeEnabled, FileEncryptionKeyService keyService) {
StorageEncryptionState state =
new StorageEncryptionState(writeEnabled, () -> keyService, null);
state.keyService = keyService;
return state;
}
/** True when new writes are encrypted (flag on + licence passed). */
public boolean isWriteEnabled() {
return writeEnabled;
}
/**
* The key service, created on first use. A failure here (no key material, wrong key) is a loud,
* actionable error — never silently-served ciphertext.
*/
public FileEncryptionKeyService keyService() throws StorageEncryptionException {
FileEncryptionKeyService current = keyService;
if (current != null) {
return current;
}
synchronized (this) {
if (keyService == null) {
try {
keyService = keyServiceFactory.get();
} catch (RuntimeException e) {
throw new StorageEncryptionException(
"Encrypted content was encountered but the storage encryption key"
+ " machinery could not be initialised: "
+ e.getMessage(),
e);
}
}
return keyService;
}
}
/** Forces eager initialisation (startup fail-fast path). */
public void initialiseEagerly() {
keyService = keyServiceFactory.get();
}
/**
* Whether presigned/direct download URLs must be suppressed: any time this node encrypts new
* writes or encrypted content may exist, a presigned URL could hand ciphertext to the client.
* The registry check is cached briefly, so installs that never enable encryption keep the S3
* fast path. Per-file precision (redirecting plaintext blobs via {@code
* StoredFile.encryptionKeyId}) is a deliberate follow-up.
*/
public boolean suppressDirectDownloads() {
if (writeEnabled || keyService != null) {
return true;
}
if (keyRepository == null) {
return false;
}
long now = System.nanoTime();
if (keysExistEverChecked
&& now - keysExistCheckedAtNanos < KEYS_EXIST_CACHE_TTL.toNanos()) {
return keysExistCached;
}
keysExistCached = keyRepository.count() > 0;
keysExistCheckedAtNanos = now;
keysExistEverChecked = true;
return keysExistCached;
}
}
@@ -0,0 +1,14 @@
package stirling.software.proprietary.storage.crypto;
/**
* The encryption key protecting the requested content is disabled (the per-scope kill switch).
* Distinct from a generic {@link StorageEncryptionException} so callers can surface this as an
* access-denied (403) rather than an internal error (500): it is a deliberate, reversible policy
* state, not a failure. Re-enabling the key restores access.
*/
public class StorageKeyRevokedException extends StorageEncryptionException {
public StorageKeyRevokedException(String message) {
super(message);
}
}
@@ -0,0 +1,95 @@
package stirling.software.proprietary.storage.model;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.UUID;
import org.hibernate.annotations.CreationTimestamp;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* A scope-level key-encryption key (KEK) for storage encryption at rest. The 256-bit KEK is stored
* AES-GCM-wrapped by the master file-encryption key; per-file data keys are wrapped by the KEK
* inside each encrypted blob's header, so this table stays tiny (one row per scope per rotation)
* and master-key rotation only re-wraps these rows, never file contents.
*
* <p>Status semantics: ACTIVE wraps new files and unwraps existing ones; RETIRED (post-rotation)
* unwraps only; DISABLED unwraps nothing — the per-scope kill switch.
*/
@Entity
@Table(
name = "file_encryption_keys",
indexes = {@Index(name = "idx_file_enc_keys_scope", columnList = "scope_type,scope_id")},
uniqueConstraints =
@UniqueConstraint(
name = "uk_file_enc_keys_scope_version",
columnNames = {"scope_type", "scope_id", "key_version"}))
@NoArgsConstructor
@Getter
@Setter
public class FileEncryptionKey implements Serializable {
private static final long serialVersionUID = 1L;
public enum ScopeType {
GLOBAL,
TEAM,
SOURCE
}
public enum Status {
ACTIVE,
RETIRED,
DISABLED
}
@Id
@Column(name = "key_id", nullable = false)
private UUID keyId;
@Enumerated(EnumType.STRING)
@Column(name = "scope_type", nullable = false, length = 16)
private ScopeType scopeType;
/** Scope discriminator (team id, source id). 0 for GLOBAL so the unique constraint holds. */
@Column(name = "scope_id", nullable = false)
private long scopeId;
/** Increments when the scope KEK is rotated; the unique constraint tolerates retired rows. */
@Column(name = "key_version", nullable = false)
private int keyVersion;
/** Base64 of IV(12) || AES-256-GCM(masterKey, kek) || tag(16), AAD-bound to keyId. */
@Column(name = "wrapped_key", nullable = false, length = 512)
private String wrappedKey;
/** Which master-key version wrapped this row; supports master rotation re-wraps. */
@Column(name = "master_key_version", nullable = false)
private int masterKeyVersion;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 16)
private Status status;
@CreationTimestamp
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@Column(name = "status_changed_at")
private LocalDateTime statusChangedAt;
@Column(name = "status_changed_by")
private String statusChangedBy;
}
@@ -102,6 +102,14 @@ public class StoredFile implements Serializable {
@Enumerated(EnumType.STRING)
private FilePurpose purpose;
/**
* file_encryption_keys id under which this file's blobs (main/history/audit) were encrypted;
* null = stored plaintext. The per-blob truth is each blob's own header — this column exists
* for reporting and the encrypt-existing migration. Nullable so ddl-auto upgrades cleanly.
*/
@Column(name = "encryption_key_id", length = 36)
private String encryptionKeyId;
@OneToMany(
mappedBy = "file",
fetch = FetchType.LAZY,
@@ -4,10 +4,15 @@ import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
@Builder(toBuilder = true)
public class StoredObject {
private final String storageKey;
private final String originalFilename;
private final String contentType;
/** Plaintext size. When encryption at rest is active the stored blob is larger. */
private final long sizeBytes;
/** file_encryption_keys id that wraps this blob's data key; null when stored plaintext. */
private final String encryptionKeyId;
}
@@ -0,0 +1,21 @@
package stirling.software.proprietary.storage.repository;
import java.util.Optional;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import stirling.software.proprietary.storage.model.FileEncryptionKey;
@Repository
public interface FileEncryptionKeyRepository extends JpaRepository<FileEncryptionKey, UUID> {
Optional<FileEncryptionKey> findFirstByScopeTypeAndScopeIdAndStatus(
FileEncryptionKey.ScopeType scopeType, long scopeId, FileEncryptionKey.Status status);
Optional<FileEncryptionKey> findFirstByScopeTypeAndScopeIdOrderByKeyVersionDesc(
FileEncryptionKey.ScopeType scopeType, long scopeId);
Optional<FileEncryptionKey> findFirstByStatus(FileEncryptionKey.Status status);
}
@@ -32,6 +32,7 @@ import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.EmailService;
import stirling.software.proprietary.storage.crypto.StorageKeyRevokedException;
import stirling.software.proprietary.storage.model.FileShare;
import stirling.software.proprietary.storage.model.FileShareAccess;
import stirling.software.proprietary.storage.model.FileShareAccessType;
@@ -143,6 +144,7 @@ public class FileStorageService {
storedFile.setContentType(mainObject.getContentType());
storedFile.setSizeBytes(mainObject.getSizeBytes());
storedFile.setStorageKey(mainObject.getStorageKey());
storedFile.setEncryptionKeyId(mainObject.getEncryptionKeyId());
applyHistoryMetadata(storedFile, historyObject);
applyAuditMetadata(storedFile, auditObject);
try {
@@ -207,6 +209,7 @@ public class FileStorageService {
existing.setContentType(mainObject.getContentType());
existing.setSizeBytes(mainObject.getSizeBytes());
existing.setStorageKey(mainObject.getStorageKey());
existing.setEncryptionKeyId(mainObject.getEncryptionKeyId());
if (historyObject != null) {
applyHistoryMetadata(existing, historyObject);
}
@@ -492,6 +495,17 @@ public class FileStorageService {
ensureStorageEnabled();
try {
return storageProvider.load(file.getStorageKey());
} catch (StorageKeyRevokedException e) {
// Deliberate, reversible policy state (encryption key disabled), not a server fault —
// surface as forbidden so the client sees "revoked", not "internal error".
log.warn(
"Access to stored file {} denied: {}",
file != null ? file.getId() : null,
e.getMessage());
throw new ResponseStatusException(
HttpStatus.FORBIDDEN,
"Access to this file has been revoked (its encryption key is disabled)",
e);
} catch (IOException e) {
log.error(
"Failed to load stored file {} (key: {})",
@@ -263,6 +263,7 @@ public class WorkflowSessionService {
storedFile.setContentType(storedObject.getContentType());
storedFile.setSizeBytes(storedObject.getSizeBytes());
storedFile.setStorageKey(storedObject.getStorageKey());
storedFile.setEncryptionKeyId(storedObject.getEncryptionKeyId());
storedFile.setPurpose(purpose);
return storedFileRepository.save(storedFile);
@@ -9,92 +9,162 @@ import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.Base64;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.springframework.transaction.PlatformTransactionManager;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License;
import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker;
import stirling.software.proprietary.storage.provider.LocalStorageProvider;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.storage.crypto.EncryptingStorageProvider;
import stirling.software.proprietary.storage.crypto.InMemoryKeyRepo;
import stirling.software.proprietary.storage.crypto.StorageEncryptionState;
import stirling.software.proprietary.storage.provider.StorageProvider;
import stirling.software.proprietary.storage.repository.StoredFileBlobRepository;
/**
* Verifies the Pro/Enterprise license gate on the S3 storage backend without touching real S3
* clients (and without needing Docker). Provider-specific construction is delegated to the existing
* provider tests.
* Verifies the Pro/Enterprise license gates and, critically, that the encryption decorator is
* installed in EVERY configuration — a node whose config lags the cluster (flag off, key rows
* already existing elsewhere) must never serve raw ciphertext.
*/
class StorageProviderConfigTest {
@Test
void provider_local_normalLicense_buildsLocalProviderWithoutLicenseCheck() {
StorageProviderConfig cfg = newConfig("local", License.NORMAL);
private static final String MASTER =
Base64.getEncoder().encodeToString("0123456789abcdef0123456789abcdef".getBytes());
StorageProvider provider = cfg.storageProvider();
assertThat(provider).isInstanceOf(LocalStorageProvider.class);
private final InMemoryKeyRepo keyRepo = new InMemoryKeyRepo();
private final PlatformTransactionManager txManager = mock(PlatformTransactionManager.class);
// ---- decorator installation matrix -------------------------------------------------
@Test
void decorator_alwaysInstalled_evenWhenEncryptionOffAndNoKeys() {
StorageProviderConfig cfg = newConfig("local", License.NORMAL, false);
StorageEncryptionState state = cfg.storageEncryptionState(MASTER, false, txManager);
StorageProvider provider = cfg.storageProvider(state, Optional.empty());
assertThat(provider).isInstanceOf(EncryptingStorageProvider.class);
assertThat(state.isWriteEnabled()).isFalse();
// Nothing encrypted can exist -> the S3 fast path stays available.
assertThat(state.suppressDirectDownloads()).isFalse();
}
@Test
void decorator_writeEnabled_requiresLicenceAndSuppressesDirectDownloads() {
StorageProviderConfig cfg = newConfig("local", License.SERVER, true);
StorageEncryptionState state = cfg.storageEncryptionState(MASTER, false, txManager);
StorageProvider provider = cfg.storageProvider(state, Optional.empty());
assertThat(provider).isInstanceOf(EncryptingStorageProvider.class);
assertThat(state.isWriteEnabled()).isTrue();
assertThat(state.suppressDirectDownloads()).isTrue();
}
@Test
void decorator_flagOffButKeysExist_decryptOnlyModeStillMaterialises() throws Exception {
// Simulate the drifted-node case: another node already created keys.
StorageProviderConfig seedCfg = newConfig("local", License.SERVER, true);
StorageEncryptionState seedState = seedCfg.storageEncryptionState(MASTER, false, txManager);
Team team = new Team();
team.setId(1L);
User owner = new User();
owner.setTeam(team);
seedState.keyService().activeKekForOwner(owner);
StorageProviderConfig cfg = newConfig("local", License.NORMAL, false);
StorageEncryptionState state = cfg.storageEncryptionState(MASTER, false, txManager);
assertThat(cfg.storageProvider(state, Optional.empty()))
.isInstanceOf(EncryptingStorageProvider.class);
assertThat(state.isWriteEnabled()).isFalse();
// Encrypted content may exist -> presigned URLs must be suppressed on this node too.
assertThat(state.suppressDirectDownloads()).isTrue();
// Eager init ran (keys existed at boot), so decryption works without a licence.
assertThat(state.keyService()).isNotNull();
}
@Test
void encryption_enabled_normalLicense_failsStartup() {
StorageProviderConfig cfg = newConfig("local", License.NORMAL, true);
assertThatThrownBy(() -> cfg.storageEncryptionState(MASTER, false, txManager))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("storage.encryption requires a Pro or Enterprise license");
}
@Test
void encryption_enabled_wrongLengthKey_failsStartup() {
StorageProviderConfig cfg = newConfig("local", License.SERVER, true);
String shortKey = Base64.getEncoder().encodeToString("only16bytes-yes!".getBytes());
assertThatThrownBy(() -> cfg.storageEncryptionState(shortKey, false, txManager))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("32 bytes");
}
// ---- backend licence gates (unchanged behaviour) ------------------------------------
@Test
void provider_s3_normalLicense_throwsBeforeBuildingClient() {
StorageProviderConfig cfg = newConfig("s3", License.NORMAL);
StorageProviderConfig cfg = newConfig("s3", License.NORMAL, false);
StorageEncryptionState state = cfg.storageEncryptionState(MASTER, false, txManager);
// License check must throw BEFORE S3Clients.build tries to validate endpoint / bucket.
// Otherwise an empty config would surface as a confusing "bucket must be set" error.
assertThatThrownBy(cfg::storageProvider)
assertThatThrownBy(() -> cfg.storageProvider(state, Optional.empty()))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("storage.provider=s3 requires a Pro or Enterprise license");
}
@Test
void provider_database_normalLicense_throws() {
StorageProviderConfig cfg = newConfig("database", License.NORMAL);
StorageProviderConfig cfg = newConfig("database", License.NORMAL, false);
StorageEncryptionState state = cfg.storageEncryptionState(MASTER, false, txManager);
assertThatThrownBy(cfg::storageProvider)
assertThatThrownBy(() -> cfg.storageProvider(state, Optional.empty()))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining(
"storage.provider=database requires a Pro or Enterprise license");
}
@Test
void provider_database_serverLicense_buildsDatabaseProvider() {
StorageProviderConfig cfg = newConfig("database", License.SERVER);
assertThatCode(cfg::storageProvider).doesNotThrowAnyException();
void provider_database_serverLicense_builds() {
StorageProviderConfig cfg = newConfig("database", License.SERVER, false);
StorageEncryptionState state = cfg.storageEncryptionState(MASTER, false, txManager);
assertThatCode(() -> cfg.storageProvider(state, Optional.empty()))
.doesNotThrowAnyException();
}
@Test
void provider_s3_serverLicense_passesLicenseCheck_thenFailsOnEmptyConfig() {
StorageProviderConfig cfg = newConfig("s3", License.SERVER);
StorageProviderConfig cfg = newConfig("s3", License.SERVER, false);
StorageEncryptionState state = cfg.storageEncryptionState(MASTER, false, txManager);
// Valid license, but no bucket/endpoint configured - so we expect a CONFIG error,
// not a license error. The error message must not mention the license.
assertThatThrownBy(cfg::storageProvider)
// not a license error.
assertThatThrownBy(() -> cfg.storageProvider(state, Optional.empty()))
.isInstanceOf(IllegalStateException.class)
.hasMessageNotContaining("Pro or Enterprise license");
}
@Test
void provider_s3_enterpriseLicense_passesLicenseCheck_thenFailsOnEmptyConfig() {
StorageProviderConfig cfg = newConfig("s3", License.ENTERPRISE);
void provider_unknown_throwsUnsupportedProvider_notLicense() {
StorageProviderConfig cfg = newConfig("magic", License.NORMAL, false);
StorageEncryptionState state = cfg.storageEncryptionState(MASTER, false, txManager);
assertThatThrownBy(cfg::storageProvider)
.isInstanceOf(IllegalStateException.class)
.hasMessageNotContaining("Pro or Enterprise license");
}
@Test
void provider_unknown_normalLicense_throwsUnsupportedProvider_notLicense() {
StorageProviderConfig cfg = newConfig("magic", License.NORMAL);
assertThatThrownBy(cfg::storageProvider)
assertThatThrownBy(() -> cfg.storageProvider(state, Optional.empty()))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("Storage provider not supported: magic")
.hasMessageNotContaining("license");
}
private static StorageProviderConfig newConfig(String provider, License license) {
private StorageProviderConfig newConfig(
String provider, License license, boolean encryptionEnabled) {
ApplicationProperties props = new ApplicationProperties();
props.getStorage().setProvider(provider);
props.getStorage()
.setEnabled(false); // local-fallback path skips dir creation when disabled
props.getStorage().setEnabled(false); // local-fallback path skips dir creation
props.getStorage().getEncryption().setEnabled(encryptionEnabled);
StoredFileBlobRepository repo = mock(StoredFileBlobRepository.class);
LicenseKeyChecker checker = mock(LicenseKeyChecker.class);
when(checker.getPremiumLicenseEnabledResult()).thenReturn(license);
@@ -111,6 +181,6 @@ class StorageProviderConfigTest {
.when(checker)
.requireProOrEnterprise(anyString());
}
return new StorageProviderConfig(props, repo, checker);
return new StorageProviderConfig(props, repo, keyRepo.mock, checker);
}
}
@@ -0,0 +1,78 @@
package stirling.software.proprietary.storage.crypto;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class EncryptedFileFormatTest {
private static EncryptedFileFormat.Header sampleHeader() {
return new EncryptedFileFormat.Header(
EncryptedFileFormat.FORMAT_VERSION,
EncryptedFileFormat.SUITE_AES_GCM_HKDF_1MIB,
UUID.randomUUID(),
123_456_789L,
new byte[EncryptedFileFormat.WRAPPED_DEK_LENGTH]);
}
@Test
void serialize_parse_roundtrips() throws Exception {
EncryptedFileFormat.Header header = sampleHeader();
byte[] bytes = header.serialize();
assertThat(bytes).hasSize(EncryptedFileFormat.HEADER_LENGTH);
EncryptedFileFormat.Header parsed = EncryptedFileFormat.parse(bytes);
assertThat(parsed).isNotNull();
assertThat(parsed.keyId()).isEqualTo(header.keyId());
assertThat(parsed.plaintextLength()).isEqualTo(header.plaintextLength());
assertThat(parsed.wrappedDek()).isEqualTo(header.wrappedDek());
}
@Test
void parse_returnsNullForPlaintext() throws Exception {
byte[] pdf = new byte[EncryptedFileFormat.HEADER_LENGTH];
pdf[0] = '%';
pdf[1] = 'P';
pdf[2] = 'D';
pdf[3] = 'F';
assertThat(EncryptedFileFormat.parse(pdf)).isNull();
}
@Test
void parse_returnsNullForShortPrefix() throws Exception {
assertThat(EncryptedFileFormat.parse(new byte[10])).isNull();
assertThat(EncryptedFileFormat.parse(null)).isNull();
}
@Test
void parse_throwsCheckedOnUnknownVersion() {
byte[] bytes = sampleHeader().serialize();
bytes[8] = 99; // format version
// Checked StorageEncryptionException so callers' IOException mapping catches it —
// a downgrade-read must not surface as a bare 500.
assertThatThrownBy(() -> EncryptedFileFormat.parse(bytes))
.isInstanceOf(StorageEncryptionException.class)
.hasMessageContaining("Unsupported");
}
@Test
void associatedData_excludesWrappedDek() {
EncryptedFileFormat.Header header = sampleHeader();
byte[] aad = header.associatedData();
assertThat(aad).hasSize(34);
// Changing the wrapped DEK must not change the AAD (it is authenticated separately).
byte[] otherDek = new byte[EncryptedFileFormat.WRAPPED_DEK_LENGTH];
otherDek[0] = 1;
EncryptedFileFormat.Header other =
new EncryptedFileFormat.Header(
header.formatVersion(),
header.cipherSuite(),
header.keyId(),
header.plaintextLength(),
otherDek);
assertThat(other.associatedData()).isEqualTo(aad);
}
}
@@ -0,0 +1,391 @@
package stirling.software.proprietary.storage.crypto;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Base64;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.core.io.InputStreamResource;
import org.springframework.core.io.Resource;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.storage.model.FileEncryptionKey;
import stirling.software.proprietary.storage.provider.LocalStorageProvider;
import stirling.software.proprietary.storage.provider.StorageProvider;
import stirling.software.proprietary.storage.provider.StoredObject;
class EncryptingStorageProviderTest {
private static final byte[] PLAINTEXT =
"This is the secret PDF payload used to prove round-trips work."
.getBytes(StandardCharsets.UTF_8);
private static final String MASTER =
Base64.getEncoder()
.encodeToString(
"0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8));
@TempDir Path tempDir;
private InMemoryKeyRepo repo;
private LocalStorageProvider inner;
private EncryptingStorageProvider provider;
private User owner;
@BeforeEach
void setUp() {
repo = new InMemoryKeyRepo();
inner = new LocalStorageProvider(tempDir);
provider = new EncryptingStorageProvider(inner, newKeyService(), true);
Team team = new Team();
team.setId(7L);
owner = new User();
owner.setId(1L);
owner.setTeam(team);
}
private FileEncryptionKeyService newKeyService() {
return new FileEncryptionKeyService(repo.mock, new FileEncryptionMasterKey(MASTER, false));
}
private static MultipartFile upload() {
return new MockMultipartFile("file", "test.pdf", "application/pdf", PLAINTEXT);
}
@Test
void store_load_roundTripsAndKeepsPlaintextMetadata() throws IOException {
StoredObject stored = provider.store(owner, upload());
assertThat(stored.getSizeBytes()).isEqualTo(PLAINTEXT.length);
assertThat(stored.getEncryptionKeyId()).isNotNull();
assertThat(stored.getOriginalFilename()).isEqualTo("test.pdf");
Resource loaded = provider.load(stored.getStorageKey());
assertThat(loaded.contentLength()).isEqualTo(PLAINTEXT.length);
try (InputStream in = loaded.getInputStream()) {
assertThat(in.readAllBytes()).isEqualTo(PLAINTEXT);
}
// Re-openable resources must support a second full read (e.g. retry after range abort).
try (InputStream in = loaded.getInputStream()) {
assertThat(in.readAllBytes()).isEqualTo(PLAINTEXT);
}
}
@Test
void store_writesOnlyCiphertextToDisk() throws IOException {
StoredObject stored = provider.store(owner, upload());
byte[] onDisk = Files.readAllBytes(tempDir.resolve(stored.getStorageKey()));
assertThat(new String(onDisk, 0, 8, StandardCharsets.US_ASCII)).isEqualTo("SPDFEAR1");
assertThat(new String(onDisk, StandardCharsets.ISO_8859_1)).doesNotContain("secret");
assertThat(onDisk.length).isGreaterThan(PLAINTEXT.length);
}
@Test
void load_legacyPlaintextBlob_passesThroughUntouched() throws IOException {
StoredObject legacy = inner.store(owner, upload());
Resource loaded = provider.load(legacy.getStorageKey());
try (InputStream in = loaded.getInputStream()) {
assertThat(in.readAllBytes()).isEqualTo(PLAINTEXT);
}
}
@Test
void store_writeDisabled_staysPlaintextButLoadStillDecrypts() throws IOException {
StoredObject encrypted = provider.store(owner, upload());
EncryptingStorageProvider decryptOnly =
new EncryptingStorageProvider(inner, newKeyService(), false);
StoredObject plain = decryptOnly.store(owner, upload());
assertThat(plain.getEncryptionKeyId()).isNull();
byte[] onDisk = Files.readAllBytes(tempDir.resolve(plain.getStorageKey()));
assertThat(onDisk).isEqualTo(PLAINTEXT);
// Decrypt-only mode still reads previously encrypted content.
try (InputStream in = decryptOnly.load(encrypted.getStorageKey()).getInputStream()) {
assertThat(in.readAllBytes()).isEqualTo(PLAINTEXT);
}
}
@Test
void load_disabledKey_failsClosed() throws IOException {
StoredObject stored = provider.store(owner, upload());
repo.rows
.get(UUID.fromString(stored.getEncryptionKeyId()))
.setStatus(FileEncryptionKey.Status.DISABLED);
// Fresh service = fresh unwrap cache, as another node would see it.
EncryptingStorageProvider fresh =
new EncryptingStorageProvider(inner, newKeyService(), true);
assertThatThrownBy(() -> fresh.load(stored.getStorageKey()))
.isInstanceOf(StorageKeyRevokedException.class)
.hasMessageContaining("disabled");
}
@Test
void load_tamperedPayload_failsAuthentication() throws IOException {
StoredObject stored = provider.store(owner, upload());
Path blob = tempDir.resolve(stored.getStorageKey());
byte[] bytes = Files.readAllBytes(blob);
bytes[EncryptedFileFormat.HEADER_LENGTH + 20] ^= 0x42; // flip a payload bit
Files.write(blob, bytes);
Resource loaded = provider.load(stored.getStorageKey());
assertThatThrownBy(
() -> {
try (InputStream in = loaded.getInputStream()) {
in.readAllBytes();
}
})
.isInstanceOf(IOException.class);
}
@Test
void load_tamperedHeaderKeyId_failsAuthentication() throws IOException {
StoredObject stored = provider.store(owner, upload());
// Second key so the swapped-in id exists and is ACTIVE.
Team otherTeam = new Team();
otherTeam.setId(99L);
User otherOwner = new User();
otherOwner.setId(2L);
otherOwner.setTeam(otherTeam);
StoredObject other = provider.store(otherOwner, upload());
Path blob = tempDir.resolve(stored.getStorageKey());
byte[] bytes = Files.readAllBytes(blob);
byte[] otherBytes = Files.readAllBytes(tempDir.resolve(other.getStorageKey()));
System.arraycopy(otherBytes, 10, bytes, 10, 16); // transplant the other key id
Files.write(blob, bytes);
assertThatThrownBy(() -> provider.load(stored.getStorageKey()))
.isInstanceOf(StorageEncryptionException.class);
}
@Test
void signedDownloadUrl_isSuppressed() throws IOException {
StoredObject stored = provider.store(owner, upload());
assertThat(provider.signedDownloadUrl(stored.getStorageKey(), Duration.ofMinutes(5)))
.isEmpty();
assertThat(
provider.signedDownloadUrl(
stored.getStorageKey(), Duration.ofMinutes(5), true, "a.pdf"))
.isEmpty();
}
@Test
void load_oneShotStreamBackend_roundTrips() throws IOException {
// Simulates S3: load() hands back a single-use InputStreamResource.
StorageProvider oneShotInner =
new StorageProvider() {
@Override
public StoredObject store(User owner, MultipartFile file) throws IOException {
return inner.store(owner, file);
}
@Override
public Resource load(String storageKey) throws IOException {
InputStream in = inner.load(storageKey).getInputStream();
return new InputStreamResource(in);
}
@Override
public void delete(String storageKey) throws IOException {
inner.delete(storageKey);
}
};
EncryptingStorageProvider oneShot =
new EncryptingStorageProvider(oneShotInner, newKeyService(), true);
StoredObject stored = oneShot.store(owner, upload());
Resource loaded = oneShot.load(stored.getStorageKey());
assertThat(loaded.isOpen()).isTrue();
assertThat(loaded.contentLength()).isEqualTo(PLAINTEXT.length);
try (InputStream in = loaded.getInputStream()) {
assertThat(in.readAllBytes()).isEqualTo(PLAINTEXT);
}
// Legacy plaintext through the one-shot path replays the sniffed prefix.
StoredObject legacy = inner.store(owner, upload());
try (InputStream in = oneShot.load(legacy.getStorageKey()).getInputStream()) {
assertThat(in.readAllBytes()).isEqualTo(PLAINTEXT);
}
}
@Test
void store_largeFile_roundTripsAcrossSegmentBoundaries() throws IOException {
byte[] big = new byte[EncryptedFileFormat.SEGMENT_SIZE_BYTES * 2 + 12345];
for (int i = 0; i < big.length; i++) {
big[i] = (byte) (i * 31);
}
StoredObject stored =
provider.store(
owner, new MockMultipartFile("file", "big.bin", "application/pdf", big));
assertThat(stored.getSizeBytes()).isEqualTo(big.length);
try (InputStream in = provider.load(stored.getStorageKey()).getInputStream()) {
assertThat(in.readAllBytes()).isEqualTo(big);
}
}
@Test
void load_tinyLegacyBlob_shorterThanHeader_passesThrough() throws IOException {
byte[] tiny = "hi".getBytes(StandardCharsets.UTF_8);
StoredObject legacy =
inner.store(owner, new MockMultipartFile("file", "tiny.txt", "text/plain", tiny));
try (InputStream in = provider.load(legacy.getStorageKey()).getInputStream()) {
assertThat(in.readAllBytes()).isEqualTo(tiny);
}
}
/** Tracks close() so leak regressions on the one-shot (S3-style) path are caught. */
private final class TrackingOneShotProvider implements StorageProvider {
final java.util.concurrent.atomic.AtomicInteger openStreams =
new java.util.concurrent.atomic.AtomicInteger();
@Override
public StoredObject store(User owner, MultipartFile file) throws IOException {
return inner.store(owner, file);
}
@Override
public Resource load(String storageKey) throws IOException {
openStreams.incrementAndGet();
InputStream tracked =
new java.io.FilterInputStream(inner.load(storageKey).getInputStream()) {
@Override
public void close() throws IOException {
openStreams.decrementAndGet();
super.close();
}
};
return new InputStreamResource(tracked);
}
@Override
public void delete(String storageKey) throws IOException {
inner.delete(storageKey);
}
}
@Test
void load_oneShot_disabledKey_closesUnderlyingStream() throws IOException {
StoredObject stored = provider.store(owner, upload());
repo.rows
.get(UUID.fromString(stored.getEncryptionKeyId()))
.setStatus(FileEncryptionKey.Status.DISABLED);
TrackingOneShotProvider tracking = new TrackingOneShotProvider();
EncryptingStorageProvider oneShot =
new EncryptingStorageProvider(tracking, newKeyService(), true);
// Exercising the kill switch repeatedly must not leak connections.
for (int i = 0; i < 3; i++) {
assertThatThrownBy(() -> oneShot.load(stored.getStorageKey()))
.isInstanceOf(StorageKeyRevokedException.class);
}
assertThat(tracking.openStreams.get()).isZero();
}
@Test
void load_oneShot_unknownHeaderVersion_closesUnderlyingStream() throws IOException {
StoredObject stored = provider.store(owner, upload());
Path blob = tempDir.resolve(stored.getStorageKey());
byte[] bytes = Files.readAllBytes(blob);
bytes[8] = 99; // future format version
Files.write(blob, bytes);
TrackingOneShotProvider tracking = new TrackingOneShotProvider();
EncryptingStorageProvider oneShot =
new EncryptingStorageProvider(tracking, newKeyService(), true);
assertThatThrownBy(() -> oneShot.load(stored.getStorageKey()))
.isInstanceOf(StorageEncryptionException.class)
.hasMessageContaining("Unsupported");
assertThat(tracking.openStreams.get()).isZero();
}
@Test
void store_uploadMisreportingSize_failsInsteadOfCorruptingContentLength() {
MultipartFile lying =
new MockMultipartFile("file", "test.pdf", "application/pdf", PLAINTEXT) {
@Override
public long getSize() {
return PLAINTEXT.length + 5;
}
};
assertThatThrownBy(() -> provider.store(owner, lying))
.isInstanceOf(StorageEncryptionException.class)
.hasMessageContaining("streamed");
}
@Test
void load_tamperedPlaintextLength_failsAuthentication() throws IOException {
StoredObject stored = provider.store(owner, upload());
Path blob = tempDir.resolve(stored.getStorageKey());
byte[] bytes = Files.readAllBytes(blob);
bytes[33] ^= 0x01; // low byte of the header's plaintextLength field
Files.write(blob, bytes);
// plaintextLength is part of the AAD, so the DEK unwrap must reject the header.
assertThatThrownBy(() -> provider.load(stored.getStorageKey()))
.isInstanceOf(StorageEncryptionException.class);
}
@Test
void signedDownloadUrl_delegatesWhenNoEncryptedContentPossible() throws IOException {
// Vanilla install: flag off, no key rows anywhere -> keep the backend's fast path.
StorageEncryptionState vanilla =
new StorageEncryptionState(false, () -> newKeyService(), repo.mock);
StorageProvider withUrls =
new StorageProvider() {
@Override
public StoredObject store(User owner, MultipartFile file) throws IOException {
return inner.store(owner, file);
}
@Override
public Resource load(String storageKey) throws IOException {
return inner.load(storageKey);
}
@Override
public void delete(String storageKey) throws IOException {
inner.delete(storageKey);
}
@Override
public java.util.Optional<java.net.URI> signedDownloadUrl(
String storageKey,
Duration ttl,
boolean inline,
String originalFilename) {
return java.util.Optional.of(java.net.URI.create("https://signed.example"));
}
};
EncryptingStorageProvider decorated = new EncryptingStorageProvider(withUrls, vanilla);
assertThat(decorated.signedDownloadUrl("k", Duration.ofMinutes(5), false, "a.pdf"))
.contains(java.net.URI.create("https://signed.example"));
// As soon as key rows exist, the same node must stop handing out direct URLs.
StoredObject encrypted = provider.store(owner, upload());
assertThat(encrypted.getEncryptionKeyId()).isNotNull();
StorageEncryptionState drifted =
new StorageEncryptionState(false, () -> newKeyService(), repo.mock);
EncryptingStorageProvider driftedNode = new EncryptingStorageProvider(withUrls, drifted);
assertThat(driftedNode.signedDownloadUrl("k", Duration.ofMinutes(5), false, "a.pdf"))
.isEmpty();
}
}
@@ -0,0 +1,113 @@
package stirling.software.proprietary.storage.crypto;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.Base64;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.AutoConfigurationPackage;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.storage.model.FileEncryptionKey;
import stirling.software.proprietary.storage.repository.FileEncryptionKeyRepository;
/**
* Key-creation against a real database and real transaction boundaries. The Mockito race test
* throws synchronously from save(), which hides the production failure mode: under a caller's
* transaction (e.g. WorkflowSessionService is class-level {@code @Transactional}) an assigned-UUID
* INSERT defers to the outer flush, so without REQUIRES_NEW the duplicate-key error would surface
* at commit — far from the recovery catch — with the outer transaction already rollback-only.
*/
@DataJpaTest
class FileEncryptionKeyServiceDbTest {
private static final String MASTER =
Base64.getEncoder().encodeToString("0123456789abcdef0123456789abcdef".getBytes());
@Autowired private FileEncryptionKeyRepository repository;
@Autowired private PlatformTransactionManager transactionManager;
@AfterEach
void wipe() {
repository.deleteAllInBatch();
}
private FileEncryptionKeyService newService() {
TransactionTemplate requiresNew = new TransactionTemplate(transactionManager);
requiresNew.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
return new FileEncryptionKeyService(
repository, new FileEncryptionMasterKey(MASTER, false), requiresNew);
}
private static User teamUser(long teamId) {
Team team = new Team();
team.setId(teamId);
User user = new User();
user.setTeam(team);
return user;
}
@Test
void createActive_duplicateInsertInsideCallerTransaction_recoversAndKeepsCallerHealthy() {
// "Node A" creates the scope key first (committed independently).
FileEncryptionKey winner = newService().createActive(FileEncryptionKey.ScopeType.TEAM, 42L);
assertThat(repository.count()).isEqualTo(1);
// "Node B" raced: it computed its key version BEFORE A's commit was visible, so it
// attempts the same (scope, version) row. Only that read is faked; the flush, the
// unique-constraint violation, and the recovery all run against the real database.
FileEncryptionKeyRepository raceTimedRepo =
org.mockito.Mockito.mock(
FileEncryptionKeyRepository.class,
org.mockito.AdditionalAnswers.delegatesTo(repository));
org.mockito.Mockito.doReturn(java.util.Optional.empty())
.when(raceTimedRepo)
.findFirstByScopeTypeAndScopeIdOrderByKeyVersionDesc(
FileEncryptionKey.ScopeType.TEAM, 42L);
TransactionTemplate requiresNew = new TransactionTemplate(transactionManager);
requiresNew.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
FileEncryptionKeyService raced =
new FileEncryptionKeyService(
raceTimedRepo, new FileEncryptionMasterKey(MASTER, false), requiresNew);
// Inside a caller transaction (the WorkflowSessionService shape): the duplicate insert
// must be absorbed by createActive's own REQUIRES_NEW transaction, recover to the
// winner's row, and leave the caller transaction committable.
TransactionTemplate callerTx = new TransactionTemplate(transactionManager);
FileEncryptionKey resolved =
callerTx.execute(
status -> {
FileEncryptionKey row =
raced.createActive(FileEncryptionKey.ScopeType.TEAM, 42L);
assertThat(status.isRollbackOnly()).isFalse();
return row;
});
assertThat(resolved.getKeyId()).isEqualTo(winner.getKeyId());
assertThat(repository.count()).isEqualTo(1);
}
@Test
void activeKekForOwner_roundTripsThroughRealDatabase() throws Exception {
FileEncryptionKeyService service = newService();
FileEncryptionKeyService.ScopeKek created = service.activeKekForOwner(teamUser(7));
// Fresh service (cold caches) unwraps the same key material from the committed row.
assertThat(newService().kekForDecrypt(created.keyId())).isEqualTo(created.key());
}
// The key entity/repository live in sibling packages, so point the JPA slice at the whole
// proprietary tree (the entity graph pulls in User/Team either way).
@SpringBootConfiguration
@AutoConfigurationPackage(basePackages = "stirling.software.proprietary")
static class TestApp {}
}
@@ -0,0 +1,197 @@
package stirling.software.proprietary.storage.crypto;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
import java.util.Base64;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.dao.DataIntegrityViolationException;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.storage.model.FileEncryptionKey;
class FileEncryptionKeyServiceTest {
private static final String MASTER_A =
Base64.getEncoder()
.encodeToString(
new byte[] {
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
});
private static final String MASTER_B =
Base64.getEncoder()
.encodeToString(
new byte[] {
32, 31, 30, 29, 28, 27, 26, 25, 24, 23, 22, 21, 20, 19, 18, 17, 16,
15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1
});
private InMemoryKeyRepo repo;
private FileEncryptionKeyService service;
@BeforeEach
void setUp() {
repo = new InMemoryKeyRepo();
service =
new FileEncryptionKeyService(
repo.mock, new FileEncryptionMasterKey(MASTER_A, false));
}
private static User teamUser(long teamId) {
Team team = new Team();
team.setId(teamId);
User user = new User();
user.setTeam(team);
return user;
}
@Test
void activeKekForOwner_createsPerTeamKeyOnDemand_thenReuses() throws Exception {
FileEncryptionKeyService.ScopeKek first = service.activeKekForOwner(teamUser(5));
FileEncryptionKeyService.ScopeKek second = service.activeKekForOwner(teamUser(5));
assertThat(second.keyId()).isEqualTo(first.keyId());
assertThat(repo.rows).hasSize(1);
FileEncryptionKey row = repo.rows.values().iterator().next();
assertThat(row.getScopeType()).isEqualTo(FileEncryptionKey.ScopeType.TEAM);
assertThat(row.getScopeId()).isEqualTo(5);
assertThat(row.getStatus()).isEqualTo(FileEncryptionKey.Status.ACTIVE);
FileEncryptionKeyService.ScopeKek otherTeam = service.activeKekForOwner(teamUser(6));
assertThat(otherTeam.keyId()).isNotEqualTo(first.keyId());
assertThat(repo.rows).hasSize(2);
}
@Test
void activeKekForOwner_withoutTeam_usesGlobalScope() throws Exception {
service.activeKekForOwner(new User());
FileEncryptionKey row = repo.rows.values().iterator().next();
assertThat(row.getScopeType()).isEqualTo(FileEncryptionKey.ScopeType.GLOBAL);
assertThat(row.getScopeId()).isZero();
}
@Test
void kekForDecrypt_unwrapsWhatWasWrapped() throws Exception {
FileEncryptionKeyService.ScopeKek created = service.activeKekForOwner(teamUser(1));
// A fresh service (empty cache) must unwrap the same key material from the DB row.
FileEncryptionKeyService fresh =
new FileEncryptionKeyService(
repo.mock, new FileEncryptionMasterKey(MASTER_A, false));
assertThat(fresh.kekForDecrypt(created.keyId())).isEqualTo(created.key());
}
@Test
void kekForDecrypt_disabledKey_failsClosed() throws Exception {
FileEncryptionKeyService.ScopeKek created = service.activeKekForOwner(teamUser(1));
repo.rows.get(created.keyId()).setStatus(FileEncryptionKey.Status.DISABLED);
FileEncryptionKeyService fresh =
new FileEncryptionKeyService(
repo.mock, new FileEncryptionMasterKey(MASTER_A, false));
assertThatThrownBy(() -> fresh.kekForDecrypt(created.keyId()))
.isInstanceOf(StorageKeyRevokedException.class)
.hasMessageContaining("disabled");
}
@Test
void kekForDecrypt_unknownKey_failsClosed() {
assertThatThrownBy(() -> service.kekForDecrypt(UUID.randomUUID()))
.isInstanceOf(StorageEncryptionException.class)
.hasMessageContaining("No encryption key");
}
@Test
void verifyMasterKey_wrongKey_refusesStartup() throws Exception {
service.activeKekForOwner(teamUser(1));
FileEncryptionKeyService wrongKey =
new FileEncryptionKeyService(
repo.mock, new FileEncryptionMasterKey(MASTER_B, false));
assertThatThrownBy(wrongKey::verifyMasterKey)
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("cannot unwrap");
}
@Test
void verifyMasterKey_correctKeyOrEmptyRegistry_passes() throws Exception {
service.verifyMasterKey(); // empty registry: nothing to prove
service.activeKekForOwner(teamUser(1));
service.verifyMasterKey();
}
@Test
void verifyMasterKey_retiredOnlyRegistry_stillVerifies() throws Exception {
FileEncryptionKeyService.ScopeKek created = service.activeKekForOwner(teamUser(1));
repo.rows.get(created.keyId()).setStatus(FileEncryptionKey.Status.RETIRED);
// A retired-only registry must still prove the key (RETIRED rows decrypt old blobs)...
new FileEncryptionKeyService(repo.mock, new FileEncryptionMasterKey(MASTER_A, false))
.verifyMasterKey();
// ...and still refuse a mismatched master key.
assertThatThrownBy(
() ->
new FileEncryptionKeyService(
repo.mock,
new FileEncryptionMasterKey(MASTER_B, false))
.verifyMasterKey())
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("cannot unwrap");
}
@Test
void createActive_concurrentInsertRace_fallsBackToWinnersRow() throws Exception {
// First save call hits the unique constraint; the service must re-read the winner's row.
FileEncryptionKey winner = new FileEncryptionKey();
winner.setKeyId(UUID.randomUUID());
winner.setScopeType(FileEncryptionKey.ScopeType.TEAM);
winner.setScopeId(9);
winner.setKeyVersion(1);
winner.setStatus(FileEncryptionKey.Status.ACTIVE);
winner.setMasterKeyVersion(1);
FileEncryptionMasterKey master = new FileEncryptionMasterKey(MASTER_A, false);
byte[] winnerKek = new byte[32];
winner.setWrappedKey(
Base64.getEncoder()
.encodeToString(
master.wrap(
winnerKek,
winner.getKeyId()
.toString()
.getBytes(
java.nio.charset.StandardCharsets
.US_ASCII))));
doAnswer(
inv -> {
repo.rows.put(winner.getKeyId(), winner);
throw new DataIntegrityViolationException("duplicate key");
})
.doAnswer(
inv -> {
FileEncryptionKey row = inv.getArgument(0);
repo.rows.put(row.getKeyId(), row);
return row;
})
.when(repo.mock)
.saveAndFlush(any(FileEncryptionKey.class));
FileEncryptionKeyService racedService = new FileEncryptionKeyService(repo.mock, master);
FileEncryptionKeyService.ScopeKek resolved = racedService.activeKekForOwner(teamUser(9));
assertThat(resolved.keyId()).isEqualTo(winner.getKeyId());
assertThat(resolved.key()).isEqualTo(winnerKek);
}
@Test
void createActive_raceWithoutWinner_rethrows() {
doThrow(new DataIntegrityViolationException("duplicate key"))
.when(repo.mock)
.saveAndFlush(any(FileEncryptionKey.class));
assertThatThrownBy(() -> service.activeKekForOwner(teamUser(9)))
.isInstanceOf(DataIntegrityViolationException.class);
}
}
@@ -0,0 +1,66 @@
package stirling.software.proprietary.storage.crypto;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.Comparator;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import stirling.software.proprietary.storage.model.FileEncryptionKey;
import stirling.software.proprietary.storage.repository.FileEncryptionKeyRepository;
/** Map-backed Mockito stub of the key repository for crypto tests (no JPA slice needed). */
public final class InMemoryKeyRepo {
public final Map<UUID, FileEncryptionKey> rows = new ConcurrentHashMap<>();
public final FileEncryptionKeyRepository mock;
public InMemoryKeyRepo() {
mock = mock(FileEncryptionKeyRepository.class);
when(mock.saveAndFlush(any(FileEncryptionKey.class)))
.thenAnswer(
inv -> {
FileEncryptionKey row = inv.getArgument(0);
rows.put(row.getKeyId(), row);
return row;
});
when(mock.save(any(FileEncryptionKey.class)))
.thenAnswer(
inv -> {
FileEncryptionKey row = inv.getArgument(0);
rows.put(row.getKeyId(), row);
return row;
});
when(mock.findById(any(UUID.class)))
.thenAnswer(inv -> Optional.ofNullable(rows.get(inv.<UUID>getArgument(0))));
when(mock.findFirstByScopeTypeAndScopeIdAndStatus(any(), anyLong(), any()))
.thenAnswer(
inv ->
rows.values().stream()
.filter(r -> r.getScopeType() == inv.getArgument(0))
.filter(r -> r.getScopeId() == inv.<Long>getArgument(1))
.filter(r -> r.getStatus() == inv.getArgument(2))
.findFirst());
when(mock.findFirstByScopeTypeAndScopeIdOrderByKeyVersionDesc(any(), anyLong()))
.thenAnswer(
inv ->
rows.values().stream()
.filter(r -> r.getScopeType() == inv.getArgument(0))
.filter(r -> r.getScopeId() == inv.<Long>getArgument(1))
.max(
Comparator.comparingInt(
FileEncryptionKey::getKeyVersion)));
when(mock.findFirstByStatus(any()))
.thenAnswer(
inv ->
rows.values().stream()
.filter(r -> r.getStatus() == inv.getArgument(0))
.findFirst());
when(mock.count()).thenAnswer(inv -> (long) rows.size());
}
}
@@ -19,12 +19,15 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.http.HttpStatus;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.server.ResponseStatusException;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.storage.crypto.StorageEncryptionException;
import stirling.software.proprietary.storage.crypto.StorageKeyRevokedException;
import stirling.software.proprietary.storage.model.FileShare;
import stirling.software.proprietary.storage.model.ShareAccessRole;
import stirling.software.proprietary.storage.model.StoredFile;
@@ -570,4 +573,37 @@ class FileStorageServiceTest {
verify(storedFileRepository).delete(f);
}
// -------------------------------------------------------------------------
// loadFile — encryption error mapping
// -------------------------------------------------------------------------
@Test
void loadFile_revokedKey_throwsForbidden() throws IOException {
StoredFile f = ownedFile(user(1L));
f.setStorageKey("k");
when(storageProvider.load("k"))
.thenThrow(new StorageKeyRevokedException("Encryption key X is disabled"));
assertThatThrownBy(() -> service.loadFile(f))
.isInstanceOf(ResponseStatusException.class)
.satisfies(
e ->
assertThat(((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.FORBIDDEN));
}
@Test
void loadFile_genericIoError_throwsInternalServerError() throws IOException {
StoredFile f = ownedFile(user(1L));
f.setStorageKey("k");
when(storageProvider.load("k")).thenThrow(new StorageEncryptionException("corrupt blob"));
assertThatThrownBy(() -> service.loadFile(f))
.isInstanceOf(ResponseStatusException.class)
.satisfies(
e ->
assertThat(((ResponseStatusException) e).getStatusCode())
.isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR));
}
}
+1
View File
@@ -44,6 +44,7 @@ ext {
jpdfiumVersion = "1.0.2"
jwtVersion = "0.13.0"
awsSdkVersion = "2.44.12"
tinkVersion = "1.23.0"
testcontainersMinioVersion = "1.21.4"
// junit-platform-launcher version managed by Spring Boot BOM
modernJavaVersion = 25