summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLuca Stefani <luca.stefani.ge1@gmail.com>2019-07-13 18:57:50 +0200
committerLuca Stefani <luca.stefani.ge1@gmail.com>2019-07-13 18:57:50 +0200
commit2fef2b18a391bd19cd74574dc0ea6088ee3459ee (patch)
treeb1353f0c0c41a0c8eb0c52d4ad706f1ee1c60dc1
parentf2bc1367496c0c168fa75df9204a0c0886a4981a (diff)
parentfab418ca79c943e612a88b3662321fce2f8a1876 (diff)
downloadandroid_frameworks_opt_net_wifi-lineage-16.0.tar.gz
android_frameworks_opt_net_wifi-lineage-16.0.tar.bz2
android_frameworks_opt_net_wifi-lineage-16.0.zip
Merge remote-tracking branch 'aosp/pie-gsi' into lineage-16.0-pie-gsilineage-16.0
* aosp/pie-gsi: DO NOT MERGE Add data integrity checking for wifi passwords Revert "DO NOT MERGE Add data integrity checking for wifi passwords" Change-Id: I44868ec18815f51870d9442c0ea8f8fd15823724
-rw-r--r--service/java/com/android/server/wifi/WifiConfigStore.java35
-rw-r--r--service/java/com/android/server/wifi/util/DataIntegrityChecker.java331
-rw-r--r--service/java/com/android/server/wifi/util/EncryptedData.java48
-rw-r--r--tests/wifitests/src/com/android/server/wifi/util/DataIntegrityCheckerTest.java88
4 files changed, 497 insertions, 5 deletions
diff --git a/service/java/com/android/server/wifi/WifiConfigStore.java b/service/java/com/android/server/wifi/WifiConfigStore.java
index 17a6670ff..255e41228 100644
--- a/service/java/com/android/server/wifi/WifiConfigStore.java
+++ b/service/java/com/android/server/wifi/WifiConfigStore.java
@@ -29,6 +29,7 @@ import android.util.Xml;
import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.os.AtomicFile;
import com.android.internal.util.FastXmlSerializer;
+import com.android.server.wifi.util.DataIntegrityChecker;
import com.android.server.wifi.util.XmlUtil;
import org.xmlpull.v1.XmlPullParser;
@@ -42,6 +43,7 @@ import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
+import java.security.DigestException;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
@@ -465,9 +467,10 @@ public class WifiConfigStore {
/**
* Class to encapsulate all file writes. This is a wrapper over {@link AtomicFile} to write/read
- * raw data from the persistent file. This class provides helper methods to read/write the
- * entire file into a byte array.
- * This helps to separate out the processing/parsing from the actual file writing.
+ * raw data from the persistent file with integrity. This class provides helper methods to
+ * read/write the entire file into a byte array.
+ * This helps to separate out the processing, parsing, and integrity checking from the actual
+ * file writing.
*/
public static class StoreFile {
/**
@@ -486,10 +489,15 @@ public class WifiConfigStore {
* Store the file name for setting the file permissions/logging purposes.
*/
private String mFileName;
+ /**
+ * The integrity file storing integrity checking data for the store file.
+ */
+ private DataIntegrityChecker mDataIntegrityChecker;
public StoreFile(File file) {
mAtomicFile = new AtomicFile(file);
mFileName = mAtomicFile.getBaseFile().getAbsolutePath();
+ mDataIntegrityChecker = new DataIntegrityChecker(mFileName);
}
/**
@@ -504,16 +512,31 @@ public class WifiConfigStore {
/**
* Read the entire raw data from the store file and return in a byte array.
*
- * @return raw data read from the file or null if the file is not found.
+ * @return raw data read from the file or null if the file is not found or the data has
+ * been altered.
* @throws IOException if an error occurs. The input stream is always closed by the method
* even when an exception is encountered.
*/
public byte[] readRawData() throws IOException {
+ byte[] bytes = null;
try {
- return mAtomicFile.readFully();
+ bytes = mAtomicFile.readFully();
+ // Check that the file has not been altered since last writeBufferedRawData()
+ if (!mDataIntegrityChecker.isOk(bytes)) {
+ Log.e(TAG, "Data integrity problem with file: " + mFileName);
+ return null;
+ }
} catch (FileNotFoundException e) {
return null;
+ } catch (DigestException e) {
+ // When integrity checking is introduced. The existing data will have no related
+ // integrity file for validation. Thus, we will assume the existing data is correct
+ // and immediately create the integrity file.
+ Log.i(TAG, "isOK() had no integrity data to check; thus vacuously "
+ + "true. Running update now.");
+ mDataIntegrityChecker.update(bytes);
}
+ return bytes;
}
/**
@@ -551,6 +574,8 @@ public class WifiConfigStore {
}
throw e;
}
+ // There was a legitimate change and update the integrity checker.
+ mDataIntegrityChecker.update(mWriteData);
// Reset the pending write data after write.
mWriteData = null;
}
diff --git a/service/java/com/android/server/wifi/util/DataIntegrityChecker.java b/service/java/com/android/server/wifi/util/DataIntegrityChecker.java
new file mode 100644
index 000000000..a55fb8eb3
--- /dev/null
+++ b/service/java/com/android/server/wifi/util/DataIntegrityChecker.java
@@ -0,0 +1,331 @@
+/*
+ * Copyright (C) 2019 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.wifi.util;
+
+import android.annotation.NonNull;
+import android.os.SystemProperties;
+import android.security.keystore.KeyGenParameterSpec;
+import android.security.keystore.KeyProperties;
+import android.text.TextUtils;
+import android.util.Log;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.security.DigestException;
+import java.security.InvalidAlgorithmParameterException;
+import java.security.InvalidKeyException;
+import java.security.KeyStore;
+import java.security.KeyStoreException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.NoSuchProviderException;
+import java.security.UnrecoverableEntryException;
+import java.security.cert.CertificateException;
+
+import javax.crypto.BadPaddingException;
+import javax.crypto.Cipher;
+import javax.crypto.IllegalBlockSizeException;
+import javax.crypto.KeyGenerator;
+import javax.crypto.NoSuchPaddingException;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.GCMParameterSpec;
+
+/**
+ * Tools to provide integrity checking of byte arrays based on NIAP Common Criteria Protection
+ * Profile <a href="https://www.niap-ccevs.org/MMO/PP/-417-/#FCS_STG_EXT.3.1">FCS_STG_EXT.3.1</a>.
+ */
+public class DataIntegrityChecker {
+ private static final String TAG = "DataIntegrityChecker";
+
+ private static final String FILE_SUFFIX = ".encrypted-checksum";
+ private static final String ALIAS_SUFFIX = ".data-integrity-checker-key";
+ private static final String CIPHER_ALGORITHM = "AES/GCM/NoPadding";
+ private static final String DIGEST_ALGORITHM = "SHA-256";
+ private static final int GCM_TAG_LENGTH = 128;
+ private static final String KEY_STORE = "AndroidKeyStore";
+
+ /**
+ * When KEYSTORE_FAILURE_RETURN_VALUE is true, all cryptographic operation failures will not
+ * enforce security and {@link #isOk(byte[])} always return true.
+ */
+ private static final boolean KEYSTORE_FAILURE_RETURN_VALUE = true;
+
+ private final File mIntegrityFile;
+
+ /**
+ * Construct a new integrity checker to update and check if/when a data file was altered
+ * outside expected conditions.
+ *
+ * @param integrityFilename The {@link File} path prefix for where the integrity data is stored.
+ * A file will be created in the name of integrityFile with the suffix
+ * {@link DataIntegrityChecker#FILE_SUFFIX} We recommend using the same
+ * path as the file for which integrity is performed on.
+ * @throws NullPointerException When integrity file is null or the empty string.
+ */
+ public DataIntegrityChecker(@NonNull String integrityFilename) {
+ if (TextUtils.isEmpty(integrityFilename)) {
+ throw new NullPointerException("integrityFilename must not be null or the empty "
+ + "string");
+ }
+ mIntegrityFile = new File(integrityFilename + FILE_SUFFIX);
+ }
+
+ /**
+ * Computes a digest of a byte array, encrypt it, and store the result
+ *
+ * Call this method immediately before storing the byte array
+ *
+ * @param data The data desired to ensure integrity
+ */
+ public void update(byte[] data) {
+ if (data == null || data.length < 1) {
+ Log.e(TAG, "No data to update.");
+ reportException(new Exception("No data to update"));
+ return;
+ }
+ byte[] digest = getDigest(data);
+ if (digest == null || digest.length < 1) {
+ return;
+ }
+ String alias = mIntegrityFile.getName() + ALIAS_SUFFIX;
+ EncryptedData integrityData = encrypt(digest, alias);
+ if (integrityData != null) {
+ writeIntegrityData(integrityData, mIntegrityFile);
+ } else {
+ reportException(new Exception("integrityData null upon update"));
+ }
+ }
+
+ /**
+ * Check the integrity of a given byte array
+ *
+ * Call this method immediately before trusting the byte array. This method will return false
+ * when the byte array was altered since the last {@link #update(byte[])}
+ * call, when {@link #update(byte[])} has never been called, or if there is
+ * an underlying issue with the cryptographic functions or the key store.
+ *
+ * @param data The data to check if its been altered
+ * @throws DigestException The integrity mIntegrityFile cannot be read. Ensure
+ * {@link #isOk(byte[])} is called after {@link #update(byte[])}. Otherwise, consider the
+ * result vacuously true and immediately call {@link #update(byte[])}.
+ * @return true if the data was not altered since {@link #update(byte[])} was last called
+ */
+ public boolean isOk(byte[] data) throws DigestException {
+ if (data == null || data.length < 1) {
+ return KEYSTORE_FAILURE_RETURN_VALUE;
+ }
+ byte[] currentDigest = getDigest(data);
+ if (currentDigest == null || currentDigest.length < 1) {
+ return KEYSTORE_FAILURE_RETURN_VALUE;
+ }
+ EncryptedData encryptedData = readIntegrityData(mIntegrityFile);
+ if (encryptedData == null) {
+ throw new DigestException("No stored digest is available to compare.");
+ }
+ byte[] storedDigest = decrypt(encryptedData);
+ if (storedDigest == null) {
+ return KEYSTORE_FAILURE_RETURN_VALUE;
+ }
+ return constantTimeEquals(storedDigest, currentDigest);
+ }
+
+ private byte[] getDigest(byte[] data) {
+ try {
+ return MessageDigest.getInstance(DIGEST_ALGORITHM).digest(data);
+ } catch (NoSuchAlgorithmException e) {
+ Log.e(TAG, "getDigest could not find algorithm: " + DIGEST_ALGORITHM);
+ reportException(e);
+ return null;
+ }
+ }
+
+ private EncryptedData encrypt(byte[] data, String keyAlias) {
+ EncryptedData encryptedData = null;
+ try {
+ Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM);
+ SecretKey secretKeyReference = getOrCreateSecretKey(keyAlias);
+ if (secretKeyReference != null) {
+ cipher.init(Cipher.ENCRYPT_MODE, secretKeyReference);
+ encryptedData = new EncryptedData(cipher.doFinal(data), cipher.getIV(), keyAlias);
+ } else {
+ reportException(new Exception("secretKeyReference is null."));
+ }
+ } catch (NoSuchAlgorithmException e) {
+ Log.e(TAG, "encrypt could not find the algorithm: " + CIPHER_ALGORITHM);
+ reportException(e);
+ } catch (NoSuchPaddingException e) {
+ Log.e(TAG, "encrypt had a padding exception");
+ reportException(e);
+ } catch (InvalidKeyException e) {
+ Log.e(TAG, "encrypt received an invalid key");
+ reportException(e);
+ } catch (BadPaddingException e) {
+ Log.e(TAG, "encrypt had a padding problem");
+ reportException(e);
+ } catch (IllegalBlockSizeException e) {
+ Log.e(TAG, "encrypt had an illegal block size");
+ reportException(e);
+ }
+ return encryptedData;
+ }
+
+ private byte[] decrypt(EncryptedData encryptedData) {
+ byte[] decryptedData = null;
+ try {
+ Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM);
+ GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, encryptedData.getIv());
+ SecretKey secretKeyReference = getOrCreateSecretKey(encryptedData.getKeyAlias());
+ if (secretKeyReference != null) {
+ cipher.init(Cipher.DECRYPT_MODE, secretKeyReference, spec);
+ decryptedData = cipher.doFinal(encryptedData.getEncryptedData());
+ }
+ } catch (NoSuchAlgorithmException e) {
+ Log.e(TAG, "decrypt could not find cipher algorithm " + CIPHER_ALGORITHM);
+ reportException(e);
+ } catch (NoSuchPaddingException e) {
+ Log.e(TAG, "decrypt could not find padding algorithm");
+ reportException(e);
+ } catch (IllegalBlockSizeException e) {
+ Log.e(TAG, "decrypt had a illegal block size");
+ reportException(e);
+ } catch (BadPaddingException e) {
+ Log.e(TAG, "decrypt had bad padding");
+ reportException(e);
+ } catch (InvalidKeyException e) {
+ Log.e(TAG, "decrypt had an invalid key");
+ reportException(e);
+ } catch (InvalidAlgorithmParameterException e) {
+ Log.e(TAG, "decrypt had an invalid algorithm parameter");
+ reportException(e);
+ }
+ return decryptedData;
+ }
+
+ private SecretKey getOrCreateSecretKey(String keyAlias) {
+ SecretKey secretKey = null;
+ try {
+ KeyStore keyStore = KeyStore.getInstance(KEY_STORE);
+ keyStore.load(null);
+ if (keyStore.containsAlias(keyAlias)) { // The key exists in key store. Get the key.
+ KeyStore.SecretKeyEntry secretKeyEntry = (KeyStore.SecretKeyEntry) keyStore
+ .getEntry(keyAlias, null);
+ if (secretKeyEntry != null) {
+ secretKey = secretKeyEntry.getSecretKey();
+ } else {
+ reportException(new Exception("keystore contains the alias and the secret key "
+ + "entry was null"));
+ }
+ } else { // The key does not exist in key store. Create the key and store it.
+ KeyGenerator keyGenerator = KeyGenerator
+ .getInstance(KeyProperties.KEY_ALGORITHM_AES, KEY_STORE);
+
+ KeyGenParameterSpec keyGenParameterSpec = new KeyGenParameterSpec.Builder(keyAlias,
+ KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
+ .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
+ .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
+ .build();
+
+ keyGenerator.init(keyGenParameterSpec);
+ secretKey = keyGenerator.generateKey();
+ }
+ } catch (CertificateException e) {
+ Log.e(TAG, "getOrCreateSecretKey had a certificate exception.");
+ reportException(e);
+ } catch (InvalidAlgorithmParameterException e) {
+ Log.e(TAG, "getOrCreateSecretKey had an invalid algorithm parameter");
+ reportException(e);
+ } catch (IOException e) {
+ Log.e(TAG, "getOrCreateSecretKey had an IO exception.");
+ reportException(e);
+ } catch (KeyStoreException e) {
+ Log.e(TAG, "getOrCreateSecretKey cannot find the keystore: " + KEY_STORE);
+ reportException(e);
+ } catch (NoSuchAlgorithmException e) {
+ Log.e(TAG, "getOrCreateSecretKey cannot find algorithm");
+ reportException(e);
+ } catch (NoSuchProviderException e) {
+ Log.e(TAG, "getOrCreateSecretKey cannot find crypto provider");
+ reportException(e);
+ } catch (UnrecoverableEntryException e) {
+ Log.e(TAG, "getOrCreateSecretKey had an unrecoverable entry exception.");
+ reportException(e);
+ }
+ return secretKey;
+ }
+
+ private void writeIntegrityData(EncryptedData encryptedData, File file) {
+ try (FileOutputStream fos = new FileOutputStream(file);
+ ObjectOutputStream oos = new ObjectOutputStream(fos)) {
+ oos.writeObject(encryptedData);
+ } catch (FileNotFoundException e) {
+ Log.e(TAG, "writeIntegrityData could not find the integrity file");
+ reportException(e);
+ } catch (IOException e) {
+ Log.e(TAG, "writeIntegrityData had an IO exception");
+ reportException(e);
+ }
+ }
+
+ private EncryptedData readIntegrityData(File file) {
+ try (FileInputStream fis = new FileInputStream(file);
+ ObjectInputStream ois = new ObjectInputStream(fis)) {
+ return (EncryptedData) ois.readObject();
+ } catch (FileNotFoundException e) {
+ Log.e(TAG, "readIntegrityData could not find integrity file");
+ reportException(e);
+ } catch (IOException e) {
+ Log.e(TAG, "readIntegrityData had an IO exception");
+ reportException(e);
+ } catch (ClassNotFoundException e) {
+ Log.e(TAG, "readIntegrityData could not find the class EncryptedData");
+ reportException(e);
+ }
+ return null;
+ }
+
+ private boolean constantTimeEquals(byte[] a, byte[] b) {
+ if (a == null && b == null) {
+ return true;
+ }
+
+ if (a == null || b == null || a.length != b.length) {
+ return false;
+ }
+
+ byte differenceAccumulator = 0;
+ for (int i = 0; i < a.length; ++i) {
+ differenceAccumulator |= a[i] ^ b[i];
+ }
+ return (differenceAccumulator == 0);
+ }
+
+ /* TODO(b/128526030): Remove this error reporting code upon resolving the bug. */
+ private static final boolean REQUEST_BUG_REPORT = false;
+ private void reportException(Exception exception) {
+ Log.wtf(TAG, "An irrecoverable key store error was encountered. "
+ + "KEYSTORE_FAILURE_RETURN_VALUE is set to " + KEYSTORE_FAILURE_RETURN_VALUE);
+ if (REQUEST_BUG_REPORT) {
+ SystemProperties.set("dumpstate.options", "bugreportwifi");
+ SystemProperties.set("ctl.start", "bugreport");
+ }
+ }
+}
diff --git a/service/java/com/android/server/wifi/util/EncryptedData.java b/service/java/com/android/server/wifi/util/EncryptedData.java
new file mode 100644
index 000000000..468f28ec0
--- /dev/null
+++ b/service/java/com/android/server/wifi/util/EncryptedData.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright (C) 2019 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.wifi.util;
+
+import java.io.Serializable;
+
+/**
+ * A class to store data created by {@link DataIntegrityChecker}.
+ */
+public class EncryptedData implements Serializable {
+ private static final long serialVersionUID = 1337L;
+
+ private byte[] mEncryptedData;
+ private byte[] mIv;
+ private String mKeyAlias;
+
+ public EncryptedData(byte[] encryptedData, byte[] iv, String keyAlias) {
+ mEncryptedData = encryptedData;
+ mIv = iv;
+ mKeyAlias = keyAlias;
+ }
+
+ public byte[] getEncryptedData() {
+ return mEncryptedData;
+ }
+
+ public byte[] getIv() {
+ return mIv;
+ }
+
+ public String getKeyAlias() {
+ return mKeyAlias;
+ }
+}
diff --git a/tests/wifitests/src/com/android/server/wifi/util/DataIntegrityCheckerTest.java b/tests/wifitests/src/com/android/server/wifi/util/DataIntegrityCheckerTest.java
new file mode 100644
index 000000000..b7076988b
--- /dev/null
+++ b/tests/wifitests/src/com/android/server/wifi/util/DataIntegrityCheckerTest.java
@@ -0,0 +1,88 @@
+/*
+ * Copyright (C) 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.server.wifi.util;
+
+import static org.junit.Assert.*;
+
+import org.junit.Ignore;
+import org.junit.Test;
+
+import java.io.File;
+import java.security.DigestException;
+
+/**
+ * Unit tests for {@link com.android.server.wifi.util.DataIntegrityChecker}.
+ */
+public class DataIntegrityCheckerTest {
+ private static byte[] sGoodData = {1, 2, 3, 4};
+ private static byte[] sBadData = {5, 6, 7, 8};
+
+ /**
+ * Verify that updating the integrity token with known data and alias will
+ * pass the integrity test. This test ensure the expected outcome for
+ * unedited data succeeds.
+ *
+ * @throws Exception
+ */
+ @Test
+ @Ignore
+ public void testIntegrityWithKnownDataAndKnownAlias() throws Exception {
+ File integrityFile = File.createTempFile("testIntegrityWithKnownDataAndKnownAlias",
+ ".tmp");
+ DataIntegrityChecker dataIntegrityChecker = new DataIntegrityChecker(
+ integrityFile.getParent());
+ dataIntegrityChecker.update(sGoodData);
+ assertTrue(dataIntegrityChecker.isOk(sGoodData));
+ }
+
+ /**
+ * Verify that checking the integrity of unknown data and a known alias
+ * will fail the integrity test. This test ensure the expected failure for
+ * altered data, in fact, fails.
+ *
+ *
+ * @throws Exception
+ */
+ @Test
+ @Ignore
+ public void testIntegrityWithUnknownDataAndKnownAlias() throws Exception {
+ File integrityFile = File.createTempFile("testIntegrityWithUnknownDataAndKnownAlias",
+ ".tmp");
+ DataIntegrityChecker dataIntegrityChecker = new DataIntegrityChecker(
+ integrityFile.getParent());
+ dataIntegrityChecker.update(sGoodData);
+ assertFalse(dataIntegrityChecker.isOk(sBadData));
+ }
+
+ /**
+ * Verify a corner case where integrity of data that has never been
+ * updated passes and adds the token to the keystore.
+ *
+ * @throws Exception
+ */
+ @Test(expected = DigestException.class)
+ @Ignore
+ public void testIntegrityWithoutUpdate() throws Exception {
+ File tmpFile = File.createTempFile("testIntegrityWithoutUpdate", ".tmp");
+
+ DataIntegrityChecker dataIntegrityChecker = new DataIntegrityChecker(
+ tmpFile.getAbsolutePath());
+
+ // the integrity data is not known, so isOk throws a DigestException
+ assertTrue(dataIntegrityChecker.isOk(sGoodData));
+ }
+}