blob: 5c48a41c1b53d80b54622a870ed5df0035bd0fa5 [file] [log] [blame]
/*
* Copyright (C) 2021 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.microdroid.test;
import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.TruthJUnit.assume;
import static org.junit.Assume.assumeNoException;
import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;
import android.content.Context;
import android.os.Build;
import android.os.ParcelFileDescriptor;
import android.os.SystemProperties;
import android.sysprop.HypervisorProperties;
import android.system.virtualmachine.VirtualMachine;
import android.system.virtualmachine.VirtualMachineCallback;
import android.system.virtualmachine.VirtualMachineConfig;
import android.system.virtualmachine.VirtualMachineConfig.DebugLevel;
import android.system.virtualmachine.VirtualMachineException;
import android.system.virtualmachine.VirtualMachineManager;
import android.util.Log;
import androidx.annotation.CallSuper;
import androidx.test.core.app.ApplicationProvider;
import com.android.microdroid.testservice.ITestService;
import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.Timeout;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.RandomAccessFile;
import java.nio.file.Files;
import java.util.List;
import java.util.OptionalLong;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import co.nstant.in.cbor.CborDecoder;
import co.nstant.in.cbor.CborException;
import co.nstant.in.cbor.model.Array;
import co.nstant.in.cbor.model.DataItem;
import co.nstant.in.cbor.model.MajorType;
@RunWith(Parameterized.class)
public class MicrodroidTests {
private static final String TAG = "MicrodroidTests";
@Rule public Timeout globalTimeout = Timeout.seconds(300);
private static final String KERNEL_VERSION = SystemProperties.get("ro.kernel.version");
/** Copy output from the VM to logcat. This is helpful when things go wrong. */
private static void logVmOutput(InputStream vmOutputStream, String name) {
new Thread(() -> {
try {
BufferedReader reader = new BufferedReader(new InputStreamReader(vmOutputStream));
String line;
while ((line = reader.readLine()) != null && !Thread.interrupted()) {
Log.i(TAG, name + ": " + line);
}
} catch (Exception e) {
Log.w(TAG, name, e);
}
}).start();
}
private static class Inner {
public boolean mProtectedVm;
public Context mContext;
public VirtualMachineManager mVmm;
public VirtualMachine mVm;
Inner(boolean protectedVm) {
mProtectedVm = protectedVm;
}
/** Create a new VirtualMachineConfig.Builder with the parameterized protection mode. */
public VirtualMachineConfig.Builder newVmConfigBuilder(String payloadConfigPath) {
return new VirtualMachineConfig.Builder(mContext, payloadConfigPath)
.protectedVm(mProtectedVm);
}
}
@Parameterized.Parameters(name = "protectedVm={0}")
public static Object[] protectedVmConfigs() {
return new Object[] { false, true };
}
@Parameterized.Parameter
public boolean mProtectedVm;
private boolean mPkvmSupported = false;
private Inner mInner;
@Before
public void setup() {
// In case when the virt APEX doesn't exist on the device, classes in the
// android.system.virtualmachine package can't be loaded. Therefore, before using the
// classes, check the existence of a class in the package and skip this test if not exist.
try {
Class.forName("android.system.virtualmachine.VirtualMachineManager");
mPkvmSupported = true;
} catch (ClassNotFoundException e) {
assumeNoException(e);
return;
}
if (mProtectedVm) {
assume()
.withMessage("Skip where protected VMs aren't support")
.that(HypervisorProperties.hypervisor_protected_vm_supported().orElse(false))
.isTrue();
} else {
assume()
.withMessage("Skip where VMs aren't support")
.that(HypervisorProperties.hypervisor_vm_supported().orElse(false))
.isTrue();
}
mInner = new Inner(mProtectedVm);
mInner.mContext = ApplicationProvider.getApplicationContext();
mInner.mVmm = VirtualMachineManager.getInstance(mInner.mContext);
}
@After
public void cleanup() throws VirtualMachineException {
if (!mPkvmSupported) {
return;
}
if (mInner == null) {
return;
}
if (mInner.mVm == null) {
return;
}
mInner.mVm.stop();
mInner.mVm.delete();
}
private abstract static class VmEventListener implements VirtualMachineCallback {
private ExecutorService mExecutorService = Executors.newSingleThreadExecutor();
void runToFinish(VirtualMachine vm) throws VirtualMachineException, InterruptedException {
vm.setCallback(mExecutorService, this);
vm.run();
logVmOutput(vm.getConsoleOutputStream(), "Console");
logVmOutput(vm.getLogOutputStream(), "Log");
mExecutorService.awaitTermination(300, TimeUnit.SECONDS);
}
void forceStop(VirtualMachine vm) {
try {
vm.clearCallback();
vm.stop();
mExecutorService.shutdown();
} catch (VirtualMachineException e) {
throw new RuntimeException(e);
}
}
@Override
public void onPayloadStarted(VirtualMachine vm, ParcelFileDescriptor stream) {}
@Override
public void onPayloadReady(VirtualMachine vm) {}
@Override
public void onPayloadFinished(VirtualMachine vm, int exitCode) {}
@Override
public void onError(VirtualMachine vm, int errorCode, String message) {}
@Override
@CallSuper
public void onDied(VirtualMachine vm, @DeathReason int reason) {
mExecutorService.shutdown();
}
}
private static final int MIN_MEM_ARM64 = 150;
private static final int MIN_MEM_X86_64 = 196;
@Test
public void connectToVmService() throws VirtualMachineException, InterruptedException {
assume()
.withMessage("SKip on 5.4 kernel. b/218303240")
.that(KERNEL_VERSION)
.isNotEqualTo("5.4");
VirtualMachineConfig.Builder builder =
mInner.newVmConfigBuilder("assets/vm_config_extra_apk.json");
if (Build.SUPPORTED_ABIS.length > 0) {
String primaryAbi = Build.SUPPORTED_ABIS[0];
switch(primaryAbi) {
case "x86_64":
builder.memoryMib(MIN_MEM_X86_64);
break;
case "arm64-v8a":
builder.memoryMib(MIN_MEM_ARM64);
break;
}
}
VirtualMachineConfig config = builder.build();
mInner.mVm = mInner.mVmm.getOrCreate("test_vm_extra_apk", config);
class TestResults {
Exception mException;
Integer mAddInteger;
String mAppRunProp;
String mSublibRunProp;
String mExtraApkTestProp;
}
final CompletableFuture<Boolean> payloadStarted = new CompletableFuture<>();
final CompletableFuture<Boolean> payloadReady = new CompletableFuture<>();
final TestResults testResults = new TestResults();
VmEventListener listener =
new VmEventListener() {
private void testVMService(VirtualMachine vm) {
try {
ITestService testService = ITestService.Stub.asInterface(
vm.connectToVsockServer(ITestService.SERVICE_PORT).get());
testResults.mAddInteger = testService.addInteger(123, 456);
testResults.mAppRunProp =
testService.readProperty("debug.microdroid.app.run");
testResults.mSublibRunProp =
testService.readProperty("debug.microdroid.app.sublib.run");
testResults.mExtraApkTestProp =
testService.readProperty("debug.microdroid.test.extra_apk");
} catch (Exception e) {
testResults.mException = e;
}
}
@Override
public void onPayloadReady(VirtualMachine vm) {
Log.i(TAG, "onPayloadReady");
payloadReady.complete(true);
testVMService(vm);
forceStop(vm);
}
@Override
public void onPayloadStarted(VirtualMachine vm, ParcelFileDescriptor stream) {
Log.i(TAG, "onPayloadStarted");
payloadStarted.complete(true);
logVmOutput(new FileInputStream(stream.getFileDescriptor()), "Payload");
}
};
listener.runToFinish(mInner.mVm);
assertThat(payloadStarted.getNow(false)).isTrue();
assertThat(payloadReady.getNow(false)).isTrue();
assertThat(testResults.mException).isNull();
assertThat(testResults.mAddInteger).isEqualTo(123 + 456);
assertThat(testResults.mAppRunProp).isEqualTo("true");
assertThat(testResults.mSublibRunProp).isEqualTo("true");
assertThat(testResults.mExtraApkTestProp).isEqualTo("PASS");
}
@Test
public void changingDebugLevelInvalidatesVmIdentity()
throws VirtualMachineException, InterruptedException, IOException {
assume()
.withMessage("SKip on 5.4 kernel. b/218303240")
.that(KERNEL_VERSION)
.isNotEqualTo("5.4");
VirtualMachineConfig.Builder builder = mInner.newVmConfigBuilder("assets/vm_config.json");
VirtualMachineConfig normalConfig = builder.debugLevel(DebugLevel.NONE).build();
mInner.mVm = mInner.mVmm.getOrCreate("test_vm", normalConfig);
VmEventListener listener =
new VmEventListener() {
@Override
public void onPayloadReady(VirtualMachine vm) {
forceStop(vm);
}
};
listener.runToFinish(mInner.mVm);
// Launch the same VM with different debug level. The Java API prohibits this (thankfully).
// For testing, we do that by creating another VM with debug level, and copy the config file
// from the new VM directory to the old VM directory.
VirtualMachineConfig debugConfig = builder.debugLevel(DebugLevel.FULL).build();
VirtualMachine newVm = mInner.mVmm.getOrCreate("test_debug_vm", debugConfig);
File vmRoot = new File(mInner.mContext.getFilesDir(), "vm");
File newVmConfig = new File(new File(vmRoot, "test_debug_vm"), "config.xml");
File oldVmConfig = new File(new File(vmRoot, "test_vm"), "config.xml");
Files.copy(newVmConfig.toPath(), oldVmConfig.toPath(), REPLACE_EXISTING);
newVm.delete();
mInner.mVm = mInner.mVmm.get("test_vm"); // re-load with the copied-in config file.
final CompletableFuture<Boolean> payloadStarted = new CompletableFuture<>();
listener =
new VmEventListener() {
@Override
public void onPayloadStarted(VirtualMachine vm, ParcelFileDescriptor stream) {
payloadStarted.complete(true);
forceStop(vm);
}
};
listener.runToFinish(mInner.mVm);
assertThat(payloadStarted.getNow(false)).isFalse();
}
private class VmCdis {
public byte[] cdiAttest;
public byte[] cdiSeal;
}
private VmCdis launchVmAndGetCdis(String instanceName)
throws VirtualMachineException, InterruptedException {
VirtualMachineConfig normalConfig = mInner.newVmConfigBuilder("assets/vm_config.json")
.debugLevel(DebugLevel.NONE)
.build();
mInner.mVm = mInner.mVmm.getOrCreate(instanceName, normalConfig);
final VmCdis vmCdis = new VmCdis();
final CompletableFuture<Exception> exception = new CompletableFuture<>();
VmEventListener listener =
new VmEventListener() {
@Override
public void onPayloadReady(VirtualMachine vm) {
try {
ITestService testService = ITestService.Stub.asInterface(
vm.connectToVsockServer(ITestService.SERVICE_PORT).get());
vmCdis.cdiAttest = testService.insecurelyExposeAttestationCdi();
vmCdis.cdiSeal = testService.insecurelyExposeSealingCdi();
forceStop(vm);
} catch (Exception e) {
exception.complete(e);
}
}
};
listener.runToFinish(mInner.mVm);
assertThat(exception.getNow(null)).isNull();
return vmCdis;
}
@Test
public void instancesOfSameVmHaveDifferentCdis()
throws VirtualMachineException, InterruptedException {
assume()
.withMessage("SKip on 5.4 kernel. b/218303240")
.that(KERNEL_VERSION)
.isNotEqualTo("5.4");
VmCdis vm_a_cdis = launchVmAndGetCdis("test_vm_a");
VmCdis vm_b_cdis = launchVmAndGetCdis("test_vm_b");
assertThat(vm_a_cdis.cdiAttest).isNotNull();
assertThat(vm_b_cdis.cdiAttest).isNotNull();
assertThat(vm_a_cdis.cdiAttest).isNotEqualTo(vm_b_cdis.cdiAttest);
assertThat(vm_a_cdis.cdiSeal).isNotNull();
assertThat(vm_b_cdis.cdiSeal).isNotNull();
assertThat(vm_a_cdis.cdiSeal).isNotEqualTo(vm_b_cdis.cdiSeal);
assertThat(vm_a_cdis.cdiAttest).isNotEqualTo(vm_b_cdis.cdiSeal);
}
@Test
public void sameInstanceKeepsSameCdis()
throws VirtualMachineException, InterruptedException {
assume()
.withMessage("SKip on 5.4 kernel. b/218303240")
.that(KERNEL_VERSION)
.isNotEqualTo("5.4");
VmCdis first_boot_cdis = launchVmAndGetCdis("test_vm");
VmCdis second_boot_cdis = launchVmAndGetCdis("test_vm");
// The attestation CDI isn't specified to be stable, though it might be
assertThat(first_boot_cdis.cdiSeal).isNotNull();
assertThat(second_boot_cdis.cdiSeal).isNotNull();
assertThat(first_boot_cdis.cdiSeal).isEqualTo(second_boot_cdis.cdiSeal);
}
@Test
public void bccIsSuperficiallyWellFormed()
throws VirtualMachineException, InterruptedException, CborException {
assume()
.withMessage("SKip on 5.4 kernel. b/218303240")
.that(KERNEL_VERSION)
.isNotEqualTo("5.4");
VirtualMachineConfig normalConfig = mInner.newVmConfigBuilder("assets/vm_config.json")
.debugLevel(DebugLevel.NONE)
.build();
mInner.mVm = mInner.mVmm.getOrCreate("bcc_vm", normalConfig);
final VmCdis vmCdis = new VmCdis();
final CompletableFuture<byte[]> bcc = new CompletableFuture<>();
final CompletableFuture<Exception> exception = new CompletableFuture<>();
VmEventListener listener =
new VmEventListener() {
@Override
public void onPayloadReady(VirtualMachine vm) {
try {
ITestService testService = ITestService.Stub.asInterface(
vm.connectToVsockServer(ITestService.SERVICE_PORT).get());
bcc.complete(testService.getBcc());
forceStop(vm);
} catch (Exception e) {
exception.complete(e);
}
}
};
listener.runToFinish(mInner.mVm);
byte[] bccBytes = bcc.getNow(null);
assertThat(exception.getNow(null)).isNull();
assertThat(bccBytes).isNotNull();
ByteArrayInputStream bais = new ByteArrayInputStream(bccBytes);
List<DataItem> dataItems = new CborDecoder(bais).decode();
assertThat(dataItems.size()).isEqualTo(1);
assertThat(dataItems.get(0).getMajorType()).isEqualTo(MajorType.ARRAY);
List<DataItem> rootArrayItems = ((Array) dataItems.get(0)).getDataItems();
assertThat(rootArrayItems.size()).isAtLeast(2); // Public key and one certificate
if (mProtectedVm) {
// When a true BCC is created, microdroid expects entries for at least: the root public
// key, pvmfw, u-boot, u-boot-env, microdroid, app payload and the service process.
assertThat(rootArrayItems.size()).isAtLeast(7);
}
}
private static final UUID MICRODROID_PARTITION_UUID =
UUID.fromString("cf9afe9a-0662-11ec-a329-c32663a09d75");
private static final UUID U_BOOT_AVB_PARTITION_UUID =
UUID.fromString("7e8221e7-03e6-4969-948b-73a4c809a4f2");
private static final UUID U_BOOT_ENV_PARTITION_UUID =
UUID.fromString("0ab72d30-86ae-4d05-81b2-c1760be2b1f9");
private static final UUID PVM_FW_PARTITION_UUID =
UUID.fromString("90d2174a-038a-4bc6-adf3-824848fc5825");
private static final long BLOCK_SIZE = 512;
// Find the starting offset which holds the data of a partition having UUID.
// This is a kind of hack; rather than parsing QCOW2 we exploit the fact that the cluster size
// is normally greater than 512. It implies that the partition data should exist at a block
// which follows the header block
private OptionalLong findPartitionDataOffset(RandomAccessFile file, UUID uuid)
throws IOException {
// For each 512-byte block in file, check header
long fileSize = file.length();
for (long idx = 0; idx + BLOCK_SIZE < fileSize; idx += BLOCK_SIZE) {
file.seek(idx);
long high = file.readLong();
long low = file.readLong();
if (uuid.equals(new UUID(high, low))) return OptionalLong.of(idx + BLOCK_SIZE);
}
return OptionalLong.empty();
}
private void flipBit(RandomAccessFile file, long offset) throws IOException {
file.seek(offset);
int b = file.readByte();
file.seek(offset);
file.writeByte(b ^ 1);
}
private boolean tryBootVm(String vmName)
throws VirtualMachineException, InterruptedException {
mInner.mVm = mInner.mVmm.get(vmName); // re-load the vm before running tests
final CompletableFuture<Boolean> payloadStarted = new CompletableFuture<>();
VmEventListener listener =
new VmEventListener() {
@Override
public void onPayloadStarted(VirtualMachine vm, ParcelFileDescriptor stream) {
payloadStarted.complete(true);
forceStop(vm);
}
};
listener.runToFinish(mInner.mVm);
return payloadStarted.getNow(false);
}
private RandomAccessFile prepareInstanceImage(String vmName)
throws VirtualMachineException, InterruptedException, IOException {
VirtualMachineConfig config = mInner.newVmConfigBuilder("assets/vm_config.json")
.debugLevel(DebugLevel.NONE)
.build();
// Remove any existing VM so we can start from scratch
VirtualMachine oldVm = mInner.mVmm.getOrCreate(vmName, config);
oldVm.delete();
mInner.mVmm.getOrCreate(vmName, config);
assertThat(tryBootVm(vmName)).isTrue();
File vmRoot = new File(mInner.mContext.getFilesDir(), "vm");
File vmDir = new File(vmRoot, vmName);
File instanceImgPath = new File(vmDir, "instance.img");
return new RandomAccessFile(instanceImgPath, "rw");
}
private void assertThatPartitionIsMissing(UUID partitionUuid)
throws VirtualMachineException, InterruptedException, IOException {
RandomAccessFile instanceFile = prepareInstanceImage("test_vm_integrity");
assertThat(findPartitionDataOffset(instanceFile, partitionUuid).isPresent())
.isFalse();
}
// Flips a bit of given partition, and then see if boot fails.
private void assertThatBootFailsAfterCompromisingPartition(UUID partitionUuid)
throws VirtualMachineException, InterruptedException, IOException {
RandomAccessFile instanceFile = prepareInstanceImage("test_vm_integrity");
OptionalLong offset = findPartitionDataOffset(instanceFile, partitionUuid);
assertThat(offset.isPresent()).isTrue();
flipBit(instanceFile, offset.getAsLong());
assertThat(tryBootVm("test_vm_integrity")).isFalse();
}
@Test
public void bootFailsWhenMicrodroidDataIsCompromised()
throws VirtualMachineException, InterruptedException, IOException {
assertThatBootFailsAfterCompromisingPartition(MICRODROID_PARTITION_UUID);
}
@Test
public void bootFailsWhenUBootAvbDataIsCompromised()
throws VirtualMachineException, InterruptedException, IOException {
if (mProtectedVm) {
assertThatBootFailsAfterCompromisingPartition(U_BOOT_AVB_PARTITION_UUID);
} else {
// non-protected VM shouldn't have u-boot avb data
assertThatPartitionIsMissing(U_BOOT_AVB_PARTITION_UUID);
}
}
@Test
public void bootFailsWhenUBootEnvDataIsCompromised()
throws VirtualMachineException, InterruptedException, IOException {
if (mProtectedVm) {
assertThatBootFailsAfterCompromisingPartition(U_BOOT_ENV_PARTITION_UUID);
} else {
// non-protected VM shouldn't have u-boot env data
assertThatPartitionIsMissing(U_BOOT_ENV_PARTITION_UUID);
}
}
@Test
public void bootFailsWhenPvmFwDataIsCompromised()
throws VirtualMachineException, InterruptedException, IOException {
if (mProtectedVm) {
assertThatBootFailsAfterCompromisingPartition(PVM_FW_PARTITION_UUID);
} else {
// non-protected VM shouldn't have pvmfw data
assertThatPartitionIsMissing(PVM_FW_PARTITION_UUID);
}
}
}