Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 84c66b8e authored by Bram Bonné's avatar Bram Bonné
Browse files

Ports second part of the chunking code from gmscore to AOSP.

Bug: 111386661
Test: atest RunFrameworksServicesRoboTests
Change-Id: I993adf481a22f862a3c5ffaa99723d8dbda679df
parent 8c12dcd1
Loading
Loading
Loading
Loading
+90 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2018 The Android Open Source Project
 *
 * 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.android.server.backup.encryption.chunking;

import com.android.server.backup.encryption.chunk.ChunkHash;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
import javax.crypto.IllegalBlockSizeException;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;

/** Encrypts chunks of a file using AES/GCM. */
public class ChunkEncryptor {
    private static final String CIPHER_ALGORITHM = "AES/GCM/NoPadding";
    private static final int GCM_NONCE_LENGTH_BYTES = 12;
    private static final int GCM_TAG_LENGTH_BYTES = 16;

    private final SecretKey mSecretKey;
    private final SecureRandom mSecureRandom;

    /**
     * A new instance using {@code mSecretKey} to encrypt chunks and {@code mSecureRandom} to
     * generate nonces.
     */
    public ChunkEncryptor(SecretKey secretKey, SecureRandom secureRandom) {
        this.mSecretKey = secretKey;
        this.mSecureRandom = secureRandom;
    }

    /**
     * Transforms {@code plaintext} into an {@link EncryptedChunk}.
     *
     * @param plaintextHash The hash of the plaintext to encrypt, to attach as the key of the chunk.
     * @param plaintext Bytes to encrypt.
     * @throws InvalidKeyException If the given secret key is not a valid AES key for decryption.
     * @throws IllegalBlockSizeException If the input data cannot be encrypted using
     *     AES/GCM/NoPadding. This should never be the case.
     */
    public EncryptedChunk encrypt(ChunkHash plaintextHash, byte[] plaintext)
            throws InvalidKeyException, IllegalBlockSizeException {
        byte[] nonce = generateNonce();
        Cipher cipher;
        try {
            cipher = Cipher.getInstance(CIPHER_ALGORITHM);
            cipher.init(
                    Cipher.ENCRYPT_MODE,
                    mSecretKey,
                    new GCMParameterSpec(GCM_TAG_LENGTH_BYTES * 8, nonce));
        } catch (NoSuchAlgorithmException
                | NoSuchPaddingException
                | InvalidAlgorithmParameterException e) {
            // This can not happen - AES/GCM/NoPadding is supported.
            throw new AssertionError(e);
        }
        byte[] encryptedBytes;
        try {
            encryptedBytes = cipher.doFinal(plaintext);
        } catch (BadPaddingException e) {
            // This can not happen - BadPaddingException can only be thrown in decrypt mode.
            throw new AssertionError("Impossible: threw BadPaddingException in encrypt mode.");
        }

        return EncryptedChunk.create(/*key=*/ plaintextHash, nonce, encryptedBytes);
    }

    private byte[] generateNonce() {
        byte[] nonce = new byte[GCM_NONCE_LENGTH_BYTES];
        mSecureRandom.nextBytes(nonce);
        return nonce;
    }
}
+47 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2018 The Android Open Source Project
 *
 * 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.android.server.backup.encryption.chunking;

import com.android.server.backup.encryption.chunk.ChunkHash;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import javax.crypto.Mac;
import javax.crypto.SecretKey;

/** Computes the SHA-256 HMAC of a chunk of bytes. */
public class ChunkHasher {
    private static final String MAC_ALGORITHM = "HmacSHA256";

    private final SecretKey mSecretKey;

    /** Constructs a new hasher which computes the HMAC using the given secret key. */
    public ChunkHasher(SecretKey secretKey) {
        this.mSecretKey = secretKey;
    }

    /** Returns the SHA-256 over the given bytes. */
    public ChunkHash computeHash(byte[] plaintext) throws InvalidKeyException {
        try {
            Mac mac = Mac.getInstance(MAC_ALGORITHM);
            mac.init(mSecretKey);
            return new ChunkHash(mac.doFinal(plaintext));
        } catch (NoSuchAlgorithmException e) {
            // This can not happen - AES/GCM/NoPadding is available as part of the framework.
            throw new AssertionError(e);
        }
    }
}
+46 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2018 The Android Open Source Project
 *
 * 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.android.server.backup.encryption.chunking;

