Move OOB flow to new OobAssociationSecureChannel

Fixes: 158024972
Test: Unit tests and oob association succeeds

Change-Id: I2b3c70d830f9e4d182778c52c0ee187560d2cc22
diff --git a/connected-device-lib/src/com/android/car/connecteddevice/ble/AssociationSecureChannel.java b/connected-device-lib/src/com/android/car/connecteddevice/ble/AssociationSecureChannel.java
index a13e690..dbe0701 100644
--- a/connected-device-lib/src/com/android/car/connecteddevice/ble/AssociationSecureChannel.java
+++ b/connected-device-lib/src/com/android/car/connecteddevice/ble/AssociationSecureChannel.java
@@ -98,33 +98,20 @@
         logd(TAG, "Continuing handshake.");
         HandshakeMessage handshakeMessage = getEncryptionRunner().continueHandshake(message);
         mState = handshakeMessage.getHandshakeState();
-
-        switch (mState) {
-            case HandshakeState.VERIFICATION_NEEDED:
-                String code = handshakeMessage.getVerificationCode();
-                if (code == null) {
-                    loge(TAG, "Unable to get verification code.");
-                    notifySecureChannelFailure(CHANNEL_ERROR_INVALID_VERIFICATION);
-                    return;
-                }
-                processVerificationCode(code);
-                return;
-            case HandshakeState.OOB_VERIFICATION_NEEDED:
-                byte[] oobCode = handshakeMessage.getOobVerificationCode();
-                if (oobCode == null) {
-                    loge(TAG, "Unable to get out of band verification code.");
-                    notifySecureChannelFailure(CHANNEL_ERROR_INVALID_VERIFICATION);
-                    return;
-                }
-                processVerificationCode(new String(oobCode));
-                return;
-            default:
-                loge(TAG, "processHandshakeInProgress: Encountered unexpected handshake state: "
-                        + mState + ".");
-                notifySecureChannelFailure(CHANNEL_ERROR_INVALID_STATE);
+        if (mState != HandshakeState.VERIFICATION_NEEDED) {
+            loge(TAG, "processHandshakeInProgress: Encountered unexpected handshake state: "
+                    + mState + ".");
+            notifySecureChannelFailure(CHANNEL_ERROR_INVALID_STATE);
+            return;
         }
 
-
+        String code = handshakeMessage.getVerificationCode();
+        if (code == null) {
+            loge(TAG, "Unable to get verification code.");
+            notifySecureChannelFailure(CHANNEL_ERROR_INVALID_VERIFICATION);
+            return;
+        }
+        processVerificationCode(code);
     }
 
     private void processVerificationCode(@NonNull String code) {
@@ -217,6 +204,15 @@
         sendHandshakeMessage(ByteUtils.uuidToBytes(uniqueId), /* isEncrypted= */ true);
     }
 
