From 249b56d80666af321c0f36e02e7582bebbe8655e Mon Sep 17 00:00:00 2001 From: Andrew Scull Date: Mon, 17 Apr 2023 13:19:45 +0000 Subject: [PATCH] Add command line interface for remote_provisioning Provide an ADB interface to interact with the remote_provisioning service for diagnostic purposes. The service reports details of the IRPC components in dumpsys and allows ADB to query the IRPC instances and request a CSR from each of them. Test: adb shell dumpsys remote_provisioning Test: adb shell cmd remote_provisioning Test: atest RemoteProvisioningShellCommandTest Bug: 265747549 Change-Id: I593a4b599f4fc8e27d7f79d1d5f3955eabc9641d Merged-In: I593a4b599f4fc8e27d7f79d1d5f3955eabc9641d --- services/core/Android.bp | 2 + .../rkp/RemoteProvisioningService.java | 17 ++ .../rkp/RemoteProvisioningShellCommand.java | 250 ++++++++++++++++++ .../RemoteProvisioningShellCommandTest.java | 244 +++++++++++++++++ 4 files changed, 513 insertions(+) create mode 100644 services/core/java/com/android/server/security/rkp/RemoteProvisioningShellCommand.java create mode 100644 services/tests/RemoteProvisioningServiceTests/src/com/android/server/security/rkp/RemoteProvisioningShellCommandTest.java diff --git a/services/core/Android.bp b/services/core/Android.bp index 6a14deabee4bb..d885cd197387a 100644 --- a/services/core/Android.bp +++ b/services/core/Android.bp @@ -155,6 +155,7 @@ java_library_static { "android.hardware.health-V2-java", // AIDL "android.hardware.health-translate-java", "android.hardware.light-V1-java", + "android.hardware.security.rkp-V3-java", "android.hardware.tv.cec-V1.1-java", "android.hardware.tv.hdmi.cec-V1-java", "android.hardware.tv.hdmi.connection-V1-java", @@ -171,6 +172,7 @@ java_library_static { "android.hardware.power-V3-java", "android.hidl.manager-V1.2-java", "capture_state_listener-aidl-java", + "cbor-java", "icu4j_calendar_astronomer", "netd-client", "overlayable_policy_aidl-java", diff --git a/services/core/java/com/android/server/security/rkp/RemoteProvisioningService.java b/services/core/java/com/android/server/security/rkp/RemoteProvisioningService.java index cd1a968a8e890..97e463646fdc5 100644 --- a/services/core/java/com/android/server/security/rkp/RemoteProvisioningService.java +++ b/services/core/java/com/android/server/security/rkp/RemoteProvisioningService.java @@ -19,14 +19,18 @@ package com.android.server.security.rkp; import android.content.Context; import android.os.Binder; import android.os.OutcomeReceiver; +import android.os.ParcelFileDescriptor; import android.os.RemoteException; import android.security.rkp.IGetRegistrationCallback; import android.security.rkp.IRemoteProvisioning; import android.security.rkp.service.RegistrationProxy; import android.util.Log; +import com.android.internal.util.DumpUtils; import com.android.server.SystemService; +import java.io.FileDescriptor; +import java.io.PrintWriter; import java.time.Duration; import java.util.concurrent.Executor; @@ -97,5 +101,18 @@ public class RemoteProvisioningService extends SystemService { Binder.restoreCallingIdentity(callingIdentity); } } + + @Override + protected void dump(FileDescriptor fd, PrintWriter pw, String[] args) { + if (!DumpUtils.checkDumpPermission(getContext(), TAG, pw)) return; + new RemoteProvisioningShellCommand().dump(pw); + } + + @Override + public int handleShellCommand(ParcelFileDescriptor in, ParcelFileDescriptor out, + ParcelFileDescriptor err, String[] args) { + return new RemoteProvisioningShellCommand().exec(this, in.getFileDescriptor(), + out.getFileDescriptor(), err.getFileDescriptor(), args); + } } } diff --git a/services/core/java/com/android/server/security/rkp/RemoteProvisioningShellCommand.java b/services/core/java/com/android/server/security/rkp/RemoteProvisioningShellCommand.java new file mode 100644 index 0000000000000..71eca691dba78 --- /dev/null +++ b/services/core/java/com/android/server/security/rkp/RemoteProvisioningShellCommand.java @@ -0,0 +1,250 @@ +/* + * Copyright (C) 2023 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.security.rkp; + +import android.hardware.security.keymint.DeviceInfo; +import android.hardware.security.keymint.IRemotelyProvisionedComponent; +import android.hardware.security.keymint.MacedPublicKey; +import android.hardware.security.keymint.ProtectedData; +import android.hardware.security.keymint.RpcHardwareInfo; +import android.os.RemoteException; +import android.os.ServiceManager; +import android.os.ShellCommand; +import android.util.IndentingPrintWriter; + +import com.android.internal.annotations.VisibleForTesting; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.PrintWriter; +import java.util.Base64; + +import co.nstant.in.cbor.CborDecoder; +import co.nstant.in.cbor.CborEncoder; +import co.nstant.in.cbor.CborException; +import co.nstant.in.cbor.model.Array; +import co.nstant.in.cbor.model.ByteString; +import co.nstant.in.cbor.model.DataItem; +import co.nstant.in.cbor.model.Map; +import co.nstant.in.cbor.model.SimpleValue; +import co.nstant.in.cbor.model.UnsignedInteger; + +class RemoteProvisioningShellCommand extends ShellCommand { + private static final String USAGE = "usage: cmd remote_provisioning SUBCOMMAND [ARGS]\n" + + "help\n" + + " Show this message.\n" + + "dump\n" + + " Dump service diagnostics.\n" + + "list [--min-version MIN_VERSION]\n" + + " List the names of the IRemotelyProvisionedComponent instances.\n" + + "csr [--challenge CHALLENGE] NAME\n" + + " Generate and print a base64-encoded CSR from the named\n" + + " IRemotelyProvisionedComponent. A base64-encoded challenge can be provided,\n" + + " or else it defaults to an empty challenge.\n"; + + @VisibleForTesting + static final String EEK_ED25519_BASE64 = "goRDoQEnoFgqpAEBAycgBiFYIJm57t1e5FL2hcZMYtw+YatXSH11N" + + "ymtdoAy0rPLY1jZWEAeIghLpLekyNdOAw7+uK8UTKc7b6XN3Np5xitk/pk5r3bngPpmAIUNB5gqrJFcpyUUS" + + "QY0dcqKJ3rZ41pJ6wIDhEOhASegWE6lAQECWCDQrsEVyirPc65rzMvRlh1l6LHd10oaN7lDOpfVmd+YCAM4G" + + "CAEIVggvoXnRsSjQlpA2TY6phXQLFh+PdwzAjLS/F4ehyVfcmBYQJvPkOIuS6vRGLEOjl0gJ0uEWP78MpB+c" + + "gWDvNeCvvpkeC1UEEvAMb9r6B414vAtzmwvT/L1T6XUg62WovGHWAQ="; + + @VisibleForTesting + static final String EEK_P256_BASE64 = "goRDoQEmoFhNpQECAyYgASFYIPcUituX9MxT79JkEcTjdR9mH6RxDGzP" + + "+glGgHSHVPKtIlggXn9b9uzk9hnM/xM3/Q+hyJPbGAZ2xF3m12p3hsMtr49YQC+XjkL7vgctlUeFR5NAsB/U" + + "m0ekxESp8qEHhxDHn8sR9L+f6Dvg5zRMFfx7w34zBfTRNDztAgRgehXgedOK/ySEQ6EBJqBYcaYBAgJYIDVz" + + "tz+gioCJsSZn6ct8daGvAmH8bmUDkTvTS30UlD5GAzgYIAEhWCDgQc8vDzQPHDMsQbDP1wwwVTXSHmpHE0su" + + "0UiWfiScaCJYIB/ORcX7YbqBIfnlBZubOQ52hoZHuB4vRfHOr9o/gGjbWECMs7p+ID4ysGjfYNEdffCsOI5R" + + "vP9s4Wc7Snm8Vnizmdh8igfY2rW1f3H02GvfMyc0e2XRKuuGmZirOrSAqr1Q"; + + private static final int ERROR = -1; + private static final int SUCCESS = 0; + + private final Injector mInjector; + + RemoteProvisioningShellCommand() { + this(new Injector()); + } + + @VisibleForTesting + RemoteProvisioningShellCommand(Injector injector) { + mInjector = injector; + } + + @Override + public void onHelp() { + getOutPrintWriter().print(USAGE); + } + + @Override + @SuppressWarnings("CatchAndPrintStackTrace") + public int onCommand(String cmd) { + if (cmd == null) { + return handleDefaultCommands(cmd); + } + try { + switch (cmd) { + case "list": + return list(); + case "csr": + return csr(); + default: + return handleDefaultCommands(cmd); + } + } catch (Exception e) { + e.printStackTrace(getErrPrintWriter()); + return ERROR; + } + } + + @SuppressWarnings("CatchAndPrintStackTrace") + void dump(PrintWriter pw) { + try { + IndentingPrintWriter ipw = new IndentingPrintWriter(pw); + for (String name : mInjector.getIrpcNames()) { + ipw.println(name + ":"); + ipw.increaseIndent(); + dumpRpcInstance(ipw, name); + ipw.decreaseIndent(); + } + } catch (Exception e) { + e.printStackTrace(pw); + } + } + + private void dumpRpcInstance(PrintWriter pw, String name) throws RemoteException { + RpcHardwareInfo info = mInjector.getIrpcBinder(name).getHardwareInfo(); + pw.println("hwVersion=" + info.versionNumber); + pw.println("rpcAuthorName=" + info.rpcAuthorName); + if (info.versionNumber < 3) { + pw.println("supportedEekCurve=" + info.supportedEekCurve); + } + pw.println("uniqueId=" + info.uniqueId); + pw.println("supportedNumKeysInCsr=" + info.supportedNumKeysInCsr); + } + + private int list() throws RemoteException { + for (String name : mInjector.getIrpcNames()) { + getOutPrintWriter().println(name); + } + return SUCCESS; + } + + private int csr() throws RemoteException, CborException { + byte[] challenge = {}; + String opt; + while ((opt = getNextOption()) != null) { + switch (opt) { + case "--challenge": + challenge = Base64.getDecoder().decode(getNextArgRequired()); + break; + default: + getErrPrintWriter().println("error: unknown option"); + return ERROR; + } + } + String name = getNextArgRequired(); + + IRemotelyProvisionedComponent binder = mInjector.getIrpcBinder(name); + RpcHardwareInfo info = binder.getHardwareInfo(); + MacedPublicKey[] emptyKeys = new MacedPublicKey[] {}; + byte[] csrBytes; + switch (info.versionNumber) { + case 1: + case 2: + DeviceInfo deviceInfo = new DeviceInfo(); + ProtectedData protectedData = new ProtectedData(); + byte[] eek = getEekChain(info.supportedEekCurve); + byte[] keysToSignMac = binder.generateCertificateRequest( + /*testMode=*/false, emptyKeys, eek, challenge, deviceInfo, protectedData); + csrBytes = composeCertificateRequestV1( + deviceInfo, challenge, protectedData, keysToSignMac); + break; + case 3: + csrBytes = binder.generateCertificateRequestV2(emptyKeys, challenge); + break; + default: + getErrPrintWriter().println("error: unsupported hwVersion: " + info.versionNumber); + return ERROR; + } + getOutPrintWriter().println(Base64.getEncoder().encodeToString(csrBytes)); + return SUCCESS; + } + + private byte[] getEekChain(int supportedEekCurve) { + switch (supportedEekCurve) { + case RpcHardwareInfo.CURVE_25519: + return Base64.getDecoder().decode(EEK_ED25519_BASE64); + case RpcHardwareInfo.CURVE_P256: + return Base64.getDecoder().decode(EEK_P256_BASE64); + default: + throw new IllegalArgumentException("unsupported EEK curve: " + supportedEekCurve); + } + } + + private byte[] composeCertificateRequestV1(DeviceInfo deviceInfo, byte[] challenge, + ProtectedData protectedData, byte[] keysToSignMac) throws CborException { + Array info = new Array() + .add(decode(deviceInfo.deviceInfo)) + .add(new Map()); + + // COSE_Signature with the hmac-sha256 algorithm and without a payload. + Array mac = new Array() + .add(new ByteString(encode( + new Map().put(new UnsignedInteger(1), new UnsignedInteger(5))))) + .add(new Map()) + .add(SimpleValue.NULL) + .add(new ByteString(keysToSignMac)); + + Array csr = new Array() + .add(info) + .add(new ByteString(challenge)) + .add(decode(protectedData.protectedData)) + .add(mac); + + return encode(csr); + } + + private byte[] encode(DataItem item) throws CborException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + new CborEncoder(baos).encode(item); + return baos.toByteArray(); + } + + private DataItem decode(byte[] data) throws CborException { + ByteArrayInputStream bais = new ByteArrayInputStream(data); + return new CborDecoder(bais).decodeNext(); + } + + @VisibleForTesting + static class Injector { + String[] getIrpcNames() { + return ServiceManager.getDeclaredInstances(IRemotelyProvisionedComponent.DESCRIPTOR); + } + + IRemotelyProvisionedComponent getIrpcBinder(String name) { + String irpc = IRemotelyProvisionedComponent.DESCRIPTOR + "/" + name; + IRemotelyProvisionedComponent binder = + IRemotelyProvisionedComponent.Stub.asInterface( + ServiceManager.waitForDeclaredService(irpc)); + if (binder == null) { + throw new IllegalArgumentException("failed to find " + irpc); + } + return binder; + } + } +} diff --git a/services/tests/RemoteProvisioningServiceTests/src/com/android/server/security/rkp/RemoteProvisioningShellCommandTest.java b/services/tests/RemoteProvisioningServiceTests/src/com/android/server/security/rkp/RemoteProvisioningShellCommandTest.java new file mode 100644 index 0000000000000..77c3396464000 --- /dev/null +++ b/services/tests/RemoteProvisioningServiceTests/src/com/android/server/security/rkp/RemoteProvisioningShellCommandTest.java @@ -0,0 +1,244 @@ +/* + * Copyright (C) 2023 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.security.rkp; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.hardware.security.keymint.DeviceInfo; +import android.hardware.security.keymint.IRemotelyProvisionedComponent; +import android.hardware.security.keymint.MacedPublicKey; +import android.hardware.security.keymint.ProtectedData; +import android.hardware.security.keymint.RpcHardwareInfo; +import android.os.Binder; +import android.os.FileUtils; + +import androidx.test.runner.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.util.Arrays; +import java.util.Base64; +import java.util.Map; + +@RunWith(AndroidJUnit4.class) +public class RemoteProvisioningShellCommandTest { + + private static class Injector extends RemoteProvisioningShellCommand.Injector { + + private final Map mIrpcs; + + Injector(Map irpcs) { + mIrpcs = irpcs; + } + + @Override + String[] getIrpcNames() { + return mIrpcs.keySet().toArray(new String[0]); + } + + @Override + IRemotelyProvisionedComponent getIrpcBinder(String name) { + IRemotelyProvisionedComponent irpc = mIrpcs.get(name); + if (irpc == null) { + throw new IllegalArgumentException("failed to find " + irpc); + } + return irpc; + } + } + + private static class CommandResult { + private int mCode; + private String mOut; + private String mErr; + + CommandResult(int code, String out, String err) { + mCode = code; + mOut = out; + mErr = err; + } + + int getCode() { + return mCode; + } + + String getOut() { + return mOut; + } + + String getErr() { + return mErr; + } + } + + private static CommandResult exec( + RemoteProvisioningShellCommand cmd, String[] args) throws Exception { + File in = File.createTempFile("rpsct_in_", null); + File out = File.createTempFile("rpsct_out_", null); + File err = File.createTempFile("rpsct_err_", null); + int code = cmd.exec( + new Binder(), + new FileInputStream(in).getFD(), + new FileOutputStream(out).getFD(), + new FileOutputStream(err).getFD(), + args); + return new CommandResult( + code, FileUtils.readTextFile(out, 0, null), FileUtils.readTextFile(err, 0, null)); + } + + @Test + public void list_zeroInstances() throws Exception { + RemoteProvisioningShellCommand cmd = new RemoteProvisioningShellCommand( + new Injector(Map.of())); + CommandResult res = exec(cmd, new String[] {"list"}); + assertThat(res.getErr()).isEmpty(); + assertThat(res.getCode()).isEqualTo(0); + assertThat(res.getOut()).isEmpty(); + } + + @Test + public void list_oneInstances() throws Exception { + RemoteProvisioningShellCommand cmd = new RemoteProvisioningShellCommand( + new Injector(Map.of("default", mock(IRemotelyProvisionedComponent.class)))); + CommandResult res = exec(cmd, new String[] {"list"}); + assertThat(res.getErr()).isEmpty(); + assertThat(res.getCode()).isEqualTo(0); + assertThat(Arrays.asList(res.getOut().split("\n"))).containsExactly("default"); + } + + @Test + public void list_twoInstances() throws Exception { + RemoteProvisioningShellCommand cmd = new RemoteProvisioningShellCommand( + new Injector(Map.of( + "default", mock(IRemotelyProvisionedComponent.class), + "strongbox", mock(IRemotelyProvisionedComponent.class)))); + CommandResult res = exec(cmd, new String[] {"list"}); + assertThat(res.getErr()).isEmpty(); + assertThat(res.getCode()).isEqualTo(0); + assertThat(Arrays.asList(res.getOut().split("\n"))).containsExactly("default", "strongbox"); + } + + @Test + public void csr_hwVersion1_withChallenge() throws Exception { + IRemotelyProvisionedComponent defaultMock = mock(IRemotelyProvisionedComponent.class); + RpcHardwareInfo defaultInfo = new RpcHardwareInfo(); + defaultInfo.versionNumber = 1; + defaultInfo.supportedEekCurve = RpcHardwareInfo.CURVE_25519; + when(defaultMock.getHardwareInfo()).thenReturn(defaultInfo); + doAnswer(invocation -> { + ((DeviceInfo) invocation.getArgument(4)).deviceInfo = new byte[] {0x00}; + ((ProtectedData) invocation.getArgument(5)).protectedData = new byte[] {0x00}; + return new byte[] {0x77, 0x77, 0x77, 0x77}; + }).when(defaultMock).generateCertificateRequest( + anyBoolean(), any(), any(), any(), any(), any()); + + RemoteProvisioningShellCommand cmd = new RemoteProvisioningShellCommand( + new Injector(Map.of("default", defaultMock))); + CommandResult res = exec(cmd, new String[] { + "csr", "--challenge", "dGVzdHRlc3R0ZXN0dGVzdA==", "default"}); + verify(defaultMock).generateCertificateRequest( + /*test_mode=*/eq(false), + eq(new MacedPublicKey[0]), + eq(Base64.getDecoder().decode(RemoteProvisioningShellCommand.EEK_ED25519_BASE64)), + eq(new byte[] { + 0x74, 0x65, 0x73, 0x74, 0x74, 0x65, 0x73, 0x74, + 0x74, 0x65, 0x73, 0x74, 0x74, 0x65, 0x73, 0x74}), + any(DeviceInfo.class), + any(ProtectedData.class)); + assertThat(res.getErr()).isEmpty(); + assertThat(res.getCode()).isEqualTo(0); + } + + @Test + public void csr_hwVersion2_withChallenge() throws Exception { + IRemotelyProvisionedComponent defaultMock = mock(IRemotelyProvisionedComponent.class); + RpcHardwareInfo defaultInfo = new RpcHardwareInfo(); + defaultInfo.versionNumber = 2; + defaultInfo.supportedEekCurve = RpcHardwareInfo.CURVE_P256; + when(defaultMock.getHardwareInfo()).thenReturn(defaultInfo); + doAnswer(invocation -> { + ((DeviceInfo) invocation.getArgument(4)).deviceInfo = new byte[] {0x00}; + ((ProtectedData) invocation.getArgument(5)).protectedData = new byte[] {0x00}; + return new byte[] {0x77, 0x77, 0x77, 0x77}; + }).when(defaultMock).generateCertificateRequest( + anyBoolean(), any(), any(), any(), any(), any()); + + RemoteProvisioningShellCommand cmd = new RemoteProvisioningShellCommand( + new Injector(Map.of("default", defaultMock))); + CommandResult res = exec(cmd, new String[] { + "csr", "--challenge", "dGVzdHRlc3R0ZXN0dGVzdA==", "default"}); + verify(defaultMock).generateCertificateRequest( + /*test_mode=*/eq(false), + eq(new MacedPublicKey[0]), + eq(Base64.getDecoder().decode(RemoteProvisioningShellCommand.EEK_P256_BASE64)), + eq(new byte[] { + 0x74, 0x65, 0x73, 0x74, 0x74, 0x65, 0x73, 0x74, + 0x74, 0x65, 0x73, 0x74, 0x74, 0x65, 0x73, 0x74}), + any(DeviceInfo.class), + any(ProtectedData.class)); + assertThat(res.getErr()).isEmpty(); + assertThat(res.getCode()).isEqualTo(0); + } + + @Test + public void csr_hwVersion3_withoutChallenge() throws Exception { + IRemotelyProvisionedComponent defaultMock = mock(IRemotelyProvisionedComponent.class); + RpcHardwareInfo defaultInfo = new RpcHardwareInfo(); + defaultInfo.versionNumber = 3; + when(defaultMock.getHardwareInfo()).thenReturn(defaultInfo); + when(defaultMock.generateCertificateRequestV2(any(), any())) + .thenReturn(new byte[] {0x68, 0x65, 0x6c, 0x6c, 0x6f}); + + RemoteProvisioningShellCommand cmd = new RemoteProvisioningShellCommand( + new Injector(Map.of("default", defaultMock))); + CommandResult res = exec(cmd, new String[] {"csr", "default"}); + verify(defaultMock).generateCertificateRequestV2(new MacedPublicKey[0], new byte[0]); + assertThat(res.getErr()).isEmpty(); + assertThat(res.getCode()).isEqualTo(0); + assertThat(res.getOut()).isEqualTo("aGVsbG8=\n"); + } + + @Test + public void csr_hwVersion3_withChallenge() throws Exception { + IRemotelyProvisionedComponent defaultMock = mock(IRemotelyProvisionedComponent.class); + RpcHardwareInfo defaultInfo = new RpcHardwareInfo(); + defaultInfo.versionNumber = 3; + when(defaultMock.getHardwareInfo()).thenReturn(defaultInfo); + when(defaultMock.generateCertificateRequestV2(any(), any())) + .thenReturn(new byte[] {0x68, 0x69}); + + RemoteProvisioningShellCommand cmd = new RemoteProvisioningShellCommand( + new Injector(Map.of("default", defaultMock))); + CommandResult res = exec(cmd, new String[] {"csr", "--challenge", "dHJpYWw=", "default"}); + verify(defaultMock).generateCertificateRequestV2( + new MacedPublicKey[0], new byte[] {0x74, 0x72, 0x69, 0x61, 0x6c}); + assertThat(res.getErr()).isEmpty(); + assertThat(res.getCode()).isEqualTo(0); + assertThat(res.getOut()).isEqualTo("aGk=\n"); + } +}