diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index 43f27eec2d..ffbacfee1b 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -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 { diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index fd9cc13c13..be93a4485f 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -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) diff --git a/app/proprietary/build.gradle b/app/proprietary/build.gradle index d2028f7859..ffe3cdc1e6 100644 --- a/app/proprietary/build.gradle +++ b/app/proprietary/build.gradle @@ -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' diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/config/StorageProviderConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/config/StorageProviderConfig.java index db063cda80..6bad8b487b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/config/StorageProviderConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/config/StorageProviderConfig.java @@ -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) { + return new EncryptingStorageProvider( + innerStorageProvider(), encryptionState, tempFileManager.orElse(null)); + } + + private StorageProvider innerStorageProvider() { boolean storageEnabled = applicationProperties.getStorage().isEnabled(); String providerName = Optional.ofNullable(applicationProperties.getStorage().getProvider()) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/EncryptedFileFormat.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/EncryptedFileFormat.java new file mode 100644 index 0000000000..389caa1e3c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/EncryptedFileFormat.java @@ -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: + * + *
+ * 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
+ * 
+ * + *

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); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/EncryptingStorageProvider.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/EncryptingStorageProvider.java new file mode 100644 index 0000000000..9a22dfd35d --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/EncryptingStorageProvider.java @@ -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. + * + *

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. + * + *

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 signedDownloadUrl(String storageKey, Duration ttl) throws IOException { + if (state.suppressDirectDownloads()) { + return Optional.empty(); + } + return delegate.signedDownloadUrl(storageKey, ttl); + } + + @Override + public Optional 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()); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/FileEncryptionKeyService.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/FileEncryptionKeyService.java new file mode 100644 index 0000000000..716d4f1a7b --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/FileEncryptionKeyService.java @@ -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). + * + *

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 unwrapCache = + Caffeine.newBuilder().expireAfterWrite(CACHE_TTL).maximumSize(10_000).build(); + private final Cache 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); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/FileEncryptionMasterKey.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/FileEncryptionMasterKey.java new file mode 100644 index 0000000000..025b281b79 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/FileEncryptionMasterKey.java @@ -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. + * + *

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 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"; + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/StorageEncryptionException.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/StorageEncryptionException.java new file mode 100644 index 0000000000..8db303dd42 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/StorageEncryptionException.java @@ -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); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/StorageEncryptionState.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/StorageEncryptionState.java new file mode 100644 index 0000000000..fe4539332a --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/StorageEncryptionState.java @@ -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. + * + *

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 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 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; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/StorageKeyRevokedException.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/StorageKeyRevokedException.java new file mode 100644 index 0000000000..a773a14860 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/crypto/StorageKeyRevokedException.java @@ -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); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileEncryptionKey.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileEncryptionKey.java new file mode 100644 index 0000000000..b58e8922c6 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileEncryptionKey.java @@ -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. + * + *

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; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFile.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFile.java index cbf5316db6..db80bd1e91 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFile.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFile.java @@ -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, diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/provider/StoredObject.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/provider/StoredObject.java index d58f223c75..d455d3f15c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/provider/StoredObject.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/provider/StoredObject.java @@ -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; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/FileEncryptionKeyRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/FileEncryptionKeyRepository.java new file mode 100644 index 0000000000..b6563f8c26 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/FileEncryptionKeyRepository.java @@ -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 { + + Optional findFirstByScopeTypeAndScopeIdAndStatus( + FileEncryptionKey.ScopeType scopeType, long scopeId, FileEncryptionKey.Status status); + + Optional findFirstByScopeTypeAndScopeIdOrderByKeyVersionDesc( + FileEncryptionKey.ScopeType scopeType, long scopeId); + + Optional findFirstByStatus(FileEncryptionKey.Status status); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/FileStorageService.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/FileStorageService.java index 32371222f6..9764f392b1 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/FileStorageService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/FileStorageService.java @@ -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: {})", diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java index 364dbf39ce..41aed7d9c2 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java @@ -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); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/storage/config/StorageProviderConfigTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/storage/config/StorageProviderConfigTest.java index ddd67db020..447354b5cb 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/storage/config/StorageProviderConfigTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/storage/config/StorageProviderConfigTest.java @@ -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); } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/storage/crypto/EncryptedFileFormatTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/storage/crypto/EncryptedFileFormatTest.java new file mode 100644 index 0000000000..89aad24587 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/storage/crypto/EncryptedFileFormatTest.java @@ -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); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/storage/crypto/EncryptingStorageProviderTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/storage/crypto/EncryptingStorageProviderTest.java new file mode 100644 index 0000000000..f66d03ae42 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/storage/crypto/EncryptingStorageProviderTest.java @@ -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 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(); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/storage/crypto/FileEncryptionKeyServiceDbTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/storage/crypto/FileEncryptionKeyServiceDbTest.java new file mode 100644 index 0000000000..e2b5be0186 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/storage/crypto/FileEncryptionKeyServiceDbTest.java @@ -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 {} +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/storage/crypto/FileEncryptionKeyServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/storage/crypto/FileEncryptionKeyServiceTest.java new file mode 100644 index 0000000000..10c0a1431d --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/storage/crypto/FileEncryptionKeyServiceTest.java @@ -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); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/storage/crypto/InMemoryKeyRepo.java b/app/proprietary/src/test/java/stirling/software/proprietary/storage/crypto/InMemoryKeyRepo.java new file mode 100644 index 0000000000..cc3828333a --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/storage/crypto/InMemoryKeyRepo.java @@ -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 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.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.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.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()); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/storage/service/FileStorageServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/storage/service/FileStorageServiceTest.java index 83d9111981..d02b04ae2b 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/storage/service/FileStorageServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/storage/service/FileStorageServiceTest.java @@ -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)); + } } diff --git a/build.gradle b/build.gradle index 8070d9db82..6aabb1a198 100644 --- a/build.gradle +++ b/build.gradle @@ -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