+    @HandshakeState
+    int getState() {
+        return mState;
+    }
+
+    void setState(@HandshakeState int state) {
+        mState = state;
+    }
+
     /** Listener that will be invoked to display verification code. */
     interface ShowVerificationCodeListener {
         /**
diff --git a/connected-device-lib/src/com/android/car/connecteddevice/ble/CarBlePeripheralManager.java b/connected-device-lib/src/com/android/car/connecteddevice/ble/CarBlePeripheralManager.java
index ebe05dc..ef69664 100644
--- a/connected-device-lib/src/com/android/car/connecteddevice/ble/CarBlePeripheralManager.java
+++ b/connected-device-lib/src/com/android/car/connecteddevice/ble/CarBlePeripheralManager.java
@@ -16,11 +16,8 @@
 
 package com.android.car.connecteddevice.ble;
 
-import static android.car.encryptionrunner.EncryptionRunnerFactory.EncryptionRunnerType.OOB_UKEY2;
-
 import static com.android.car.connecteddevice.ConnectedDeviceManager.DEVICE_ERROR_INVALID_HANDSHAKE;
 import static com.android.car.connecteddevice.ConnectedDeviceManager.DEVICE_ERROR_UNEXPECTED_DISCONNECTION;
-import static com.android.car.connecteddevice.ble.SecureBleChannel.CHANNEL_ERROR_INVALID_HANDSHAKE;
 import static com.android.car.connecteddevice.util.SafeLog.logd;
 import static com.android.car.connecteddevice.util.SafeLog.loge;
 import static com.android.car.connecteddevice.util.SafeLog.logw;
@@ -35,7 +32,6 @@
 import android.bluetooth.le.AdvertiseCallback;
 import android.bluetooth.le.AdvertiseData;
 import android.bluetooth.le.AdvertiseSettings;
-import android.car.encryptionrunner.EncryptionRunnerFactory;
 import android.os.Handler;
 import android.os.Looper;
 import android.os.ParcelUuid;
@@ -48,8 +44,6 @@
 import com.android.car.connecteddevice.util.EventLog;
 import com.android.internal.annotations.VisibleForTesting;
 
-import java.security.InvalidAlgorithmParameterException;
-import java.security.InvalidKeyException;
 import java.time.Duration;
 import java.util.Arrays;
 import java.util.UUID;
@@ -58,9 +52,6 @@
 import java.util.concurrent.ScheduledFuture;
 import java.util.concurrent.TimeUnit;
 
-import javax.crypto.BadPaddingException;
-import javax.crypto.IllegalBlockSizeException;
-
 /**
  * Communication manager that allows for targeted connections to a specific device in the car.
  */
@@ -76,8 +67,6 @@
     // fails.
     private static final long ASSOCIATE_ADVERTISING_DELAY_MS = 10L;
 
-    private static final Duration OOB_CODE_EXCHANGE_TIMEOUT = Duration.ofSeconds(5);
-
     private static final UUID CLIENT_CHARACTERISTIC_CONFIG =
             UUID.fromString("00002902-0000-1000-8000-00805f9b34fb");
 
@@ -125,6 +114,8 @@
 
     private AdvertiseCallback mAdvertiseCallback;
 
+    private OobConnectionManager mOobConnectionManager;
+
     /**
      * Initialize a new instance of manager.
      *
@@ -212,6 +203,7 @@
         mConnectedDevices.clear();
         mReconnectDeviceId = null;
         mReconnectChallenge = null;
+        mOobConnectionManager = null;
     }
 
     /** Attempt to connect to device with provided id. */
@@ -335,86 +327,12 @@
         logd(TAG, "Starting out of band association.");
         reset();
         mAssociationCallback = callback;
+        mOobConnectionManager = oobConnectionManager;
         addConnectedDevice(bluetoothDevice, /* isReconnect= */ false, /* isOob= */ true);
         BleDevice connectedDevice = getConnectedDevice();
         if (connectedDevice == null || connectedDevice.mSecureChannel == null) {
             loge(TAG, "Connected device or secure channel are null");
-            return;
         }
-
-        logd(TAG, "Setting out of band verification listener.");
-        ((AssociationSecureChannel) connectedDevice.mSecureChannel)
-                .setShowVerificationCodeListener(
-                        code -> {
-                            logd(TAG, "onShowVerificationCode called for out of band exchange");
-                            if (!isAssociating()) {
-                                loge(TAG, "No valid callback for association.");
-                                return;
-                            }
-
-                            performOutOfBandVerification(code.getBytes(), connectedDevice,
-                                    oobConnectionManager);
-                        });
-    }
-
-    private void performOutOfBandVerification(byte[] code, BleDevice connectedDevice,
-            OobConnectionManager oobConnectionManager) {
-        byte[] encryptedCode;
-        try {
-            encryptedCode = oobConnectionManager.encryptVerificationCode(code);
-        } catch (InvalidKeyException | InvalidAlgorithmParameterException
-                | IllegalBlockSizeException | BadPaddingException e) {
-            loge(TAG, "Encryption failed for verification code exchange.", e);
-            connectedDevice.mSecureChannel.notifySecureChannelFailure(
-                    CHANNEL_ERROR_INVALID_HANDSHAKE);
-            return;
-        }
-
-        Handler handler = new Handler(Looper.getMainLooper());
-        SecureBleChannel.Callback oobExchangeCallback = new SecureBleChannel.Callback() {
-            @Override
-            public void onMessageReceived(DeviceMessage deviceMessage) {
-                handler.removeCallbacksAndMessages(null);
-                connectedDevice.mSecureChannel.unregisterCallback(this);
-                connectedDevice.mSecureChannel.registerCallback(mSecureChannelCallback);
-
-                byte[] serverEncryptedCode = deviceMessage.getMessage();
-                byte[] decryptedCode;
-                try {
-                    decryptedCode =
-                            oobConnectionManager.decryptVerificationCode(serverEncryptedCode);
-                } catch (InvalidKeyException | InvalidAlgorithmParameterException
-                        | IllegalBlockSizeException | BadPaddingException e) {
-                    loge(TAG, "Decryption failed for verification code exchange", e);
-                    connectedDevice.mSecureChannel.notifySecureChannelFailure(
-                            CHANNEL_ERROR_INVALID_HANDSHAKE);
-                    return;
-                }
-
-                if (!Arrays.equals(code, decryptedCode)) {
-                    loge(TAG, "Exchanged verification codes don't match. Code is "
-                            + new String(code) + "and decrypted code is "
-                            + new String(decryptedCode));
-                    connectedDevice.mSecureChannel.notifySecureChannelFailure(
-                            CHANNEL_ERROR_INVALID_HANDSHAKE);
-                }
-            }
-        };
-
-        connectedDevice.mSecureChannel.unregisterCallback(mSecureChannelCallback);
-        connectedDevice.mSecureChannel.registerCallback(oobExchangeCallback);
-
-        handler.postDelayed(() -> {
-            connectedDevice.mSecureChannel.unregisterCallback(oobExchangeCallback);
-            connectedDevice.mSecureChannel.registerCallback(mSecureChannelCallback);
-
-            loge(TAG, "Verification code exchange failed due to a timeout.");
-            connectedDevice.mSecureChannel.notifySecureChannelFailure(
-                    CHANNEL_ERROR_INVALID_HANDSHAKE);
-        }, OOB_CODE_EXCHANGE_TIMEOUT.toMillis());
-
-        connectedDevice.mSecureChannel.sendHandshakeMessage(encryptedCode,
-                /* isEncrypted= */ false);
     }
 
     private void attemptAssociationAdvertising(@NonNull String adapterName,
@@ -554,13 +472,12 @@
                     disconnectWithError("Error occurred in stream: " + exception.getMessage());
                 });
         SecureBleChannel secureChannel;
