All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.metaeffekt.artifact.analysis.flow.ng.crypt.EncryptedZipSupplier Maven / Gradle / Ivy

There is a newer version: 0.126.0
Show newest version
/*
 * 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