[Thread][API] add API for ephemeral key.

This CL adds the API for starting/stopping the ephemeral key mode, and
subscribing to the ephemeral key state changes.

Ephemeral key mode is started with a limit amount of time to allow
Thread credential sharing.

Bug: 348323500
Test: atest CtsThreadNetworkTestCases
Change-Id: I540c88ec4668291358fbe87a678d8f9d31a67e54
diff --git a/thread/framework/java/android/net/thread/IStateCallback.aidl b/thread/framework/java/android/net/thread/IStateCallback.aidl
index 9d0a571..57c365b 100644
--- a/thread/framework/java/android/net/thread/IStateCallback.aidl
+++ b/thread/framework/java/android/net/thread/IStateCallback.aidl
@@ -23,4 +23,6 @@
     void onDeviceRoleChanged(int deviceRole);
     void onPartitionIdChanged(long partitionId);
     void onThreadEnableStateChanged(int enabledState);
+    void onEphemeralKeyStateChanged(
+            int ephemeralKeyState, @nullable String ephemeralKey, long expiryMillis);
 }
diff --git a/thread/framework/java/android/net/thread/IThreadNetworkController.aidl b/thread/framework/java/android/net/thread/IThreadNetworkController.aidl
index b7f68c9..e9cbb83 100644
--- a/thread/framework/java/android/net/thread/IThreadNetworkController.aidl
+++ b/thread/framework/java/android/net/thread/IThreadNetworkController.aidl
@@ -53,4 +53,7 @@
     void setConfiguration(in ThreadConfiguration config, in IOperationReceiver receiver);
     void registerConfigurationCallback(in IConfigurationReceiver receiver);
     void unregisterConfigurationCallback(in IConfigurationReceiver receiver);
+
+    void activateEphemeralKeyMode(long lifetimeMillis, in IOperationReceiver receiver);
+    void deactivateEphemeralKeyMode(in IOperationReceiver receiver);
 }
diff --git a/thread/framework/java/android/net/thread/ThreadNetworkController.java b/thread/framework/java/android/net/thread/ThreadNetworkController.java
index cb4e8de..1222398 100644
--- a/thread/framework/java/android/net/thread/ThreadNetworkController.java
+++ b/thread/framework/java/android/net/thread/ThreadNetworkController.java
@@ -40,6 +40,7 @@
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 import java.time.Duration;
+import java.time.Instant;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.concurrent.Executor;
@@ -82,6 +83,25 @@
     /** The Thread radio is being disabled. */
     public static final int STATE_DISABLING = 2;
 
+    /** The ephemeral key mode is disabled. */
+    @FlaggedApi(Flags.FLAG_EPSKC_ENABLED)
+    public static final int EPHEMERAL_KEY_DISABLED = 0;
+
+    /**
+     * The ephemeral key mode is enabled, an external commissioner candidate can use the ephemeral
+     * key to connect to this device and get Thread credential shared.
+     */
+    @FlaggedApi(Flags.FLAG_EPSKC_ENABLED)
+    public static final int EPHEMERAL_KEY_ENABLED = 1;
+
+    /**
+     * The ephemeral key is in use. This state means there is already an active secure session
+     * connected to this device with the ephemeral key, it's not possible to use the ephemeral key
+     * for new connections in this state.
+     */
+    @FlaggedApi(Flags.FLAG_EPSKC_ENABLED)
+    public static final int EPHEMERAL_KEY_IN_USE = 2;
+
     /** @hide */
     @Retention(RetentionPolicy.SOURCE)
     @IntDef({
@@ -100,6 +120,13 @@
             value = {STATE_DISABLED, STATE_ENABLED, STATE_DISABLING})
     public @interface EnabledState {}
 
+    /** @hide */
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef(
+            prefix = {"EPHEMERAL_KEY_"},
+            value = {EPHEMERAL_KEY_DISABLED, EPHEMERAL_KEY_ENABLED, EPHEMERAL_KEY_IN_USE})
+    public @interface EphemeralKeyState {}
+
     /** Thread standard version 1.3. */
     public static final int THREAD_VERSION_1_3 = 4;
 
@@ -110,6 +137,9 @@
     @SuppressLint("MinMaxConstant")
     public static final int MAX_POWER_CHANNEL_DISABLED = Integer.MIN_VALUE;
 
+    /** The maximum lifetime of an ephemeral key. @hide */
+    @NonNull private static final Duration EPHEMERAL_KEY_LIFETIME_MAX = Duration.ofMinutes(10);
+
     /** @hide */
     @Retention(RetentionPolicy.SOURCE)
     @IntDef({THREAD_VERSION_1_3})
@@ -174,6 +204,87 @@
         }
     }
 