import java.io.IOException;
import java.io.InputStream;
import java.security.GeneralSecurityException;

/** Splits an input stream into chunks, which are to be encrypted separately. */
public interface Chunker {
    /**
     * Splits the input stream into chunks.
     *
     * @param inputStream The input stream.
     * @param chunkConsumer A function that processes each chunk as it is produced.
     * @throws IOException If there is a problem reading the input stream.
     * @throws GeneralSecurityException if the consumer function throws an error.
     */
    void chunkify(InputStream inputStream, ChunkConsumer chunkConsumer)
            throws IOException, GeneralSecurityException;

    /** Function that consumes chunks. */
    interface ChunkConsumer {
        /**
         * Invoked for each chunk.
         *
         * @param chunk Plaintext bytes of chunk.
         * @throws GeneralSecurityException if there is an issue encrypting the chunk.
         */
        void accept(byte[] chunk) throws GeneralSecurityException;
    }
}
+52 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2018 The Android Open Source Project
 *
 * 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.android.server.backup.encryption.chunking;

import java.io.IOException;
import java.io.OutputStream;

/** Writes data straight to an output stream. */
public class RawBackupWriter implements BackupWriter {
    private final OutputStream outputStream;
    private long bytesWritten;

    /** Constructs a new writer which writes bytes to the given output stream. */
    public RawBackupWriter(OutputStream outputStream) {
        this.outputStream = outputStream;
    }

    @Override
    public void writeBytes(byte[] bytes) throws IOException {
        outputStream.write(bytes);
        bytesWritten += bytes.length;
    }

    @Override
    public void writeChunk(long start, int length) throws IOException {
        throw new UnsupportedOperationException("RawBackupWriter cannot write existing chunks");
    }

    @Override
    public long getBytesWritten() {
        return bytesWritten;
    }

    @Override
    public void flush() throws IOException {
        outputStream.flush();
    }
}
+154 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2018 The Android Open Source Project
 *
 * 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.android.server.backup.encryption.chunking;

import static com.android.server.backup.testing.CryptoTestUtils.generateAesKey;
import static com.google.common.truth.Truth.assertThat;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;

import android.platform.test.annotations.Presubmit;
import com.android.server.backup.encryption.chunk.ChunkHash;
import com.android.server.testing.FrameworkRobolectricTestRunner;
import com.android.server.testing.SystemLoaderPackages;
import java.security.SecureRandom;
import javax.crypto.Cipher;
import javax.crypto.Mac;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.invocation.InvocationOnMock;
import org.mockito.stubbing.Answer;
import org.robolectric.annotation.Config;

@RunWith(FrameworkRobolectricTestRunner.class)
@Config(manifest = Config.NONE, sdk = 26)
@SystemLoaderPackages({"com.android.server.backup"})
@Presubmit
public class ChunkEncryptorTest {
    private static final String MAC_ALGORITHM = "HmacSHA256";
    private static final String CIPHER_ALGORITHM = "AES/GCM/NoPadding";
    private static final int GCM_NONCE_LENGTH_BYTES = 12;
    private static final int GCM_TAG_LENGTH_BYTES = 16;
    private static final String CHUNK_PLAINTEXT =
            "A little Learning is a dang'rous Thing;\n"
                    + "Drink deep, or taste not the Pierian Spring:\n"
                    + "There shallow Draughts intoxicate the Brain,\n"
                    + "And drinking largely sobers us again.";
    private static final byte[] PLAINTEXT_BYTES = CHUNK_PLAINTEXT.getBytes(UTF_8);
    private static final byte[] NONCE_1 = "0123456789abc".getBytes(UTF_8);
    private static final byte[] NONCE_2 = "123456789abcd".getBytes(UTF_8);

    private static final byte[][] NONCES = new byte[][] {NONCE_1, NONCE_2};

