mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
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:
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
+66
-1
@@ -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())
|
||||
|
||||
+105
@@ -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 0–33 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);
|
||||
}
|
||||
}
|
||||
+470
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+214
@@ -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);
|
||||
}
|
||||
}
|
||||
+172
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -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);
|
||||
}
|
||||
}
|
||||
+112
@@ -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;
|
||||
}
|
||||
}
|
||||
+14
@@ -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);
|
||||
}
|
||||
}
|
||||
+95
@@ -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;
|
||||
}
|
||||
+8
@@ -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,
|
||||
|
||||
+6
-1
@@ -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;
|
||||
}
|
||||
|
||||
+21
@@ -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);
|
||||
}
|
||||
+14
@@ -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: {})",
|
||||
|
||||
+1
@@ -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);
|
||||
|
||||
+106
-36
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+78
@@ -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);
|
||||
}
|
||||
}
|
||||
+391
@@ -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();
|
||||
}
|
||||
}
|
||||
+113
@@ -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 {}
|
||||
}
|
||||
+197
@@ -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);
|
||||
}
|
||||
}
|
||||
+66
@@ -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());
|
||||
}
|
||||
}
|
||||
+36
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user