Skip to content

Commit

Permalink
[COMPRESS-633] Add encryption support for SevenZ (#332)
Browse files Browse the repository at this point in the history
* feat: Encyrption support for Seven7

Implementation of password-based encryption for 7z compressor

COMPRESS-633

* feat: Encyrption support for Seven7 without `AES/CBC/PKCS5Padding`

As `AES/CBC/PKCS5Padding` is raised as weak of security, a manual implementation to fill cither block size is done

COMPRESS-633

* feat: Encyrption support for SevenZ

- implementation without storing password in a clear way
- several corrections suggeested by reviewers

COMPRESS-633

* feat: Encyrption support for SevenZ

typo

COMPRESS-633

* feat: Encyrption support for SevenZ

Avoid incrasing the public API surface with uneccessary method

COMPRESS-633

* feat: Encyrption support for SevenZ

no IDE specifi config files

COMPRESS-633

* Fix spelling

* Update super class from master

* AES256Options does not need to be public

* Fix spelling in Javadoc

Co-authored-by: Gary Gregory <[email protected]>
  • Loading branch information
Dougniel and garydgregory authored Dec 10, 2022
1 parent f4eb199 commit f0d13f9
Show file tree
Hide file tree
Showing 6 changed files with 379 additions and 55 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ target
.classpath
.settings
.idea
.vscode
*.iml
*~
/.externalToolBuilders/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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 org.apache.commons.compress.archivers.sevenz;

import java.security.GeneralSecurityException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;

/**
* Options for {@link SevenZMethod#AES256SHA256} encoder
*
* @since 1.23
* @see AES256SHA256Decoder
*/
class AES256Options {

private final byte[] salt;
private final byte[] iv;
private final int numCyclesPower;
private final Cipher cipher;

/**
* @param password password used for encryption
*/
public AES256Options(char[] password) {
this(password, new byte[0], randomBytes(16), 19);
}

/**
* @param password password used for encryption
* @param salt for password hash salting (enforce password security)
* @param iv Initialization Vector (IV) used by cipher algorithm
* @param numCyclesPower another password security enforcer parameter that controls the cycles of password hashing. More the
* this number is high, more security you'll have but also high CPU usage
*/
public AES256Options(char[] password, byte[] salt, byte[] iv, int numCyclesPower) {
this.salt = salt;
this.iv = iv;
this.numCyclesPower = numCyclesPower;

// NOTE: for security purposes, password is wrapped in a Cipher as soon as possible to not stay in memory
final byte[] aesKeyBytes = AES256SHA256Decoder.sha256Password(password, numCyclesPower, salt);
final SecretKey aesKey = new SecretKeySpec(aesKeyBytes, "AES");

try {
cipher = Cipher.getInstance("AES/CBC/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, aesKey, new IvParameterSpec(iv));
} catch (final GeneralSecurityException generalSecurityException) {
throw new IllegalStateException(
"Encryption error (do you have the JCE Unlimited Strength Jurisdiction Policy Files installed?)",
generalSecurityException
);
}
}

byte[] getIv() {
return iv;
}

int getNumCyclesPower() {
return numCyclesPower;
}

byte[] getSalt() {
return salt;
}

Cipher getCipher() {
return cipher;
}

private static byte[] randomBytes(int size) {
byte[] bytes = new byte[size];
try {
SecureRandom.getInstanceStrong().nextBytes(bytes);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("No strong secure random available to generate strong AES key", e);
}
return bytes;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,32 @@
*/
package org.apache.commons.compress.archivers.sevenz;

import static java.nio.charset.StandardCharsets.UTF_16LE;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;

import javax.crypto.Cipher;
import javax.crypto.CipherInputStream;
import javax.crypto.CipherOutputStream;
import javax.crypto.SecretKey;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;

import org.apache.commons.compress.PasswordRequiredException;

class AES256SHA256Decoder extends AbstractCoder {

AES256SHA256Decoder() {
super(AES256Options.class);
}

@Override
InputStream decode(final String archiveName, final InputStream in, final long uncompressedLength,
Expand Down Expand Up @@ -73,26 +84,7 @@ private CipherInputStream init() throws IOException {
System.arraycopy(passwordBytes, 0, aesKeyBytes, saltSize,
Math.min(passwordBytes.length, aesKeyBytes.length - saltSize));
} else {
final MessageDigest digest;
try {
digest = MessageDigest.getInstance("SHA-256");
} catch (final NoSuchAlgorithmException noSuchAlgorithmException) {
throw new IOException("SHA-256 is unsupported by your Java implementation",
noSuchAlgorithmException);
}
final byte[] extra = new byte[8];
for (long j = 0; j < (1L << numCyclesPower); j++) {
digest.update(salt);
digest.update(passwordBytes);
digest.update(extra);
for (int k = 0; k < extra.length; k++) {
++extra[k];
if (extra[k] != 0) {
break;
}
}
}
aesKeyBytes = digest.digest();
aesKeyBytes = sha256Password(passwordBytes, numCyclesPower, salt);
}

final SecretKey aesKey = new SecretKeySpec(aesKeyBytes, "AES");
Expand All @@ -103,8 +95,8 @@ private CipherInputStream init() throws IOException {
isInitialized = true;
return cipherInputStream;
} catch (final GeneralSecurityException generalSecurityException) {
throw new IOException("Decryption error " +
"(do you have the JCE Unlimited Strength Jurisdiction Policy Files installed?)",
throw new IllegalStateException(
"Decryption error (do you have the JCE Unlimited Strength Jurisdiction Policy Files installed?)",
generalSecurityException);
}
}
Expand All @@ -127,4 +119,133 @@ public void close() throws IOException {
}
};
}

@Override
OutputStream encode(OutputStream out, Object options) throws IOException {
final AES256Options opts = (AES256Options) options;

return new OutputStream() {
private final CipherOutputStream cipherOutputStream = new CipherOutputStream(out, opts.getCipher());

// Ensures that data are encrypt in respect of cipher block size and pad with '0' if smaller
// NOTE: As "AES/CBC/PKCS5Padding" is weak and should not be used, we use "AES/CBC/NoPadding" with this
// manual implementation for padding possible thanks to the size of the file stored separately
private final int cipherBlockSize = opts.getCipher().getBlockSize();
private final byte[] cipherBlockBuffer = new byte[cipherBlockSize];
private int count = 0;

@Override
public void write(int b) throws IOException {
cipherBlockBuffer[count++] = (byte) b;
if (count == cipherBlockSize) {
flushBuffer();
}
}

@Override
public void write(byte[] b, int off, int len) throws IOException {
int gap = len + count > cipherBlockSize ? cipherBlockSize - count : len;
System.arraycopy(b, off, cipherBlockBuffer, count, gap);
count += gap;

if (count == cipherBlockSize) {
flushBuffer();

if (len - gap >= cipherBlockSize) {
// skip buffer to encrypt data chunks big enought to fit cipher block size
int multipleCipherBlockSizeLen = (len - gap) / cipherBlockSize * cipherBlockSize;
cipherOutputStream.write(b, off + gap, multipleCipherBlockSizeLen);
gap += multipleCipherBlockSizeLen;
}
System.arraycopy(b, off + gap, cipherBlockBuffer, 0, len - gap);
count = len - gap;
}
}

private void flushBuffer() throws IOException {
cipherOutputStream.write(cipherBlockBuffer);
count = 0;
Arrays.fill(cipherBlockBuffer, (byte) 0);
}

@Override
public void flush() throws IOException {
cipherOutputStream.flush();
}

@Override
public void close() throws IOException {
if (count > 0) {
cipherOutputStream.write(cipherBlockBuffer);
}
cipherOutputStream.close();
}
};
}

@Override
byte[] getOptionsAsProperties(Object options) throws IOException {
final AES256Options opts = (AES256Options) options;
final byte[] props = new byte[2 + opts.getSalt().length + opts.getIv().length];

// First byte : control (numCyclesPower + flags of salt or iv presence)
props[0] = (byte) (opts.getNumCyclesPower() | (opts.getSalt().length == 0 ? 0 : (1 << 7)) | (opts.getIv().length == 0 ? 0 : (1 << 6)));

if (opts.getSalt().length != 0 || opts.getIv().length != 0) {
// second byte : size of salt/iv data
props[1] = (byte) (((opts.getSalt().length == 0 ? 0 : opts.getSalt().length - 1) << 4) | (opts.getIv().length == 0 ? 0 : opts.getIv().length - 1));

// remain bytes : salt/iv data
System.arraycopy(opts.getSalt(), 0, props, 2, opts.getSalt().length);
System.arraycopy(opts.getIv(), 0, props, 2 + opts.getSalt().length, opts.getIv().length);
}

return props;
}

static byte[] sha256Password(final char[] password, final int numCyclesPower, final byte[] salt) {
return sha256Password(utf16Decode(password), numCyclesPower, salt);
}

static byte[] sha256Password(final byte[] password, final int numCyclesPower, final byte[] salt) {
final MessageDigest digest;
try {
digest = MessageDigest.getInstance("SHA-256");
} catch (final NoSuchAlgorithmException noSuchAlgorithmException) {
throw new IllegalStateException("SHA-256 is unsupported by your Java implementation", noSuchAlgorithmException);
}
final byte[] extra = new byte[8];
for (long j = 0; j < (1L << numCyclesPower); j++) {
digest.update(salt);
digest.update(password);
digest.update(extra);
for (int k = 0; k < extra.length; k++) {
++extra[k];
if (extra[k] != 0) {
break;
}
}
}
return digest.digest();
}

/**
* Convenience method that encodes Unicode characters into bytes in UTF-16 (ittle-endian byte order) charset
*
* @param chars characters to encode
* @return encoded characters
* @since 1.23
*/
static byte[] utf16Decode(final char[] chars) {
if (chars == null) {
return null;
}
final ByteBuffer encoded = UTF_16LE.encode(CharBuffer.wrap(chars));
if (encoded.hasArray()) {
return encoded.array();
}
final byte[] e = new byte[encoded.remaining()];
encoded.get(e);
return e;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.CharBuffer;
import java.nio.channels.Channels;
import java.nio.channels.SeekableByteChannel;
import java.nio.file.Files;
Expand Down Expand Up @@ -137,7 +136,7 @@ public SevenZFile(final File fileName, final char[] password) throws IOException
*/
public SevenZFile(final File fileName, final char[] password, final SevenZFileOptions options) throws IOException {
this(Files.newByteChannel(fileName.toPath(), EnumSet.of(StandardOpenOption.READ)), // NOSONAR
fileName.getAbsolutePath(), utf16Decode(password), true, options);
fileName.getAbsolutePath(), AES256SHA256Decoder.utf16Decode(password), true, options);
}

/**
Expand Down Expand Up @@ -256,7 +255,7 @@ public SevenZFile(final SeekableByteChannel channel, final String fileName,
*/
public SevenZFile(final SeekableByteChannel channel, final String fileName, final char[] password,
final SevenZFileOptions options) throws IOException {
this(channel, fileName, utf16Decode(password), false, options);
this(channel, fileName, AES256SHA256Decoder.utf16Decode(password), false, options);
}

/**
Expand Down Expand Up @@ -2056,19 +2055,6 @@ public String getDefaultName() {
return lastSegment + "~";
}

private static byte[] utf16Decode(final char[] chars) {
if (chars == null) {
return null;
}
final ByteBuffer encoded = UTF_16LE.encode(CharBuffer.wrap(chars));
if (encoded.hasArray()) {
return encoded.array();
}
final byte[] e = new byte[encoded.remaining()];
encoded.get(e);
return e;
}

private static int assertFitsIntoNonNegativeInt(final String what, final long value) throws IOException {
if (value > Integer.MAX_VALUE || value < 0) {
throw new IOException("Cannot handle " + what + " " + value);
Expand Down
Loading

0 comments on commit f0d13f9

Please sign in to comment.