+    /** Returns the maximum lifetime allowed when activating ephemeral key mode. */
+    @FlaggedApi(Flags.FLAG_EPSKC_ENABLED)
+    @NonNull
+    public Duration getMaxEphemeralKeyLifetime() {
+        return EPHEMERAL_KEY_LIFETIME_MAX;
+    }
+
+    /**
+     * Activates ephemeral key mode with a given {@code lifetime}. The ephemeral key is a temporary,
+     * single-use numeric code that is used for Thread Administration Sharing. After activation, the
+     * mode may expire or get deactivated, caller to this method should subscribe to the ephemeral
+     * key state updates with {@link #registerStateCallback} to get notified when the ephemeral key
+     * state changes.
+     *
+     * <p>On success, {@link OutcomeReceiver#onResult} of {@code receiver} is called. The ephemeral
+     * key string contains a sequence of numeric digits 0-9 of user-input friendly length (typically
+     * 9). Subscribers to ephemeral key state updates with {@link #registerStateCallback} will be
+     * notified with a call to {@link #onEphemeralKeyStateChanged}.
+     *
+     * <p>On failure, {@link OutcomeReceiver#onError} of {@code receiver} will be invoked with a
+     * specific error:
+     *
+     * <ul>
+     *   <li>{@link ThreadNetworkException#ERROR_FAILED_PRECONDITION} when this device is not
+     *       attached to Thread network
+     *   <li>{@link ThreadNetworkException#ERROR_BUSY} when ephemeral key mode is already activated
+     *       on the device, caller can recover from this error when the ephemeral key mode gets
+     *       deactivated
+     * </ul>
+     *
+     * @param lifetime valid lifetime of the generated ephemeral key, should be larger than {@link
+     *     Duration#ZERO} and at most the duration returned by {@link #getMaxEphemeralKeyLifetime}.
+     * @param executor the executor on which to execute {@code receiver}
+     * @param receiver the receiver to receive the result of this operation
+     * @throws IllegalArgumentException if the {@code lifetime} exceeds the allowed range
+     */
+    @FlaggedApi(Flags.FLAG_EPSKC_ENABLED)
+    @RequiresPermission("android.permission.THREAD_NETWORK_PRIVILEGED")
+    public void activateEphemeralKeyMode(
+            @NonNull Duration lifetime,
+            @NonNull @CallbackExecutor Executor executor,
+            @NonNull OutcomeReceiver<Void, ThreadNetworkException> receiver) {
+        if (lifetime.compareTo(Duration.ZERO) <= 0
+                || lifetime.compareTo(EPHEMERAL_KEY_LIFETIME_MAX) > 0) {
+            throw new IllegalArgumentException(
+                    "Invalid ephemeral key lifetime: the value must be in range of (0, "
+                            + EPHEMERAL_KEY_LIFETIME_MAX
+                            + "]");
+        }
+        long lifetimeMillis = lifetime.toMillis();
+        try {
+            mControllerService.activateEphemeralKeyMode(
+                    lifetimeMillis, new OperationReceiverProxy(executor, receiver));
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
+    /**
+     * Deactivates ephemeral key mode. If there is an active connection with the ephemeral key, the
+     * connection will be terminated.
+     *
+     * <p>On success, {@link OutcomeReceiver#onResult} of {@code receiver} is called. The call will
+     * always succeed if the device is not in ephemeral key mode.
+     *
+     * @param executor the executor to execute {@code receiver}
+     * @param receiver the receiver to receive the result of this operation
+     */
+    @FlaggedApi(Flags.FLAG_EPSKC_ENABLED)
+    @RequiresPermission("android.permission.THREAD_NETWORK_PRIVILEGED")
+    public void deactivateEphemeralKeyMode(
+            @NonNull @CallbackExecutor Executor executor,
+            @NonNull OutcomeReceiver<Void, ThreadNetworkException> receiver) {
+        try {
+            mControllerService.deactivateEphemeralKeyMode(
+                    new OperationReceiverProxy(executor, receiver));
+        } catch (RemoteException e) {
+            throw e.rethrowFromSystemServer();
+        }
+    }
+
     /** Returns the Thread version this device is operating on. */
     @ThreadVersion
     public int getThreadVersion() {
@@ -248,6 +359,24 @@
          * @param enabledState the new Thread enabled state
          */
         default void onThreadEnableStateChanged(@EnabledState int enabledState) {}
+
+        /**
+         * The ephemeral key state has changed.
+         *
+         * @param ephemeralKeyState the ephemeral key state
+         * @param ephemeralKey the ephemeral key string which contains a sequence of numeric digits
+         *     0-9 of user-input friendly length (typically 9), or {@code null} if {@code
+         *     ephemeralKeyState} is {@link #EPHEMERAL_KEY_DISABLED} or the caller doesn't have the
+         *     permission {@link android.permission.THREAD_NETWORK_PRIVILEGED}
+         * @param expiry a timestamp of when the ephemeral key will expireor {@code null} if {@code
+         *     ephemeralKeyState} is {@link #EPHEMERAL_KEY_DISABLED}
+         */
+        @FlaggedApi(Flags.FLAG_EPSKC_ENABLED)
+        @RequiresPermission("android.permission.THREAD_NETWORK_PRIVILEGED")
+        default void onEphemeralKeyStateChanged(
+                @EphemeralKeyState int ephemeralKeyState,
+                @Nullable String ephemeralKey,
+                @Nullable Instant expiry) {}
     }
 
     private static final class StateCallbackProxy extends IStateCallback.Stub {
@@ -288,13 +417,37 @@
                 Binder.restoreCallingIdentity(identity);
             }
         }
+
+        @Override
+        public void onEphemeralKeyStateChanged(
+                @EphemeralKeyState int ephemeralKeyState, String ephemeralKey, long expiryMillis) {
+            if (!Flags.epskcEnabled()) {
+                throw new IllegalStateException(
+                        "This should not be called when Ephemeral key API is disabled");
+            }
+
+            final long identity = Binder.clearCallingIdentity();
+            final Instant expiry =
+                    ephemeralKeyState == EPHEMERAL_KEY_DISABLED
+                            ? null
+                            : Instant.ofEpochMilli(expiryMillis);
+
+            try {
+                mExecutor.execute(
+                        () ->
+                                mCallback.onEphemeralKeyStateChanged(
+                                        ephemeralKeyState, ephemeralKey, expiry));
+            } finally {
+                Binder.restoreCallingIdentity(identity);
+            }
+        }
     }
 
     /**
      * Registers a callback to be called when Thread network states are changed.
      *
-     * <p>Upon return of this method, methods of {@code callback} will be invoked immediately with
-     * existing states.
+     * <p>Upon return of this method, all methods of {@code callback} will be invoked immediately
+     * with existing states. The order of the invoked callbacks is not guaranteed.
      *
      * @param executor the executor to execute the {@code callback}
      * @param callback the callback to receive Thread network state changes
diff --git a/thread/tests/cts/AndroidTest.xml b/thread/tests/cts/AndroidTest.xml
index 34aabe2..e954d3b 100644
--- a/thread/tests/cts/AndroidTest.xml
+++ b/thread/tests/cts/AndroidTest.xml
@@ -56,4 +56,14 @@
         <!-- Ignores tests introduced by guava-android-testlib -->
         <option name="exclude-annotation" value="org.junit.Ignore"/>
     </test>
+
+    <!--
+        This doesn't override a read-only flag, to run the tests locally with `epskc_enabled` flag
+        enabled, set the flag to `is_fixed_read_only: false`. This should be removed after the
+        `epskc_enabled` flag is rolled out.
+    -->
+    <target_preparer class="com.android.tradefed.targetprep.FeatureFlagTargetPreparer">
+        <option name="flag-value"
+                value="thread_network/com.android.net.thread.flags.epskc_enabled=true"/>
+    </target_preparer>
 </configuration>
diff --git a/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java b/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
index c048394..1792bfb 100644
--- a/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
+++ b/thread/tests/cts/src/android/net/thread/cts/ThreadNetworkControllerTest.java
@@ -27,11 +27,14 @@
 import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_LEADER;
 import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_ROUTER;
 import static android.net.thread.ThreadNetworkController.DEVICE_ROLE_STOPPED;
+import static android.net.thread.ThreadNetworkController.EPHEMERAL_KEY_DISABLED;
+import static android.net.thread.ThreadNetworkController.EPHEMERAL_KEY_ENABLED;
 import static android.net.thread.ThreadNetworkController.STATE_DISABLED;
 import static android.net.thread.ThreadNetworkController.STATE_DISABLING;
 import static android.net.thread.ThreadNetworkController.STATE_ENABLED;
 import static android.net.thread.ThreadNetworkController.THREAD_VERSION_1_3;
 import static android.net.thread.ThreadNetworkException.ERROR_ABORTED;
+import static android.net.thread.ThreadNetworkException.ERROR_BUSY;
 import static android.net.thread.ThreadNetworkException.ERROR_FAILED_PRECONDITION;
 import static android.net.thread.ThreadNetworkException.ERROR_REJECTED_BY_PEER;
 import static android.net.thread.ThreadNetworkException.ERROR_THREAD_DISABLED;
@@ -72,6 +75,8 @@
 import android.os.HandlerThread;
 import android.os.OutcomeReceiver;
 import android.platform.test.annotations.RequiresFlagsEnabled;
+import android.platform.test.flag.junit.CheckFlagsRule;
+import android.platform.test.flag.junit.DeviceFlagsValueProvider;
 import android.util.SparseIntArray;
 
 import androidx.annotation.NonNull;
@@ -82,6 +87,8 @@
 import com.android.net.thread.flags.Flags;
 import com.android.testutils.FunctionalUtils.ThrowingRunnable;
 
+import kotlin.Triple;
+
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Ignore;
@@ -96,6 +103,7 @@
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Random;
 import java.util.Set;
 import java.util.concurrent.CompletableFuture;
@@ -134,9 +142,13 @@
                     put(VALID_CHANNEL, VALID_POWER);
                 }
             };
+    private static final Duration EPHEMERAL_KEY_LIFETIME = Duration.ofSeconds(1);
 
     @Rule public final ThreadFeatureCheckerRule mThreadRule = new ThreadFeatureCheckerRule();
 
+    @Rule
+    public final CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule();
+
     private final Context mContext = ApplicationProvider.getApplicationContext();
     private ExecutorService mExecutor;
     private ThreadNetworkController mController;
@@ -164,6 +176,7 @@
 
         setEnabledAndWait(mController, true);
         setConfigurationAndWait(mController, DEFAULT_CONFIG);
+        deactivateEphemeralKeyModeAndWait(mController);
     }
 
     @After
@@ -183,6 +196,7 @@
             }
         }
         mConfigurationCallbacksToCleanUp.clear();
+        deactivateEphemeralKeyModeAndWait(mController);
     }
 
     @Test
@@ -819,6 +833,221 @@
         listener.unregisterStateCallback();
     }
 
+    @Test
+    @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
+    public void getMaxEphemeralKeyLifetime_isLargerThanZero() {
+        assertThat(mController.getMaxEphemeralKeyLifetime()).isGreaterThan(Duration.ZERO);
+    }
+
+    @Test
+    @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
+    public void activateEphemeralKeyMode_withPrivilegedPermission_succeeds() throws Exception {
+        joinRandomizedDatasetAndWait(mController);
+        CompletableFuture<Void> startFuture = new CompletableFuture<>();
+
+        runAsShell(
+                THREAD_NETWORK_PRIVILEGED,
+                () ->
+                        mController.activateEphemeralKeyMode(
+                                EPHEMERAL_KEY_LIFETIME,
+                                mExecutor,
+                                newOutcomeReceiver(startFuture)));
+
+        startFuture.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS);
+    }
+
+    @Test
+    @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
+    public void activateEphemeralKeyMode_withoutPrivilegedPermission_throwsSecurityException()
+            throws Exception {
+        dropAllPermissions();
+
+        assertThrows(
+                SecurityException.class,
+                () ->
+                        mController.activateEphemeralKeyMode(
+                                EPHEMERAL_KEY_LIFETIME, mExecutor, v -> {}));
+    }
+
+    @Test
+    @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
+    public void activateEphemeralKeyMode_withZeroLifetime_throwsIllegalArgumentException()
+            throws Exception {
+        grantPermissions(THREAD_NETWORK_PRIVILEGED);
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> mController.activateEphemeralKeyMode(Duration.ZERO, mExecutor, v -> {}));
+    }
+
+    @Test
+    @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
+    public void activateEphemeralKeyMode_withInvalidLargeLifetime_throwsIllegalArgumentException()
+            throws Exception {
+        grantPermissions(THREAD_NETWORK_PRIVILEGED);
+        Duration lifetime = mController.getMaxEphemeralKeyLifetime().plusMillis(1);
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> mController.activateEphemeralKeyMode(lifetime, Runnable::run, v -> {}));
+    }
+
+    @Test
+    @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
+    public void activateEphemeralKeyMode_concurrentRequests_secondOneFailsWithBusyError()
+            throws Exception {
+        joinRandomizedDatasetAndWait(mController);
+        CompletableFuture<Void> future1 = new CompletableFuture<>();
+        CompletableFuture<Void> future2 = new CompletableFuture<>();
+
+        runAsShell(
+                THREAD_NETWORK_PRIVILEGED,
+                () -> {
+                    mController.activateEphemeralKeyMode(
+                            EPHEMERAL_KEY_LIFETIME, mExecutor, newOutcomeReceiver(future1));
+                    mController.activateEphemeralKeyMode(
+                            EPHEMERAL_KEY_LIFETIME, mExecutor, newOutcomeReceiver(future2));
+                });
+
+        var thrown =
+                assertThrows(
+                        ExecutionException.class,
+                        () -> {
+                            future2.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS);
+                        });
+        var threadException = (ThreadNetworkException) thrown.getCause();
+        assertThat(threadException.getErrorCode()).isEqualTo(ERROR_BUSY);
+    }
+
+    @Test
+    @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
+    public void deactivateEphemeralKeyMode_withoutPrivilegedPermission_throwsSecurityException()
+            throws Exception {
+        dropAllPermissions();
+
+        assertThrows(
+                SecurityException.class,
+                () -> mController.deactivateEphemeralKeyMode(mExecutor, v -> {}));
+    }
+
+    @Test
+    @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
+    public void subscribeEpskcState_permissionsGranted_returnsCurrentState() throws Exception {
+        CompletableFuture<Integer> stateFuture = new CompletableFuture<>();
+        CompletableFuture<String> ephemeralKeyFuture = new CompletableFuture<>();
+        CompletableFuture<Instant> expiryFuture = new CompletableFuture<>();
+        StateCallback callback =
+                new ThreadNetworkController.StateCallback() {
+                    @Override
+                    public void onDeviceRoleChanged(int r) {}
+
+                    @Override
+                    public void onEphemeralKeyStateChanged(
+                            int state, String ephemeralKey, Instant expiry) {
+                        stateFuture.complete(state);
+                        ephemeralKeyFuture.complete(ephemeralKey);
+                        expiryFuture.complete(expiry);
+                    }
+                };
+
+        runAsShell(
+                ACCESS_NETWORK_STATE,
+                THREAD_NETWORK_PRIVILEGED,
+                () -> mController.registerStateCallback(mExecutor, callback));
+
+        try {
+            assertThat(stateFuture.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS))
+                    .isEqualTo(EPHEMERAL_KEY_DISABLED);
+            assertThat(ephemeralKeyFuture.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS)).isNull();
+            assertThat(expiryFuture.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS)).isNull();
+        } finally {
+            runAsShell(ACCESS_NETWORK_STATE, () -> mController.unregisterStateCallback(callback));
+        }
+    }
+
+    @Test
+    @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
+    public void subscribeEpskcState_withoutThreadPriviledgedPermission_returnsNullEphemeralKey()
+            throws Exception {
+        CompletableFuture<Integer> stateFuture = new CompletableFuture<>();
+        CompletableFuture<String> ephemeralKeyFuture = new CompletableFuture<>();
+        CompletableFuture<Instant> expiryFuture = new CompletableFuture<>();
+        StateCallback callback =
+                new ThreadNetworkController.StateCallback() {
+                    @Override
+                    public void onDeviceRoleChanged(int r) {}
+
+                    @Override
+                    public void onEphemeralKeyStateChanged(
+                            int state, String ephemeralKey, Instant expiry) {
+                        stateFuture.complete(state);
+                        ephemeralKeyFuture.complete(ephemeralKey);
+                        expiryFuture.complete(expiry);
+                    }
+                };
+        joinRandomizedDatasetAndWait(mController);
+        activateEphemeralKeyModeAndWait(mController);
+
+        runAsShell(
+                ACCESS_NETWORK_STATE, () -> mController.registerStateCallback(mExecutor, callback));
+
+        try {
+            assertThat(stateFuture.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS))
+                    .isEqualTo(EPHEMERAL_KEY_ENABLED);
+            assertThat(ephemeralKeyFuture.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS)).isNull();
+            assertThat(
+                            expiryFuture
+                                    .get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS)
+                                    .isAfter(Instant.now()))
+                    .isTrue();
+        } finally {
+            runAsShell(ACCESS_NETWORK_STATE, () -> mController.unregisterStateCallback(callback));
+        }
+    }
+
+    @Test
+    @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
+    public void subscribeEpskcState_ephemralKeyStateChanged_returnsUpdatedState() throws Exception {
+        EphemeralKeyStateListener listener = new EphemeralKeyStateListener(mController);
+        joinRandomizedDatasetAndWait(mController);
+
+        try {
+            activateEphemeralKeyModeAndWait(mController);
+            deactivateEphemeralKeyModeAndWait(mController);
+
+            listener.expectThreadEphemeralKeyMode(EPHEMERAL_KEY_DISABLED);
+            listener.expectThreadEphemeralKeyMode(EPHEMERAL_KEY_ENABLED);
+            listener.expectThreadEphemeralKeyMode(EPHEMERAL_KEY_DISABLED);
+        } finally {
+            listener.unregisterStateCallback();
+        }
+    }
+
+    @Test
+    @RequiresFlagsEnabled({Flags.FLAG_EPSKC_ENABLED})
+    public void subscribeEpskcState_epskcEnabled_returnsSameExpiry() throws Exception {
+        EphemeralKeyStateListener listener1 = new EphemeralKeyStateListener(mController);
+        Triple<Integer, String, Instant> epskc1;
+        try {
+            joinRandomizedDatasetAndWait(mController);
+            activateEphemeralKeyModeAndWait(mController);
+            epskc1 = listener1.expectThreadEphemeralKeyMode(EPHEMERAL_KEY_ENABLED);
+        } finally {
+            listener1.unregisterStateCallback();
+        }
+
+        EphemeralKeyStateListener listener2 = new EphemeralKeyStateListener(mController);
+        try {
+            Triple<Integer, String, Instant> epskc2 =
+                    listener2.expectThreadEphemeralKeyMode(EPHEMERAL_KEY_ENABLED);
+
+            assertThat(epskc2.getSecond()).isEqualTo(epskc1.getSecond());
+            assertThat(epskc2.getThird()).isEqualTo(epskc1.getThird());
+        } finally {
+            listener2.unregisterStateCallback();
+        }
+    }
+
     // TODO (b/322437869): add test case to verify when Thread is in DISABLING state, any commands
     // (join/leave/scheduleMigration/setEnabled) fail with ERROR_BUSY. This is not currently tested
     // because DISABLING has very short lifecycle, it's not possible to guarantee the command can be
@@ -1274,6 +1503,71 @@
         setFuture.get(SET_CONFIGURATION_TIMEOUT_MILLIS, MILLISECONDS);
     }
 
+    private void deactivateEphemeralKeyModeAndWait(ThreadNetworkController controller)
+            throws Exception {
+        CompletableFuture<Void> clearFuture = new CompletableFuture<>();
+        runAsShell(
+                THREAD_NETWORK_PRIVILEGED,
+                () ->
+                        controller.deactivateEphemeralKeyMode(
+                                mExecutor, newOutcomeReceiver(clearFuture)));
+        clearFuture.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS);
+    }
+
+    private void activateEphemeralKeyModeAndWait(ThreadNetworkController controller)
+            throws Exception {
+        CompletableFuture<Void> startFuture = new CompletableFuture<>();
+        runAsShell(
+                THREAD_NETWORK_PRIVILEGED,
+                () ->
+                        controller.activateEphemeralKeyMode(
+                                EPHEMERAL_KEY_LIFETIME,
+                                mExecutor,
+                                newOutcomeReceiver(startFuture)));
+        startFuture.get(CALLBACK_TIMEOUT_MILLIS, MILLISECONDS);
+    }
+
+    private class EphemeralKeyStateListener {
+        private ArrayTrackRecord<Triple<Integer, String, Instant>> mEphemeralKeyStates =
+                new ArrayTrackRecord<>();
+        private final ArrayTrackRecord<Triple<Integer, String, Instant>>.ReadHead mReadHead =
+                mEphemeralKeyStates.newReadHead();
+        ThreadNetworkController mController;
+        StateCallback mCallback =
+                new ThreadNetworkController.StateCallback() {
+                    @Override
+                    public void onDeviceRoleChanged(int r) {}
+
+                    @Override
+                    public void onEphemeralKeyStateChanged(
+                            int state, String ephemeralKey, Instant expiry) {
+                        mEphemeralKeyStates.add(new Triple<>(state, ephemeralKey, expiry));
+                    }
+                };
+
+        EphemeralKeyStateListener(ThreadNetworkController controller) {
+            this.mController = controller;
+            runAsShell(
+                    ACCESS_NETWORK_STATE,
+                    THREAD_NETWORK_PRIVILEGED,
+                    () -> controller.registerStateCallback(mExecutor, mCallback));
+        }
+
+        // Expect that EphemeralKey has the expected state, and return a Triple of <state,
+        // passcode, expiry>.
+        public Triple<Integer, String, Instant> expectThreadEphemeralKeyMode(int state) {
+            Triple<Integer, String, Instant> epskc =
+                    mReadHead.poll(
+                            ENABLED_TIMEOUT_MILLIS, e -> Objects.equals(e.getFirst(), state));
+            assertThat(epskc).isNotNull();
+            return epskc;
+        }
+
+        public void unregisterStateCallback() {
+            runAsShell(ACCESS_NETWORK_STATE, () -> mController.unregisterStateCallback(mCallback));
+        }
+    }
+
     private CompletableFuture joinRandomizedDataset(
             ThreadNetworkController controller, String networkName) throws Exception {
         ActiveOperationalDataset activeDataset = newRandomizedDataset(networkName, controller);