-        // TODO(b/157492943): Define an out of band version of ReconnectSecureChannel
         if (isReconnect) {
             secureChannel = new ReconnectSecureChannel(secureStream, mStorage, mReconnectDeviceId,
                     mReconnectChallenge);
         } else if (isOob) {
-            secureChannel = new AssociationSecureChannel(secureStream, mStorage,
-                    EncryptionRunnerFactory.newRunner(OOB_UKEY2));
+            secureChannel = new OobAssociationSecureChannel(secureStream, mStorage,
+                    mOobConnectionManager);
         } else {
             secureChannel = new AssociationSecureChannel(secureStream, mStorage);
         }
diff --git a/connected-device-lib/src/com/android/car/connecteddevice/ble/OobAssociationSecureChannel.java b/connected-device-lib/src/com/android/car/connecteddevice/ble/OobAssociationSecureChannel.java
new file mode 100644
index 0000000..d607f0b
--- /dev/null
+++ b/connected-device-lib/src/com/android/car/connecteddevice/ble/OobAssociationSecureChannel.java
@@ -0,0 +1,131 @@
+/*
+ * Copyright (C) 2020 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.car.connecteddevice.ble;
+
+import static android.car.encryptionrunner.HandshakeMessage.HandshakeState;
+
+import static com.android.car.connecteddevice.util.SafeLog.loge;
+
+import android.annotation.NonNull;
+import android.car.encryptionrunner.EncryptionRunner;
+import android.car.encryptionrunner.EncryptionRunnerFactory;
+import android.car.encryptionrunner.HandshakeException;
+import android.car.encryptionrunner.HandshakeMessage;
+
+import com.android.car.connecteddevice.oob.OobConnectionManager;
+import com.android.car.connecteddevice.storage.ConnectedDeviceStorage;
+
+import java.security.InvalidAlgorithmParameterException;
+import java.security.InvalidKeyException;
+import java.util.Arrays;
+
+import javax.crypto.BadPaddingException;
+import javax.crypto.IllegalBlockSizeException;
+
+/**
+ * A secure channel established with the association flow with an out-of-band verification.
+ */
+class OobAssociationSecureChannel extends AssociationSecureChannel {
+
+    private static final String TAG = "OobAssociationSecureChannel";
+
+    private final OobConnectionManager mOobConnectionManager;
+
+    private byte[] mOobCode;
+
+    OobAssociationSecureChannel(
+            BleDeviceMessageStream stream,
+            ConnectedDeviceStorage storage,
+            OobConnectionManager oobConnectionManager) {
+        this(stream, storage, oobConnectionManager, EncryptionRunnerFactory.newRunner(
+                EncryptionRunnerFactory.EncryptionRunnerType.OOB_UKEY2));
+    }
+
+    OobAssociationSecureChannel(
+            BleDeviceMessageStream stream,
+            ConnectedDeviceStorage storage,
+            OobConnectionManager oobConnectionManager,
+            EncryptionRunner encryptionRunner) {
+        super(stream, storage, encryptionRunner);
+        mOobConnectionManager = oobConnectionManager;
+    }
+
+    @Override
+    void processHandshake(@NonNull byte[] message) throws HandshakeException {
+        switch (getState()) {
+            case HandshakeState.IN_PROGRESS:
+                processHandshakeInProgress(message);
+                break;
+            case HandshakeState.OOB_VERIFICATION_NEEDED:
+                processHandshakeOobVerificationNeeded(message);
+                break;
+            default:
+                super.processHandshake(message);
+        }
+    }
+
+    private void processHandshakeInProgress(@NonNull byte[] message) throws HandshakeException {
+        HandshakeMessage handshakeMessage = getEncryptionRunner().continueHandshake(message);
+        setState(handshakeMessage.getHandshakeState());
+        int state = getState();
+        if (state != HandshakeState.OOB_VERIFICATION_NEEDED) {
+            loge(TAG, "processHandshakeInProgress: Encountered unexpected handshake state: "
+                    + state + ".");
+            notifySecureChannelFailure(CHANNEL_ERROR_INVALID_STATE);
+            return;
+        }
+
+        mOobCode = handshakeMessage.getOobVerificationCode();
+        if (mOobCode == null) {
+            loge(TAG, "Unable to get out of band verification code.");
+            notifySecureChannelFailure(CHANNEL_ERROR_INVALID_VERIFICATION);
+            return;
+        }
+
+        byte[] encryptedCode;
+        try {
+            encryptedCode = mOobConnectionManager.encryptVerificationCode(mOobCode);
+        } catch (InvalidKeyException | InvalidAlgorithmParameterException
+                | IllegalBlockSizeException | BadPaddingException e) {
+            loge(TAG, "Encryption failed for verification code exchange.", e);
+            notifySecureChannelFailure(CHANNEL_ERROR_INVALID_HANDSHAKE);
+            return;
+        }
+
+        sendHandshakeMessage(encryptedCode, /* isEncrypted= */ false);
+    }
+
+    private void processHandshakeOobVerificationNeeded(@NonNull byte[] message) {
+        byte[] decryptedCode;
+        try {
+            decryptedCode = mOobConnectionManager.decryptVerificationCode(message);
+        } catch (InvalidKeyException | InvalidAlgorithmParameterException
+                | IllegalBlockSizeException | BadPaddingException e) {
+            loge(TAG, "Decryption failed for verification code exchange", e);
+            notifySecureChannelFailure(CHANNEL_ERROR_INVALID_HANDSHAKE);
+            return;
+        }
+
+        if (!Arrays.equals(mOobCode, decryptedCode)) {
+            loge(TAG, "Exchanged verification codes do not match. Aborting secure channel.");
+            notifySecureChannelFailure(CHANNEL_ERROR_INVALID_VERIFICATION);
+            return;
+        }
+
+        notifyOutOfBandAccepted();
+    }
+}
diff --git a/connected-device-lib/tests/unit/src/com/android/car/connecteddevice/ble/AssociationSecureChannelTest.java b/connected-device-lib/tests/unit/src/com/android/car/connecteddevice/ble/AssociationSecureChannelTest.java
index d9cab5e..1a4941c 100644
--- a/connected-device-lib/tests/unit/src/com/android/car/connecteddevice/ble/AssociationSecureChannelTest.java
+++ b/connected-device-lib/tests/unit/src/com/android/car/connecteddevice/ble/AssociationSecureChannelTest.java
@@ -119,38 +119,6 @@
     }
 
     @Test