    @Mock private SecureRandom mSecureRandomMock;
    private SecretKey mSecretKey;
    private ChunkHash mPlaintextHash;
    private ChunkEncryptor mChunkEncryptor;

    @Before
    public void setUp() throws Exception {
        mSecretKey = generateAesKey();
        ChunkHasher chunkHasher = new ChunkHasher(mSecretKey);
        mPlaintextHash = chunkHasher.computeHash(PLAINTEXT_BYTES);
        mSecureRandomMock = mock(SecureRandom.class);
        mChunkEncryptor = new ChunkEncryptor(mSecretKey, mSecureRandomMock);

        // Return NONCE_1, then NONCE_2 for invocations of mSecureRandomMock.nextBytes().
        doAnswer(
                        new Answer<Void>() {
                            private int mInvocation = 0;

                            @Override
                            public Void answer(InvocationOnMock invocation) {
                                byte[] nonceDestination = invocation.getArgument(0);
                                System.arraycopy(
                                        NONCES[this.mInvocation],
                                        0,
                                        nonceDestination,
                                        0,
                                        GCM_NONCE_LENGTH_BYTES);
                                this.mInvocation++;
                                return null;
                            }
                        })
                .when(mSecureRandomMock)
                .nextBytes(any(byte[].class));
    }

    @Test
    public void encrypt_withHash_resultContainsHashAsKey() throws Exception {
        EncryptedChunk chunk = mChunkEncryptor.encrypt(mPlaintextHash, PLAINTEXT_BYTES);

        assertThat(chunk.key()).isEqualTo(mPlaintextHash);
    }

    @Test
    public void encrypt_generatesHmacOfPlaintext() throws Exception {
        EncryptedChunk chunk = mChunkEncryptor.encrypt(mPlaintextHash, PLAINTEXT_BYTES);

        byte[] generatedHash = chunk.key().getHash();
        Mac mac = Mac.getInstance(MAC_ALGORITHM);
        mac.init(mSecretKey);
        byte[] plaintextHmac = mac.doFinal(PLAINTEXT_BYTES);
        assertThat(generatedHash).isEqualTo(plaintextHmac);
    }

    @Test
    public void encrypt_whenInvokedAgain_generatesNewNonce() throws Exception {
        EncryptedChunk chunk1 = mChunkEncryptor.encrypt(mPlaintextHash, PLAINTEXT_BYTES);

        EncryptedChunk chunk2 = mChunkEncryptor.encrypt(mPlaintextHash, PLAINTEXT_BYTES);

        assertThat(chunk1.nonce()).isNotEqualTo(chunk2.nonce());
    }

    @Test
    public void encrypt_whenInvokedAgain_generatesNewCiphertext() throws Exception {
        EncryptedChunk chunk1 = mChunkEncryptor.encrypt(mPlaintextHash, PLAINTEXT_BYTES);

        EncryptedChunk chunk2 = mChunkEncryptor.encrypt(mPlaintextHash, PLAINTEXT_BYTES);

        assertThat(chunk1.encryptedBytes()).isNotEqualTo(chunk2.encryptedBytes());
    }

    @Test
    public void encrypt_generates12ByteNonce() throws Exception {
        EncryptedChunk encryptedChunk = mChunkEncryptor.encrypt(mPlaintextHash, PLAINTEXT_BYTES);

        byte[] nonce = encryptedChunk.nonce();
        assertThat(nonce).hasLength(GCM_NONCE_LENGTH_BYTES);
    }

    @Test
    public void encrypt_decryptedResultCorrespondsToPlaintext() throws Exception {
        EncryptedChunk chunk = mChunkEncryptor.encrypt(mPlaintextHash, PLAINTEXT_BYTES);

        Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM);
        cipher.init(
                Cipher.DECRYPT_MODE,
                mSecretKey,
                new GCMParameterSpec(GCM_TAG_LENGTH_BYTES * 8, chunk.nonce()));
        byte[] decrypted = cipher.doFinal(chunk.encryptedBytes());
        assertThat(decrypted).isEqualTo(PLAINTEXT_BYTES);
    }
}
Loading