Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1104,6 +1104,13 @@ public static class Storage {
@Data
public static class Encryption {
private boolean enabled = false;

/**
* Emit an audit event for every decrypt of an encrypted blob. Compliance reviewers
* (HIPAA) expect read audit, so it defaults on; busy multi-user installs can disable.
* Denied decrypts and key lifecycle events are always audited regardless.
*/
private boolean auditReads = true;
}

@Data
Expand Down
25 changes: 7 additions & 18 deletions app/core/src/main/resources/settings.yml.template
Original file line number Diff line number Diff line change
Expand Up @@ -292,26 +292,15 @@ 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 at rest for stored files (AES-256, per-team keys). Requires a Pro or
# Enterprise licence. Key setup, cluster requirements, the encrypt-existing migration,
# the revocation kill switch and master-key rotation are documented in
# devGuide/STORAGE_ENCRYPTION_AT_REST.md
# WARNING: back up the master key (configs/file-encryption.key by default) - losing it
# makes every encrypted stored file permanently unrecoverable.
encryption:
enabled: false # set to 'true' to encrypt stored files at rest
auditReads: true # audit every decrypt of an encrypted file (denied decrypts and key lifecycle events are always audited). NOTE: audit events require an Enterprise licence; encryption itself works on Pro.
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ public enum AuditEventType {
// File operations - STANDARD level
FILE_OPERATION("File operation"),

// Storage encryption at rest - STANDARD level
STORAGE_ENCRYPTION("Storage encryption operation"),

// PDF operations - STANDARD level
PDF_PROCESS("PDF processing operation"),

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,14 @@
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.KeygenLicenseVerifier.License;
import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker;
import stirling.software.proprietary.service.AuditService;
import stirling.software.proprietary.storage.crypto.AuditingStorageEncryptionListener;
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.StorageEncryptionAuditListener;
import stirling.software.proprietary.storage.crypto.StorageEncryptionState;
import stirling.software.proprietary.storage.provider.DatabaseStorageProvider;
import stirling.software.proprietary.storage.provider.LocalStorageProvider;
Expand All @@ -42,23 +46,33 @@ public class StorageProviderConfig {
private final StoredFileBlobRepository storedFileBlobRepository;
private final FileEncryptionKeyRepository fileEncryptionKeyRepository;
private final LicenseKeyChecker licenseKeyChecker;
private final AuditService auditService;

/**
* 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.
* The encryption state behind the always-installed decorator, shared with the admin API and
* migration job. 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("${stirling.security.fileEncryptionKeyPrevious:}")
String previousFileEncryptionKey,
@Value("${stirling.security.fileEncryptionKeyVersion:1}") int fileEncryptionKeyVersion,
@Value("${cluster.enabled:false}") boolean clusterEnabled,
PlatformTransactionManager transactionManager) {
boolean writeEnabled = applicationProperties.getStorage().getEncryption().isEnabled();
if (writeEnabled) {
licenseKeyChecker.requireProOrEnterprise("storage.encryption");
warnIfAuditUnavailable();
}
StorageEncryptionAuditListener listener =
new AuditingStorageEncryptionListener(
auditService,
applicationProperties.getStorage().getEncryption().isAuditReads());
// Key creation must commit independently of any caller transaction (see
// FileEncryptionKeyService#createActive).
TransactionTemplate requiresNew = new TransactionTemplate(transactionManager);
Expand All @@ -68,8 +82,14 @@ public StorageEncryptionState storageEncryptionState(
writeEnabled,
() ->
createKeyService(
configuredFileEncryptionKey, clusterEnabled, requiresNew),
fileEncryptionKeyRepository);
configuredFileEncryptionKey,
previousFileEncryptionKey,
fileEncryptionKeyVersion,
clusterEnabled,
listener,
requiresNew),
fileEncryptionKeyRepository,
listener);
// The registry table may not exist when storage is unused, so only probe if it is on.
boolean probeForExistingKeys =
!writeEnabled && applicationProperties.getStorage().isEnabled();
Expand All @@ -82,12 +102,33 @@ public StorageEncryptionState storageEncryptionState(
return state;
}

/**
* Encryption at rest is available on Pro, but {@code AuditService} only records events on an
* Enterprise licence. Without this warning a Pro operator would enable encryption, be told it
* is audited, and silently get no encrypt/decrypt/revocation trail at all.
*/
private void warnIfAuditUnavailable() {
if (licenseKeyChecker.getPremiumLicenseEnabledResult() != License.ENTERPRISE) {
log.warn(
"Storage encryption at rest is enabled, but audit events require an Enterprise"
+ " licence: encrypt/decrypt, revocation and plaintext-export events"
+ " will NOT be recorded on this licence tier. Encryption itself is"
+ " unaffected. See devGuide/STORAGE_ENCRYPTION_AT_REST.md");
}
}

private FileEncryptionKeyService createKeyService(
String configuredKey, boolean clusterEnabled, TransactionOperations keyCreationTx) {
String configuredKey,
String previousKey,
int keyVersion,
boolean clusterEnabled,
StorageEncryptionAuditListener listener,
TransactionOperations keyCreationTx) {
FileEncryptionMasterKey masterKey =
new FileEncryptionMasterKey(configuredKey, clusterEnabled);
new FileEncryptionMasterKey(configuredKey, previousKey, keyVersion, clusterEnabled);
FileEncryptionKeyService keyService =
new FileEncryptionKeyService(fileEncryptionKeyRepository, masterKey, keyCreationTx);
new FileEncryptionKeyService(
fileEncryptionKeyRepository, masterKey, listener, keyCreationTx);
// Wrong key must fail fast, not silently start a second key hierarchy.
keyService.verifyMasterKey();
return keyService;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import java.time.Duration;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;

import org.springframework.http.ContentDisposition;
Expand All @@ -31,7 +32,9 @@
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.service.AuditService;
import stirling.software.proprietary.storage.model.FileShare;
import stirling.software.proprietary.storage.model.StoredFile;
import stirling.software.proprietary.storage.model.api.CreateShareLinkRequest;
Expand All @@ -56,6 +59,7 @@ public class FileStorageController {

private final FileStorageService fileStorageService;
private final StorageProvider storageProvider;
private final AuditService auditService;

@PostMapping(
value = "/files",
Expand Down Expand Up @@ -262,6 +266,21 @@ public List<ShareLinkAccessResponse> listShareAccesses(
private ResponseEntity<org.springframework.core.io.Resource> buildFileResponse(
StoredFile file, boolean inline) {
org.springframework.core.io.Resource resource = fileStorageService.loadFile(file);
if (file.getEncryptionKeyId() != null) {
// Compliance marker: a plaintext copy of encrypted-at-rest content left the platform
// (inline=true is an in-app view; false is a saved download).
auditService.audit(
AuditEventType.STORAGE_ENCRYPTION,
Map.of(
"action",
"plaintextExport",
"fileId",
file.getId(),
"inline",
inline,
"keyId",
file.getEncryptionKeyId()));
}
String contentType =
file.getContentType() == null
? MediaType.APPLICATION_OCTET_STREAM_VALUE
Expand Down
Loading