-    public void testEncryptionHandshake_oobAssociation() throws InterruptedException {
-        Semaphore semaphore = new Semaphore(0);
-        ChannelCallback callbackSpy = spy(new ChannelCallback(semaphore));
-        setupAssociationSecureChannel(callbackSpy, EncryptionRunnerFactory::newOobDummyRunner);
-        ArgumentCaptor<String> deviceIdCaptor = ArgumentCaptor.forClass(String.class);
-        ArgumentCaptor<DeviceMessage> messageCaptor =
-                ArgumentCaptor.forClass(DeviceMessage.class);
-
-        initHandshakeMessage();
-        verify(mStreamMock).writeMessage(messageCaptor.capture(), any());
-        byte[] response = messageCaptor.getValue().getMessage();
-        assertThat(response).isEqualTo(DummyEncryptionRunner.INIT_RESPONSE.getBytes());
-
-        respondToContinueMessage();
-        verify(mShowVerificationCodeListenerMock).showVerificationCode(anyString());
-
-        mChannel.notifyOutOfBandAccepted();
-        sendDeviceId();
-        assertThat(semaphore.tryAcquire(100, TimeUnit.MILLISECONDS)).isTrue();
-        verify(callbackSpy).onDeviceIdReceived(deviceIdCaptor.capture());
-        verify(mStreamMock, times(2)).writeMessage(messageCaptor.capture(), any());
-        byte[] deviceIdMessage = messageCaptor.getValue().getMessage();
-        assertThat(deviceIdMessage).isEqualTo(ByteUtils.uuidToBytes(SERVER_DEVICE_ID));
-        assertThat(deviceIdCaptor.getValue()).isEqualTo(CLIENT_DEVICE_ID.toString());
-        verify(mStorageMock).saveEncryptionKey(eq(CLIENT_DEVICE_ID.toString()), any());
-        verify(mStorageMock).saveChallengeSecret(CLIENT_DEVICE_ID.toString(), CLIENT_SECRET);
-
-        assertThat(semaphore.tryAcquire(100, TimeUnit.MILLISECONDS)).isTrue();
-        verify(callbackSpy).onSecureChannelEstablished();
-    }
-
-    @Test
     public void testEncryptionHandshake_Association_wrongInitHandshakeMessage()
             throws InterruptedException {
         Semaphore semaphore = new Semaphore(0);
diff --git a/connected-device-lib/tests/unit/src/com/android/car/connecteddevice/ble/CarBlePeripheralManagerTest.java b/connected-device-lib/tests/unit/src/com/android/car/connecteddevice/ble/CarBlePeripheralManagerTest.java
index 967caa0..431aa29 100644
--- a/connected-device-lib/tests/unit/src/com/android/car/connecteddevice/ble/CarBlePeripheralManagerTest.java
+++ b/connected-device-lib/tests/unit/src/com/android/car/connecteddevice/ble/CarBlePeripheralManagerTest.java
@@ -16,8 +16,6 @@
 
 package com.android.car.connecteddevice.ble;
 
-import static com.android.car.connecteddevice.ble.SecureBleChannel.CHANNEL_ERROR_INVALID_HANDSHAKE;
-
 import static com.google.common.truth.Truth.assertThat;
 
 import static org.mockito.ArgumentMatchers.any;
@@ -41,7 +39,6 @@
 import androidx.test.ext.junit.runners.AndroidJUnit4;
 
 import com.android.car.connecteddevice.AssociationCallback;
-import com.android.car.connecteddevice.BleStreamProtos.BleOperationProto;
 import com.android.car.connecteddevice.model.AssociatedDevice;
 import com.android.car.connecteddevice.oob.OobConnectionManager;
 import com.android.car.connecteddevice.storage.ConnectedDeviceStorage;
@@ -235,105 +232,6 @@
                 .stopAdvertising(any(AdvertiseCallback.class));
     }
 
-    @Test
-    public void startOutOfBandAssociation_success() throws Exception {
-        BluetoothDevice bluetoothDevice = BluetoothAdapter.getDefaultAdapter()
-                .getRemoteDevice(TEST_REMOTE_DEVICE_ADDRESS);
-
-        Semaphore semaphore = new Semaphore(0);
-        AssociationCallback callback = createAssociationCallback(semaphore);
-        mCarBlePeripheralManager.startOutOfBandAssociation(bluetoothDevice,
-                mMockOobConnectionManager, callback);
-
-        AssociationSecureChannel associationSecureChannel =
-                (AssociationSecureChannel) mCarBlePeripheralManager.getConnectedDeviceChannel();
-        associationSecureChannel.getShowVerificationCodeListener().showVerificationCode(
-                TEST_VERIFICATION_CODE);
-
-        ArgumentCaptor<byte[]> codeArgumentCaptor = ArgumentCaptor.forClass(byte[].class);
-        verify(mMockOobConnectionManager).encryptVerificationCode(codeArgumentCaptor.capture());
-        assertThat(codeArgumentCaptor.getValue()).isEqualTo(TEST_VERIFICATION_CODE.getBytes());
-
-        associationSecureChannel.onMessageReceived(
-                new DeviceMessage(null, false, TEST_ENCRYPTED_VERIFICATION_CODE.getBytes()),
-                BleOperationProto.OperationType.CLIENT_MESSAGE);
-
-        verify(mMockOobConnectionManager, timeout(100)).decryptVerificationCode(
-                codeArgumentCaptor.capture());
-        assertThat(codeArgumentCaptor.getValue()).isEqualTo(
-                TEST_ENCRYPTED_VERIFICATION_CODE.getBytes());
-
-        SecureBleChannel.Callback channelCallback = associationSecureChannel.getCallback();
-        assertThat(channelCallback).isNotNull();
-        channelCallback.onDeviceIdReceived(TEST_REMOTE_DEVICE_ID.toString());
-        channelCallback.onSecureChannelEstablished();
-        ArgumentCaptor<AssociatedDevice> deviceCaptor =
-                ArgumentCaptor.forClass(AssociatedDevice.class);
-        verify(mMockStorage).addAssociatedDeviceForActiveUser(deviceCaptor.capture());
-        AssociatedDevice device = deviceCaptor.getValue();
-        assertThat(device.getDeviceId()).isEqualTo(TEST_REMOTE_DEVICE_ID.toString());
-        assertThat(tryAcquire(semaphore)).isTrue();
-        verify(callback).onAssociationCompleted(eq(TEST_REMOTE_DEVICE_ID.toString()));
-    }
-
-    @Test
-    public void startOutOfBandAssociation_onInvalidAuthTokenReceived_secureChannelFailure()
-            throws Exception {
-        when(mMockOobConnectionManager.decryptVerificationCode(
-                TEST_ENCRYPTED_VERIFICATION_CODE.getBytes())).thenReturn("invalidCode".getBytes());
-        BluetoothDevice bluetoothDevice = BluetoothAdapter.getDefaultAdapter()
-                .getRemoteDevice(TEST_REMOTE_DEVICE_ADDRESS);
-
-        Semaphore semaphore = new Semaphore(0);
-        AssociationCallback callback = createAssociationCallback(semaphore);
-        mCarBlePeripheralManager.startOutOfBandAssociation(bluetoothDevice,
-                mMockOobConnectionManager, callback);
-
-        AssociationSecureChannel associationSecureChannel =
-                (AssociationSecureChannel) mCarBlePeripheralManager.getConnectedDeviceChannel();
-        associationSecureChannel.getShowVerificationCodeListener().showVerificationCode(
-                TEST_VERIFICATION_CODE);
-
-        ArgumentCaptor<byte[]> codeArgumentCaptor = ArgumentCaptor.forClass(byte[].class);
-        verify(mMockOobConnectionManager).encryptVerificationCode(codeArgumentCaptor.capture());
-        assertThat(codeArgumentCaptor.getValue()).isEqualTo(TEST_VERIFICATION_CODE.getBytes());
-
-        associationSecureChannel.onMessageReceived(
-                new DeviceMessage(null, false, TEST_ENCRYPTED_VERIFICATION_CODE.getBytes()),
-                BleOperationProto.OperationType.CLIENT_MESSAGE);
-
-        verify(mMockOobConnectionManager, timeout(100)).decryptVerificationCode(
-                codeArgumentCaptor.capture());
-        assertThat(codeArgumentCaptor.getValue()).isEqualTo(
-                TEST_ENCRYPTED_VERIFICATION_CODE.getBytes());
-
-        assertThat(tryAcquire(semaphore)).isTrue();
-        verify(callback).onAssociationError(CHANNEL_ERROR_INVALID_HANDSHAKE);
-    }
-
-    @Test
-    public void startOutOfBandAssociation_onTimeout_secureChannelFailure() throws Exception {
-        BluetoothDevice bluetoothDevice = BluetoothAdapter.getDefaultAdapter()
-                .getRemoteDevice(TEST_REMOTE_DEVICE_ADDRESS);
-
-        Semaphore semaphore = new Semaphore(0);
-        AssociationCallback callback = createAssociationCallback(semaphore);
-        mCarBlePeripheralManager.startOutOfBandAssociation(bluetoothDevice,
-                mMockOobConnectionManager, callback);
-
-        AssociationSecureChannel associationSecureChannel =
-                (AssociationSecureChannel) mCarBlePeripheralManager.getConnectedDeviceChannel();
-        associationSecureChannel.getShowVerificationCodeListener().showVerificationCode(
-                TEST_VERIFICATION_CODE);
-
-        ArgumentCaptor<byte[]> codeArgumentCaptor = ArgumentCaptor.forClass(byte[].class);
-        verify(mMockOobConnectionManager).encryptVerificationCode(codeArgumentCaptor.capture());
-        assertThat(codeArgumentCaptor.getValue()).isEqualTo(TEST_VERIFICATION_CODE.getBytes());
-
-        assertThat(semaphore.tryAcquire(5100, TimeUnit.MILLISECONDS)).isTrue();
-        verify(callback).onAssociationError(CHANNEL_ERROR_INVALID_HANDSHAKE);
-    }
-
     private BlePeripheralManager.Callback startAssociation(AssociationCallback callback,
             String deviceName) {
         ArgumentCaptor<BlePeripheralManager.Callback> callbackCaptor =
diff --git a/connected-device-lib/tests/unit/src/com/android/car/connecteddevice/ble/OobAssociationSecureChannelTest.java b/connected-device-lib/tests/unit/src/com/android/car/connecteddevice/ble/OobAssociationSecureChannelTest.java
new file mode 100644
index 0000000..d586dde
--- /dev/null
+++ b/connected-device-lib/tests/unit/src/com/android/car/connecteddevice/ble/OobAssociationSecureChannelTest.java
@@ -0,0 +1,228 @@
+/*
+ * Copyright (C) 2020 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.car.connecteddevice.ble;
+
+import static com.android.car.connecteddevice.BleStreamProtos.BleOperationProto.OperationType;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mockitoSession;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.car.encryptionrunner.DummyEncryptionRunner;
+import android.car.encryptionrunner.EncryptionRunnerFactory;
+
+import com.android.car.connecteddevice.oob.OobConnectionManager;
+import com.android.car.connecteddevice.storage.ConnectedDeviceStorage;
+import com.android.car.connecteddevice.util.ByteUtils;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.MockitoSession;
+import org.mockito.quality.Strictness;
+
+import java.security.InvalidAlgorithmParameterException;
+import java.security.InvalidKeyException;
+import java.util.UUID;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.TimeUnit;
+
+import javax.crypto.BadPaddingException;
+import javax.crypto.IllegalBlockSizeException;
+
+public class OobAssociationSecureChannelTest {
+    private static final UUID CLIENT_DEVICE_ID =
+            UUID.fromString("a5645523-3280-410a-90c1-582a6c6f4969");
+
+    private static final UUID SERVER_DEVICE_ID =
+            UUID.fromString("a29f0c74-2014-4b14-ac02-be6ed15b545a");
+
+    private static final byte[] CLIENT_SECRET = ByteUtils.randomBytes(32);
+
+    @Mock
+    private BleDeviceMessageStream mStreamMock;
+
+    @Mock
+    private ConnectedDeviceStorage mStorageMock;
+
+    @Mock
+    private OobConnectionManager mOobConnectionManagerMock;
+
+    private OobAssociationSecureChannel mChannel;
+
+    private BleDeviceMessageStream.MessageReceivedListener mMessageReceivedListener;
+
+    private MockitoSession mMockitoSession;
+
+    @Before
+    public void setUp() {
+        mMockitoSession = mockitoSession()
+                .initMocks(this)
+                .strictness(Strictness.WARN)
+                .startMocking();
+        when(mStorageMock.getUniqueId()).thenReturn(SERVER_DEVICE_ID);
+    }
+
+    @After
+    public void tearDown() {
+        if (mMockitoSession != null) {
+            mMockitoSession.finishMocking();
+        }
+    }
+
+    @Test
+    public void testEncryptionHandshake_oobAssociation() throws InterruptedException {
+        Semaphore semaphore = new Semaphore(0);
+        ChannelCallback
+                callbackSpy = spy(new ChannelCallback(semaphore));
+        setupOobAssociationSecureChannel(callbackSpy);
+        ArgumentCaptor<String> deviceIdCaptor = ArgumentCaptor.forClass(String.class);
+        ArgumentCaptor<DeviceMessage> messageCaptor =
+                ArgumentCaptor.forClass(DeviceMessage.class);
+
+        initHandshakeMessage();
+        verify(mStreamMock).writeMessage(messageCaptor.capture(), any());
+        byte[] response = messageCaptor.getValue().getMessage();
+        assertThat(response).isEqualTo(DummyEncryptionRunner.INIT_RESPONSE.getBytes());
+        reset(mStreamMock);
+        respondToContinueMessage();
+        verify(mStreamMock).writeMessage(messageCaptor.capture(), any());
+        byte[] oobCodeResponse = messageCaptor.getValue().getMessage();
+        assertThat(oobCodeResponse).isEqualTo(DummyEncryptionRunner.VERIFICATION_CODE.getBytes());
+        respondToOobCode();
+        sendDeviceId();
+        assertThat(semaphore.tryAcquire(100, TimeUnit.MILLISECONDS)).isTrue();
+        verify(callbackSpy).onDeviceIdReceived(deviceIdCaptor.capture());
+        verify(mStreamMock, times(2)).writeMessage(messageCaptor.capture(), any());
+        byte[] deviceIdMessage = messageCaptor.getValue().getMessage();
+        assertThat(deviceIdMessage).isEqualTo(ByteUtils.uuidToBytes(SERVER_DEVICE_ID));
+        assertThat(deviceIdCaptor.getValue()).isEqualTo(CLIENT_DEVICE_ID.toString());
+        verify(mStorageMock).saveEncryptionKey(eq(CLIENT_DEVICE_ID.toString()), any());
+        verify(mStorageMock).saveChallengeSecret(CLIENT_DEVICE_ID.toString(), CLIENT_SECRET);
+
+        assertThat(semaphore.tryAcquire(100, TimeUnit.MILLISECONDS)).isTrue();
+        verify(callbackSpy).onSecureChannelEstablished();
+    }
+
+    private void setupOobAssociationSecureChannel(ChannelCallback callback) {
+        mChannel = new OobAssociationSecureChannel(mStreamMock, mStorageMock,
+                mOobConnectionManagerMock, EncryptionRunnerFactory.newOobDummyRunner());
+        mChannel.registerCallback(callback);
+        ArgumentCaptor<BleDeviceMessageStream.MessageReceivedListener> listenerCaptor =
+                ArgumentCaptor.forClass(BleDeviceMessageStream.MessageReceivedListener.class);
+        verify(mStreamMock).setMessageReceivedListener(listenerCaptor.capture());
+        mMessageReceivedListener = listenerCaptor.getValue();
+        try {
+            when(mOobConnectionManagerMock.encryptVerificationCode(any()))
+                    .thenReturn(DummyEncryptionRunner.VERIFICATION_CODE.getBytes());
+        } catch (InvalidAlgorithmParameterException | BadPaddingException | InvalidKeyException
+                | IllegalBlockSizeException e) {
+        }
+        try {
+            when(mOobConnectionManagerMock.decryptVerificationCode(any()))
+                    .thenReturn(DummyEncryptionRunner.VERIFICATION_CODE.getBytes());
+        } catch (InvalidAlgorithmParameterException | BadPaddingException | InvalidKeyException
+                | IllegalBlockSizeException e) {
+        }
+    }
+
+    private void sendDeviceId() {
+        DeviceMessage message = new DeviceMessage(
+                /* recipient= */ null,
+                /* isMessageEncrypted= */ true,
+                ByteUtils.concatByteArrays(ByteUtils.uuidToBytes(CLIENT_DEVICE_ID), CLIENT_SECRET)
+        );
+        mMessageReceivedListener.onMessageReceived(message, OperationType.ENCRYPTION_HANDSHAKE);
+    }
+
+    private void initHandshakeMessage() {
+        DeviceMessage message = new DeviceMessage(
+                /* recipient= */ null,
+                /* isMessageEncrypted= */ false,
+                DummyEncryptionRunner.INIT.getBytes()
+        );
+        mMessageReceivedListener.onMessageReceived(message, OperationType.ENCRYPTION_HANDSHAKE);
+    }
+
+    private void respondToContinueMessage() {
+        DeviceMessage message = new DeviceMessage(
+                /* recipient= */ null,
+                /* isMessageEncrypted= */ false,
+                DummyEncryptionRunner.CLIENT_RESPONSE.getBytes()
+        );
+        mMessageReceivedListener.onMessageReceived(message, OperationType.ENCRYPTION_HANDSHAKE);
+    }
+
+    private void respondToOobCode() {
+        DeviceMessage message = new DeviceMessage(
+                /* recipient= */ null,
+                /* isMessageEncrypted= */ false,
+                DummyEncryptionRunner.VERIFICATION_CODE.getBytes()
+        );
+        mMessageReceivedListener.onMessageReceived(message, OperationType.ENCRYPTION_HANDSHAKE);
+    }
+
+    /**
+     * Add the thread control logic into {@link SecureBleChannel.Callback} only for spy purpose.
+     *
+     * <p>The callback will release the semaphore which hold by one test when this callback
+     * is called, telling the test that it can verify certain behaviors which will only occurred
+     * after the callback is notified. This is needed mainly because of the callback is notified
+     * in a different thread.
+     */
+    private static class ChannelCallback implements SecureBleChannel.Callback {
+        private final Semaphore mSemaphore;
+
+        ChannelCallback(Semaphore semaphore) {
+            mSemaphore = semaphore;
+        }
+
+        @Override
+        public void onSecureChannelEstablished() {
+            mSemaphore.release();
+        }
+
+        @Override
+        public void onEstablishSecureChannelFailure(int error) {
+            mSemaphore.release();
+        }
+
+        @Override
+        public void onMessageReceived(DeviceMessage deviceMessage) {
+            mSemaphore.release();
+        }
+
+        @Override
+        public void onMessageReceivedError(Exception exception) {
+            mSemaphore.release();
+        }
+
+        @Override
+        public void onDeviceIdReceived(String deviceId) {
+            mSemaphore.release();
+        }
+    }
+}