com.metaeffekt.artifact.analysis.flow.ng.crypt.EncryptedZipSupplier Maven / Gradle / Ivy
/*
* Copyright 2021-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.metaeffekt.artifact.analysis.flow.ng.crypt;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SequenceWriter;
import com.metaeffekt.artifact.analysis.flow.ng.DecryptableKeyslot;
import com.metaeffekt.artifact.analysis.flow.ng.EncryptedArchiveConstants;
import com.metaeffekt.artifact.analysis.flow.ng.crypt.param.SupplierParameters;
import com.metaeffekt.artifact.analysis.flow.ng.exception.SelfcheckFailedException;
import com.metaeffekt.artifact.analysis.flow.ng.keyholder.UserKeysForSupplier;
import com.metaeffekt.artifact.analysis.flow.ng.keyholder.UserKeysStorage;
import org.apache.commons.io.IOUtils;
import org.apache.commons.io.output.CloseShieldOutputStream;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.crypto.NoSuchPaddingException;
import java.io.*;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.CharsetDecoder;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.StandardOpenOption;
import java.security.*;
import java.util.*;
import java.util.stream.Collectors;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import static com.metaeffekt.artifact.analysis.flow.ng.EncryptedArchiveConstants.LICENSE_ENTRY_NAME;
/**
* Assists in producing encrypted archives, hiding low-level annoyances.
*/
public class EncryptedZipSupplier implements AutoCloseable {
private static final Logger LOG = LoggerFactory.getLogger(EncryptedZipSupplier.class);
private EncryptedEntryOutputStream currentEntry = null;
protected final SupplierParameters param;
protected final ZipOutputStream zipOutputStream;
protected final ContentEncryptionKey contentEncryptionKey;
/**
* Creates a new object. Handles all initialization with the given Parameters.
* @param param the parameters used in encryption.
* @throws IOException if the output file cannot be written to.
*/
public EncryptedZipSupplier(SupplierParameters param) throws IOException {
this.param = Objects.requireNonNull(param);
// we can fail early if we can't write to the target directory
if (!param.getZipOutputFile().getParentFile().isDirectory()) {
if (!param.getZipOutputFile().getParentFile().mkdirs()) {
LOG.error("Could not create directory [{}]", param.getZipOutputFile().getParentFile());
throw new RuntimeException("Could not create zip's output directory.");
}
}
// require some sort of license file to be included
if (!Files.isRegularFile(param.getLicenseTextFile().toPath())) {
LOG.error("The provided license text file is not a regular file.");
throw new IllegalArgumentException("Aborting early due to unreadable license text file.");
} else if (!param.getLicenseTextFile().canRead()) {
LOG.error("The provided license text file is not readable.");
throw new IllegalArgumentException("Aborting early due to unreadable license text file.");
}
// create the zip output stream
final OutputStream fileOutputStream =
Files.newOutputStream(param.getZipOutputFile().toPath(),
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING);
this.zipOutputStream = new ZipOutputStream(fileOutputStream);
// maximum compression for possibly smaller file sizes for unencrypted files like the keyslot file.
// the performance cost of this is most likely not important.
zipOutputStream.setMethod(ZipOutputStream.DEFLATED);
zipOutputStream.setLevel(9);
// create the master key, shared between all files in one zip package
this.contentEncryptionKey = new ContentEncryptionKey();
// write LICENSE.txt
writeLicense();
// write the keyslots file
writeKeyslots();
}
/**
* Reads license file contents from params and writes it to the encryptedf zip package.
* Be mindful before overriding this. It's beneficial to have some sort of license file in every zip.
* @throws IOException throws if reading or writing the license file failed.
*/
protected void writeLicense() throws IOException {
// begin license text file
zipOutputStream.putNextEntry(new ZipEntry(LICENSE_ENTRY_NAME));
// validate the file before writing it
CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder();
decoder.onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(CodingErrorAction.REPORT);
try (InputStream inputStream = Files.newInputStream(param.getLicenseTextFile().toPath())) {
byte[] bytes = IOUtils.toByteArray(inputStream);
// test decode with UTF-8 to make sure the provided license is valid UTF-8
CharBuffer chars = decoder.decode(ByteBuffer.wrap(bytes));
if (StringUtils.isBlank(chars)) {
LOG.warn("The provided license file appears to be blank!");
}
zipOutputStream.write(bytes);
}
zipOutputStream.closeEntry();
}
/**
* Creates a new archive entry with given name and returns a stream to said entry.
* Encryption is handled by this stream.
* @param entryName the name of the entry to be decrypt
* @return returns a stream that unencrypted data can be written to.
* @throws IOException throws if there was an issue writing (to) the entry.
* @throws InvalidAlgorithmParameterException throws if there was an issue writing (to) the entry.
* @throws NoSuchPaddingException throws if there was an issue writing (to) the entry.
* @throws NoSuchAlgorithmException throws if there was an issue writing (to) the entry.
* @throws NoSuchProviderException throws if there was an issue writing (to) the entry.
* @throws InvalidKeyException throws if there was an issue writing (to) the entry.
*/
public EncryptedEntryOutputStream getEncryptedEntryStream(String entryName) throws IOException, InvalidAlgorithmParameterException, NoSuchPaddingException, NoSuchAlgorithmException, NoSuchProviderException, InvalidKeyException {
if (currentEntry != null && !currentEntry.isClosed()) {
currentEntry.close();
}
// create the next entry
zipOutputStream.putNextEntry(new ZipEntry(entryName));
// wrap the current stream to escape ZipOutputStream's annoying api
currentEntry = EncryptedEntryOutputStream.createEncryptionOutputStream(
zipOutputStream,
param.getContentAlgorithmParam(),
contentEncryptionKey
);
return currentEntry;
}
/**
* Tried to read the supplier key twice.
* If the checksum is wrong, a warning is logged. It will then attempt to read and use the keys anyway.
* @param keyfile the keyfile containing a user key used in publishing.
* @return returns the read key.
* @throws RuntimeException throws RuntimeException if reading the user key failed twice.
*/
protected UserKeysForSupplier tryTwice(File keyfile) {
try {
try {
return UserKeysStorage.readUserKeysForPublisher(keyfile, true);
} catch (SelfcheckFailedException e) {
LOG.warn("Keyfile's selfcheck failed: possibly corrupt at [{}]", keyfile.getPath());
LOG.warn("trying to add the corrupt keyfile anyway... please investigate before release.");
return UserKeysStorage.readUserKeysForPublisher(keyfile, false);
}
} catch (IOException e) {
LOG.error("Could not read user key at '[{}]'", keyfile.getPath());
throw new RuntimeException("Error while trying to read user key at '" + keyfile.getPath() + "'.", e);
}
}
/**
* Attempts to load keys from json files in the specified directory.
*
* Should give some kind of feedback about which keys are being included, currently logs info to terminal.
*
* @param allowedUserKeysDir the directory to load from
* @return returns the list of loaded keys
*/
protected List loadUserKeys(File allowedUserKeysDir) {
File[] keyFiles = allowedUserKeysDir.listFiles(
(f) -> f != null
&& !f.isHidden()
);
if (keyFiles == null) {
return new ArrayList<>();
}
List keys = new ArrayList<>();
LOG.info("Loading user keys");
for (File keyfile : keyFiles) {
if (!keyfile.isFile()) {
LOG.warn("Ignoring non-file '" + keyfile.getPath() + "'.");
continue;
}
if (!keyfile.getName().endsWith(".json")) {
LOG.warn("Expected .json files for reading '" + UserKeysForSupplier.class.getName() + "' information.");
}
LOG.info("Granting access to user key: " + keyfile.getPath());
keys.add(tryTwice(keyfile));
}
return keys;
}
/**
* Validates set of keyslots against possible inconsistencies.
* These inconsistencies are an artifact of wanting to stream keyslots without state or indirection.
* This is more of a sanity check and should never be false with correct code.
* @param param required to get some data from to check against the actual state of things
* @param keyslots the keyslots to check
* @return true if everything is ok, false if there was an inconsistency.
*/
protected static boolean validateKeyslots(SupplierParameters param, List keyslots) {
if (keyslots == null || keyslots.isEmpty()) {
throw new IllegalArgumentException("Can't validate keyslots that don't exist...");
}
if (keyslots.size() != param.keyslotNum) {
return false;
}
// contentKeyLength is currently stored in every keyslot instead of in one location
// this is so that keyslots can be json-streamed more easily.
// this check ensures that no inconsistencies are introduced by this redundancy.
byte[] contentKeyLength = keyslots.get(0).getContentKeyLength();
int slotHmacLength = keyslots.get(0).getSlotHmac().length;
for (DecryptableKeyslot slot : keyslots) {
if (!Arrays.equals(contentKeyLength, slot.getContentKeyLength())) {
return false;
}
if (slot.getSlotHmac().length != slotHmacLength) {
return false;
}
}
// if we got here, none of the checks must have failed!
return true;
}
/**
* Creates all required keyslots using inputs from {@link #param}.
* @return returns a list of generated keyslots.
*/
private List createKeyslots() {
// preload all recipient (public and secret hmac) keys
List userKeysList = loadUserKeys(param.getAllowedUserKeysDir());
if (userKeysList.isEmpty()) {
LOG.warn("ZERO user keys have been loaded! The output will likely be useless.");
}
SecureRandom secureRandom = new SecureRandom();
// generate keyslots for recipient public keys
ArrayList keyslots = userKeysList.stream()
.map(k -> DecryptableKeyslot.createDecryptableKeyslot(contentEncryptionKey.getRaw(), k))
.collect(Collectors.toCollection(ArrayList::new));
// pad the slots out to the number given by SupplierParameters
if (keyslots.size() > param.keyslotNum) {
LOG.error("Privacy of the number of existing keys is revealed in index: generated more than keyslotNum.");
} else if (keyslots.size() == param.keyslotNum) {
LOG.warn("Keyslots EXACTLY fill the maximum allowed number! keyslotNum must be increased soon.");
} else {
while (keyslots.size() < param.keyslotNum) {
keyslots.add(DecryptableKeyslot.generateBogusKeyslot(secureRandom,
contentEncryptionKey.getLength()));
}
}
// shuffle all keyslots
Collections.shuffle(keyslots, secureRandom);
// validate keyslots before writing, sanity-checking for some basic inconsistencies
if (!validateKeyslots(param, keyslots)) {
throw new RuntimeException("Generated keyslots failed to validate");
}
return keyslots;
}
/**
* Writes a list of keyslots, creating them in the process.
* Notably uses {@link #param} and {@link #zipOutputStream} in the process
* @throws IOException throws on write error.
*/
private void writeKeyslots() throws IOException {
List keyslots = createKeyslots();
// put zip entry
zipOutputStream.putNextEntry(new ZipEntry(EncryptedArchiveConstants.KEYSLOT_ENTRY_NAME));
// write keyslots
// closeshield required to cleanly run closeEntry() which ZipOutputStream's close() doesn't do.
try (CloseShieldOutputStream closeShield = CloseShieldOutputStream.wrap(zipOutputStream);
final OutputStreamWriter writer = new OutputStreamWriter(closeShield, StandardCharsets.UTF_8);
final SequenceWriter seq = new ObjectMapper()
.writerFor(DecryptableKeyslot.class)
.withRootValueSeparator("\n")
.writeValues(writer)) {
seq.writeAll((Iterable) keyslots);
seq.flush();
// manually append another newline cause jackson doesn't properly terminate the last line
writer.write('\n');
}
zipOutputStream.closeEntry();
}
@Override
public void close() throws IOException{
// try closeEntry if it hasn't been called yet
zipOutputStream.close();
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy