diff --git a/Android.bp b/Android.bp index 781f51428e533..3754c81f9e04b 100644 --- a/Android.bp +++ b/Android.bp @@ -670,6 +670,7 @@ java_library { "libphonenumber-platform", "nist-sip", "tagsoup", + "rappor", ], dxflags: ["--core-library"], } diff --git a/core/java/android/privacy/DifferentialPrivacyConfig.java b/core/java/android/privacy/DifferentialPrivacyConfig.java new file mode 100644 index 0000000000000..e14893e7dd031 --- /dev/null +++ b/core/java/android/privacy/DifferentialPrivacyConfig.java @@ -0,0 +1,34 @@ +/* + * Copyright 2017 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 android.privacy; + +/** + * An interface for differential privacy configuration. + * {@link DifferentialPrivacyEncoder} will apply this configuration to do differential privacy + * encoding. + * + * @hide + */ +public interface DifferentialPrivacyConfig { + + /** + * Returns the name of the algorithm used in differential privacy config. + * + * @return The name of the algorithm + */ + String getAlgorithm(); +} diff --git a/core/java/android/privacy/DifferentialPrivacyEncoder.java b/core/java/android/privacy/DifferentialPrivacyEncoder.java new file mode 100644 index 0000000000000..9355d6a5c723c --- /dev/null +++ b/core/java/android/privacy/DifferentialPrivacyEncoder.java @@ -0,0 +1,78 @@ +/* + * Copyright 2017 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 android.privacy; + +/** + * An interface for differential privacy encoder. + * Applications can use it to convert privacy sensitive data to privacy protected report. + * There is no decoder implemented in Android as it is not possible decode a single report by + * design. + * + *

Each type of log should have its own encoder, otherwise it may leak + * some information about Permanent Randomized Response(PRR, is used to create a “noisy” + * answer which is memoized by the client and permanently reused in place of the real answer). + * + *

Some encoders may not support all encoding methods, and it will throw {@link + * UnsupportedOperationException} if you call unsupported encoding method. + * + *

WARNING: Privacy protection works only when encoder uses a suitable DP configuration, + * and the configuration and algorithm that is suitable is highly dependent on the use case. + * If the configuration is not suitable for the use case, it may hurt privacy or utility or both. + * + * @hide + */ +public interface DifferentialPrivacyEncoder { + + /** + * Apply differential privacy to encode a string. + * + * @param original An arbitrary string + * @return Differential privacy encoded bytes derived from the string + */ + byte[] encodeString(String original); + + /** + * Apply differential privacy to encode a boolean. + * + * @param original An arbitrary boolean. + * @return Differential privacy encoded bytes derived from the boolean + */ + byte[] encodeBoolean(boolean original); + + /** + * Apply differential privacy to encode sequence of bytes. + * + * @param original An arbitrary byte array. + * @return Differential privacy encoded bytes derived from the bytes + */ + byte[] encodeBits(byte[] original); + + /** + * Returns the configuration that this encoder is using. + */ + DifferentialPrivacyConfig getConfig(); + + /** + * Return True if the output from encoder is NOT securely randomized, otherwise encoder should + * be secure to randomize input. + * + * A non-secure encoder is intended only for testing only and must not be used to process + * real data. + * + */ + boolean isInsecureEncoderForTest(); +} diff --git a/core/java/android/privacy/internal/longitudinalreporting/LongitudinalReportingConfig.java b/core/java/android/privacy/internal/longitudinalreporting/LongitudinalReportingConfig.java new file mode 100644 index 0000000000000..ee910fc1a39df --- /dev/null +++ b/core/java/android/privacy/internal/longitudinalreporting/LongitudinalReportingConfig.java @@ -0,0 +1,107 @@ +/* + * Copyright 2017 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 android.privacy.internal.longitudinalreporting; + +import android.privacy.DifferentialPrivacyConfig; +import android.privacy.internal.rappor.RapporConfig; +import android.text.TextUtils; + +import com.android.internal.util.Preconditions; + +/** + * A class to store {@link LongitudinalReportingEncoder} configuration. + * + *

+ * + * @hide + */ +public class LongitudinalReportingConfig implements DifferentialPrivacyConfig { + + private static final String ALGORITHM_NAME = "LongitudinalReporting"; + + // Probability to flip input value. + private final double mProbabilityF; + + // Probability to override original value. + private final double mProbabilityP; + // Probability to override value with 1. + private final double mProbabilityQ; + + // IRR config to randomize original value + private final RapporConfig mIRRConfig; + + private final String mEncoderId; + + /** + * Constructor to create {@link LongitudinalReportingConfig} used for {@link + * LongitudinalReportingEncoder} + * + * @param encoderId Unique encoder id. + * @param probabilityF Probability F used in Longitudinal Reporting algorithm. + * @param probabilityP Probability P used in Longitudinal Reporting algorithm. This will be + * quantized to the nearest 1/256. + * @param probabilityQ Probability Q used in Longitudinal Reporting algorithm. This will be + * quantized to the nearest 1/256. + */ + public LongitudinalReportingConfig(String encoderId, double probabilityF, + double probabilityP, double probabilityQ) { + Preconditions.checkArgument(probabilityF >= 0 && probabilityF <= 1, + "probabilityF must be in range [0.0, 1.0]"); + this.mProbabilityF = probabilityF; + Preconditions.checkArgument(probabilityP >= 0 && probabilityP <= 1, + "probabilityP must be in range [0.0, 1.0]"); + this.mProbabilityP = probabilityP; + Preconditions.checkArgument(probabilityQ >= 0 && probabilityQ <= 1, + "probabilityQ must be in range [0.0, 1.0]"); + this.mProbabilityQ = probabilityQ; + Preconditions.checkArgument(!TextUtils.isEmpty(encoderId), "encoderId cannot be empty"); + mEncoderId = encoderId; + mIRRConfig = new RapporConfig(encoderId, 1, 0.0, probabilityF, 1 - probabilityF, 1, 1); + } + + @Override + public String getAlgorithm() { + return ALGORITHM_NAME; + } + + RapporConfig getIRRConfig() { + return mIRRConfig; + } + + double getProbabilityP() { + return mProbabilityP; + } + + double getProbabilityQ() { + return mProbabilityQ; + } + + String getEncoderId() { + return mEncoderId; + } + + @Override + public String toString() { + return String.format("EncoderId: %s, ProbabilityF: %.3f, ProbabilityP: %.3f" + + ", ProbabilityQ: %.3f", + mEncoderId, mProbabilityF, mProbabilityP, mProbabilityQ); + } +} diff --git a/core/java/android/privacy/internal/longitudinalreporting/LongitudinalReportingEncoder.java b/core/java/android/privacy/internal/longitudinalreporting/LongitudinalReportingEncoder.java new file mode 100644 index 0000000000000..219868d663d23 --- /dev/null +++ b/core/java/android/privacy/internal/longitudinalreporting/LongitudinalReportingEncoder.java @@ -0,0 +1,170 @@ +/* + * Copyright 2017 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 android.privacy.internal.longitudinalreporting; + +import android.privacy.DifferentialPrivacyEncoder; +import android.privacy.internal.rappor.RapporConfig; +import android.privacy.internal.rappor.RapporEncoder; + +import com.android.internal.annotations.VisibleForTesting; + +/** + * Differential privacy encoder by using Longitudinal Reporting algorithm. + * + * + * Notes: It supports encodeBoolean() only for now. + * + * + *

+ * Definition: + * PRR = Permanent Randomized Response + * IRR = Instantaneous Randomized response + * + * Algorithm: + * Step 1: Create long-term secrets x(ignoreOriginalInput)=Ber(P), y=Ber(Q), where Ber denotes + * Bernoulli distribution on {0, 1}, and we use it as a long-term secret, we implement Ber(x) by + * using PRR(2x, 0) when x < 1/2, PRR(2(1-x), 1) when x >= 1/2. + * Step 2: If x is 0, report IRR(F, original), otherwise report IRR(F, y) + *

+ * + * Reference: go/bit-reporting-with-longitudinal-privacy + * TODO: Add a public blog / site to explain how it works. + * + * @hide + */ +public class LongitudinalReportingEncoder implements DifferentialPrivacyEncoder { + + // Suffix that will be added to Rappor's encoder id. There's a (relatively) small risk some + // other Rappor encoder may re-use the same encoder id. + private static final String PRR1_ENCODER_ID = "prr1_encoder_id"; + private static final String PRR2_ENCODER_ID = "prr2_encoder_id"; + + private final LongitudinalReportingConfig mConfig; + + // IRR encoder to encode input value. + private final RapporEncoder mIRREncoder; + + // A value that used to replace original value as input, so there's always a chance we are + // doing IRR on a fake value not actual original value. + // Null if original value does not need to be replaced. + private final Boolean mFakeValue; + + // True if encoder is securely randomized. + private final boolean mIsSecure; + + /** + * Create {@link LongitudinalReportingEncoder} with + * {@link LongitudinalReportingConfig} provided. + * + * @param config Longitudinal Reporting parameters to encode input + * @param userSecret User generated secret that used to generate PRR + * @return {@link LongitudinalReportingEncoder} instance + */ + public static LongitudinalReportingEncoder createEncoder(LongitudinalReportingConfig config, + byte[] userSecret) { + return new LongitudinalReportingEncoder(config, true, userSecret); + } + + /** + * Create insecure {@link LongitudinalReportingEncoder} with + * {@link LongitudinalReportingConfig} provided. + * Should not use it to process sensitive data. + * + * @param config Rappor parameters to encode input. + * @return {@link LongitudinalReportingEncoder} instance. + */ + @VisibleForTesting + public static LongitudinalReportingEncoder createInsecureEncoderForTest( + LongitudinalReportingConfig config) { + return new LongitudinalReportingEncoder(config, false, null); + } + + private LongitudinalReportingEncoder(LongitudinalReportingConfig config, + boolean secureEncoder, byte[] userSecret) { + mConfig = config; + mIsSecure = secureEncoder; + final boolean ignoreOriginalInput = getLongTermRandomizedResult(config.getProbabilityP(), + secureEncoder, userSecret, config.getEncoderId() + PRR1_ENCODER_ID); + + if (ignoreOriginalInput) { + mFakeValue = getLongTermRandomizedResult(config.getProbabilityQ(), + secureEncoder, userSecret, config.getEncoderId() + PRR2_ENCODER_ID); + } else { + // Not using fake value, so IRR will be processed on real input value. + mFakeValue = null; + } + + final RapporConfig irrConfig = config.getIRRConfig(); + mIRREncoder = secureEncoder + ? RapporEncoder.createEncoder(irrConfig, userSecret) + : RapporEncoder.createInsecureEncoderForTest(irrConfig); + } + + @Override + public byte[] encodeString(String original) { + throw new UnsupportedOperationException(); + } + + @Override + public byte[] encodeBoolean(boolean original) { + if (mFakeValue != null) { + // Use the fake value generated in PRR. + original = mFakeValue.booleanValue(); + } + return mIRREncoder.encodeBoolean(original); + } + + @Override + public byte[] encodeBits(byte[] bits) { + throw new UnsupportedOperationException(); + } + + @Override + public LongitudinalReportingConfig getConfig() { + return mConfig; + } + + @Override + public boolean isInsecureEncoderForTest() { + return !mIsSecure; + } + + /** + * Get PRR result that with probability p is 1, probability 1-p is 0. + */ + @VisibleForTesting + public static boolean getLongTermRandomizedResult(double p, boolean secureEncoder, + byte[] userSecret, String encoderId) { + // Use Rappor to get PRR result. Rappor's P and Q are set to 0 and 1 so IRR will not be + // effective. + // As Rappor has rapporF/2 chance returns 0, rapporF/2 chance returns 1, and 1-rapporF + // chance returns original input. + // If p < 0.5, setting rapporF=2p and input=0 will make Rappor has p chance to return 1 + // P(output=1 | input=0) = rapporF/2 = 2p/2 = p. + // If p >= 0.5, setting rapporF=2(1-p) and input=1 will make Rappor has p chance + // to return 1. + // P(output=1 | input=1) = rapporF/2 + (1 - rapporF) = 2(1-p)/2 + (1 - 2(1-p)) = p. + final double effectiveF = p < 0.5f ? p * 2 : (1 - p) * 2; + final boolean prrInput = p < 0.5f ? false : true; + final RapporConfig prrConfig = new RapporConfig(encoderId, 1, effectiveF, + 0, 1, 1, 1); + final RapporEncoder encoder = secureEncoder + ? RapporEncoder.createEncoder(prrConfig, userSecret) + : RapporEncoder.createInsecureEncoderForTest(prrConfig); + return encoder.encodeBoolean(prrInput)[0] > 0; + } +} diff --git a/core/java/android/privacy/internal/rappor/RapporConfig.java b/core/java/android/privacy/internal/rappor/RapporConfig.java new file mode 100644 index 0000000000000..221999bf0a95b --- /dev/null +++ b/core/java/android/privacy/internal/rappor/RapporConfig.java @@ -0,0 +1,87 @@ +/* + * Copyright 2017 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 android.privacy.internal.rappor; + +import android.privacy.DifferentialPrivacyConfig; +import android.text.TextUtils; + +import com.android.internal.util.Preconditions; + +/** + * A class to store {@link RapporEncoder} config. + * + * @hide + */ +public class RapporConfig implements DifferentialPrivacyConfig { + + private static final String ALGORITHM_NAME = "Rappor"; + + final String mEncoderId; + final int mNumBits; + final double mProbabilityF; + final double mProbabilityP; + final double mProbabilityQ; + final int mNumCohorts; + final int mNumBloomHashes; + + /** + * Constructor for {@link RapporConfig}. + * + * @param encoderId Unique id for encoder. + * @param numBits Number of bits to be encoded in Rappor algorithm. + * @param probabilityF Probability F that used in Rappor algorithm. This will be + * quantized to the nearest 1/128. + * @param probabilityP Probability P that used in Rappor algorithm. + * @param probabilityQ Probability Q that used in Rappor algorithm. + * @param numCohorts Number of cohorts that used in Rappor algorithm. + * @param numBloomHashes Number of bloom hashes that used in Rappor algorithm. + */ + public RapporConfig(String encoderId, int numBits, double probabilityF, + double probabilityP, double probabilityQ, int numCohorts, int numBloomHashes) { + Preconditions.checkArgument(!TextUtils.isEmpty(encoderId), "encoderId cannot be empty"); + this.mEncoderId = encoderId; + Preconditions.checkArgument(numBits > 0, "numBits needs to be > 0"); + this.mNumBits = numBits; + Preconditions.checkArgument(probabilityF >= 0 && probabilityF <= 1, + "probabilityF must be in range [0.0, 1.0]"); + this.mProbabilityF = probabilityF; + Preconditions.checkArgument(probabilityP >= 0 && probabilityP <= 1, + "probabilityP must be in range [0.0, 1.0]"); + this.mProbabilityP = probabilityP; + Preconditions.checkArgument(probabilityQ >= 0 && probabilityQ <= 1, + "probabilityQ must be in range [0.0, 1.0]"); + this.mProbabilityQ = probabilityQ; + Preconditions.checkArgument(numCohorts > 0, "numCohorts needs to be > 0"); + this.mNumCohorts = numCohorts; + Preconditions.checkArgument(numBloomHashes > 0, "numBloomHashes needs to be > 0"); + this.mNumBloomHashes = numBloomHashes; + } + + @Override + public String getAlgorithm() { + return ALGORITHM_NAME; + } + + @Override + public String toString() { + return String.format( + "EncoderId: %s, NumBits: %d, ProbabilityF: %.3f, ProbabilityP: %.3f" + + ", ProbabilityQ: %.3f, NumCohorts: %d, NumBloomHashes: %d", + mEncoderId, mNumBits, mProbabilityF, mProbabilityP, mProbabilityQ, + mNumCohorts, mNumBloomHashes); + } +} diff --git a/core/java/android/privacy/internal/rappor/RapporEncoder.java b/core/java/android/privacy/internal/rappor/RapporEncoder.java new file mode 100644 index 0000000000000..2eca4c98d235d --- /dev/null +++ b/core/java/android/privacy/internal/rappor/RapporEncoder.java @@ -0,0 +1,125 @@ +/* + * Copyright 2017 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 android.privacy.internal.rappor; + +import android.privacy.DifferentialPrivacyEncoder; + +import com.google.android.rappor.Encoder; + +import java.security.SecureRandom; +import java.util.Random; + +/** + * Differential privacy encoder by using + * RAPPOR + * algorithm. + * + * @hide + */ +public class RapporEncoder implements DifferentialPrivacyEncoder { + + // Hard-coded seed and secret for insecure encoder + private static final long INSECURE_RANDOM_SEED = 0x12345678L; + private static final byte[] INSECURE_SECRET = new byte[]{ + (byte) 0xD7, (byte) 0x68, (byte) 0x99, (byte) 0x93, + (byte) 0x94, (byte) 0x13, (byte) 0x53, (byte) 0x54, + (byte) 0xFE, (byte) 0xD0, (byte) 0x7E, (byte) 0x54, + (byte) 0xFE, (byte) 0xD0, (byte) 0x7E, (byte) 0x54, + (byte) 0xD7, (byte) 0x68, (byte) 0x99, (byte) 0x93, + (byte) 0x94, (byte) 0x13, (byte) 0x53, (byte) 0x54, + (byte) 0xFE, (byte) 0xD0, (byte) 0x7E, (byte) 0x54, + (byte) 0xFE, (byte) 0xD0, (byte) 0x7E, (byte) 0x54, + (byte) 0xD7, (byte) 0x68, (byte) 0x99, (byte) 0x93, + (byte) 0x94, (byte) 0x13, (byte) 0x53, (byte) 0x54, + (byte) 0xFE, (byte) 0xD0, (byte) 0x7E, (byte) 0x54, + (byte) 0xFE, (byte) 0xD0, (byte) 0x7E, (byte) 0x54 + }; + private static final SecureRandom sSecureRandom = new SecureRandom(); + + private final RapporConfig mConfig; + + // Rappor encoder + private final Encoder mEncoder; + // True if encoder is secure (seed is securely randomized) + private final boolean mIsSecure; + + + private RapporEncoder(RapporConfig config, boolean secureEncoder, byte[] userSecret) { + mConfig = config; + mIsSecure = secureEncoder; + final Random random; + if (secureEncoder) { + // Use SecureRandom as random generator. + random = sSecureRandom; + } else { + // Hard-coded random generator, to have deterministic result. + random = new Random(INSECURE_RANDOM_SEED); + userSecret = INSECURE_SECRET; + } + mEncoder = new Encoder(random, null, null, + userSecret, config.mEncoderId, config.mNumBits, + config.mProbabilityF, config.mProbabilityP, config.mProbabilityQ, + config.mNumCohorts, config.mNumBloomHashes); + } + + /** + * Create {@link RapporEncoder} with {@link RapporConfig} and user secret provided. + * + * @param config Rappor parameters to encode input. + * @param userSecret Per device unique secret key. + * @return {@link RapporEncoder} instance. + */ + public static RapporEncoder createEncoder(RapporConfig config, byte[] userSecret) { + return new RapporEncoder(config, true, userSecret); + } + + /** + * Create insecure {@link RapporEncoder} with {@link RapporConfig} provided. + * Should not use it to process sensitive data. + * + * @param config Rappor parameters to encode input. + * @return {@link RapporEncoder} instance. + */ + public static RapporEncoder createInsecureEncoderForTest(RapporConfig config) { + return new RapporEncoder(config, false, null); + } + + @Override + public byte[] encodeString(String original) { + return mEncoder.encodeString(original); + } + + @Override + public byte[] encodeBoolean(boolean original) { + return mEncoder.encodeBoolean(original); + } + + @Override + public byte[] encodeBits(byte[] bits) { + return mEncoder.encodeBits(bits); + } + + @Override + public RapporConfig getConfig() { + return mConfig; + } + + @Override + public boolean isInsecureEncoderForTest() { + return !mIsSecure; + } +} diff --git a/core/tests/privacytests/Android.mk b/core/tests/privacytests/Android.mk new file mode 100644 index 0000000000000..7b112259c9d7a --- /dev/null +++ b/core/tests/privacytests/Android.mk @@ -0,0 +1,16 @@ +LOCAL_PATH:= $(call my-dir) +include $(CLEAR_VARS) + +# We only want this apk build for tests. +LOCAL_MODULE_TAGS := tests + +# Include all test java files. +LOCAL_SRC_FILES := \ + $(call all-java-files-under, src) + +LOCAL_STATIC_JAVA_LIBRARIES := junit rappor-tests android-support-test + +LOCAL_JAVA_LIBRARIES := android.test.runner +LOCAL_PACKAGE_NAME := FrameworksPrivacyLibraryTests + +include $(BUILD_PACKAGE) diff --git a/core/tests/privacytests/AndroidManifest.xml b/core/tests/privacytests/AndroidManifest.xml new file mode 100644 index 0000000000000..a0e52814bdcdd --- /dev/null +++ b/core/tests/privacytests/AndroidManifest.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + diff --git a/core/tests/privacytests/src/android/privacy/LongitudinalReportingEncoderTest.java b/core/tests/privacytests/src/android/privacy/LongitudinalReportingEncoderTest.java new file mode 100644 index 0000000000000..91664381efe04 --- /dev/null +++ b/core/tests/privacytests/src/android/privacy/LongitudinalReportingEncoderTest.java @@ -0,0 +1,392 @@ +/* + * Copyright (C) 2017 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 android.privacy; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import android.privacy.internal.longitudinalreporting.LongitudinalReportingConfig; +import android.privacy.internal.longitudinalreporting.LongitudinalReportingEncoder; + +import android.support.test.filters.SmallTest; +import android.support.test.runner.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +/** + * Unit test for the {@link LongitudinalReportingEncoder}. + * + * As {@link LongitudinalReportingEncoder} is based on Rappor, + * most cases are covered by Rappor tests already. + */ +@RunWith(AndroidJUnit4.class) +@SmallTest +public class LongitudinalReportingEncoderTest { + + @Test + public void testLongitudinalReportingEncoder_config() throws Exception { + final LongitudinalReportingConfig config = new LongitudinalReportingConfig( + "Foo", // encoderId + 0.4, // probabilityF + 0.25, // probabilityP + 1); // probabilityQ + final LongitudinalReportingEncoder encoder = + LongitudinalReportingEncoder.createInsecureEncoderForTest( + config); + assertEquals("LongitudinalReporting", encoder.getConfig().getAlgorithm()); + assertEquals( + "EncoderId: Foo, ProbabilityF: 0.400, ProbabilityP: 0.250, ProbabilityQ: 1.000", + encoder.getConfig().toString()); + } + + @Test + public void testLongitudinalReportingEncoder_basicIRRTest() throws Exception { + // Test if IRR can generate expected result when seed is fixed (insecure encoder) + final LongitudinalReportingConfig config = new LongitudinalReportingConfig( + "Foo", // encoderId + 0.4, // probabilityF + 0, // probabilityP + 0); // probabilityQ + // Use insecure encoder here to make sure seed is set. + final LongitudinalReportingEncoder encoder = + LongitudinalReportingEncoder.createInsecureEncoderForTest( + config); + assertEquals(1, encoder.encodeBoolean(true)[0]); + assertEquals(0, encoder.encodeBoolean(true)[0]); + assertEquals(1, encoder.encodeBoolean(true)[0]); + assertEquals(1, encoder.encodeBoolean(true)[0]); + assertEquals(1, encoder.encodeBoolean(true)[0]); + assertEquals(1, encoder.encodeBoolean(true)[0]); + assertEquals(0, encoder.encodeBoolean(true)[0]); + assertEquals(1, encoder.encodeBoolean(true)[0]); + assertEquals(1, encoder.encodeBoolean(true)[0]); + assertEquals(1, encoder.encodeBoolean(true)[0]); + + assertEquals(0, encoder.encodeBoolean(false)[0]); + assertEquals(1, encoder.encodeBoolean(false)[0]); + assertEquals(1, encoder.encodeBoolean(false)[0]); + assertEquals(0, encoder.encodeBoolean(false)[0]); + assertEquals(0, encoder.encodeBoolean(false)[0]); + assertEquals(0, encoder.encodeBoolean(false)[0]); + assertEquals(1, encoder.encodeBoolean(false)[0]); + assertEquals(0, encoder.encodeBoolean(false)[0]); + assertEquals(0, encoder.encodeBoolean(false)[0]); + assertEquals(1, encoder.encodeBoolean(false)[0]); + + // Test if IRR returns original result when f = 0 + final LongitudinalReportingConfig config2 = new LongitudinalReportingConfig( + "Foo", // encoderId + 0, // probabilityF + 0, // probabilityP + 0); // probabilityQ + final LongitudinalReportingEncoder encoder2 + = LongitudinalReportingEncoder.createEncoder( + config2, makeTestingUserSecret("secret2")); + for (int i = 0; i < 10; i++) { + assertEquals(1, encoder2.encodeBoolean(true)[0]); + } + for (int i = 0; i < 10; i++) { + assertEquals(0, encoder2.encodeBoolean(false)[0]); + } + + // Test if IRR returns opposite result when f = 1 + final LongitudinalReportingConfig config3 = new LongitudinalReportingConfig( + "Foo", // encoderId + 1, // probabilityF + 0, // probabilityP + 0); // probabilityQ + final LongitudinalReportingEncoder encoder3 + = LongitudinalReportingEncoder.createEncoder( + config3, makeTestingUserSecret("secret3")); + for (int i = 0; i < 10; i++) { + assertEquals(1, encoder3.encodeBoolean(false)[0]); + } + for (int i = 0; i < 10; i++) { + assertEquals(0, encoder3.encodeBoolean(true)[0]); + } + } + + @Test + public void testLongitudinalReportingEncoder_basicPRRTest() throws Exception { + // Should always return original value when p = 0 + for (int i = 0; i < 10; i++) { + for (int j = 0; j < 10; j++) { + final LongitudinalReportingConfig config1 = new LongitudinalReportingConfig( + "Foo" + i, // encoderId + 0, // probabilityF + 0, // probabilityP + 0); // probabilityQ + final LongitudinalReportingEncoder encoder1 + = LongitudinalReportingEncoder.createEncoder( + config1, makeTestingUserSecret("encoder" + j)); + assertEquals(0, encoder1.encodeBoolean(false)[0]); + assertEquals(0, encoder1.encodeBoolean(false)[0]); + assertEquals(0, encoder1.encodeBoolean(false)[0]); + assertEquals(0, encoder1.encodeBoolean(false)[0]); + assertEquals(0, encoder1.encodeBoolean(false)[0]); + assertEquals(1, encoder1.encodeBoolean(true)[0]); + assertEquals(1, encoder1.encodeBoolean(true)[0]); + assertEquals(1, encoder1.encodeBoolean(true)[0]); + assertEquals(1, encoder1.encodeBoolean(true)[0]); + assertEquals(1, encoder1.encodeBoolean(true)[0]); + } + } + + // Should always return false when p = 1, q = 0 + for (int i = 0; i < 10; i++) { + for (int j = 0; j < 10; j++) { + final LongitudinalReportingConfig config2 = new LongitudinalReportingConfig( + "Foo" + i, // encoderId + 0, // probabilityF + 1, // probabilityP + 0); // probabilityQ + final LongitudinalReportingEncoder encoder2 + = LongitudinalReportingEncoder.createEncoder( + config2, makeTestingUserSecret("encoder" + j)); + assertEquals(0, encoder2.encodeBoolean(false)[0]); + assertEquals(0, encoder2.encodeBoolean(false)[0]); + assertEquals(0, encoder2.encodeBoolean(false)[0]); + assertEquals(0, encoder2.encodeBoolean(false)[0]); + assertEquals(0, encoder2.encodeBoolean(false)[0]); + assertEquals(0, encoder2.encodeBoolean(true)[0]); + assertEquals(0, encoder2.encodeBoolean(true)[0]); + assertEquals(0, encoder2.encodeBoolean(true)[0]); + assertEquals(0, encoder2.encodeBoolean(true)[0]); + assertEquals(0, encoder2.encodeBoolean(true)[0]); + } + } + + // Should always return true when p = 1, q = 1 + for (int i = 0; i < 10; i++) { + for (int j = 0; j < 10; j++) { + final LongitudinalReportingConfig config3 = new LongitudinalReportingConfig( + "Foo" + i, // encoderId + 0, // probabilityF + 1, // probabilityP + 1); // probabilityQ + final LongitudinalReportingEncoder encoder3 + = LongitudinalReportingEncoder.createEncoder( + config3, makeTestingUserSecret("encoder" + j)); + assertEquals(1, encoder3.encodeBoolean(false)[0]); + assertEquals(1, encoder3.encodeBoolean(false)[0]); + assertEquals(1, encoder3.encodeBoolean(false)[0]); + assertEquals(1, encoder3.encodeBoolean(false)[0]); + assertEquals(1, encoder3.encodeBoolean(false)[0]); + assertEquals(1, encoder3.encodeBoolean(true)[0]); + assertEquals(1, encoder3.encodeBoolean(true)[0]); + assertEquals(1, encoder3.encodeBoolean(true)[0]); + assertEquals(1, encoder3.encodeBoolean(true)[0]); + assertEquals(1, encoder3.encodeBoolean(true)[0]); + } + } + + // PRR should return different value when encoder id is changed + boolean hasFalseResult1 = false; + boolean hasTrueResult1 = false; + for (int i = 0; i < 50; i++) { + boolean firstResult = false; + for (int j = 0; j < 10; j++) { + final LongitudinalReportingConfig config4 = new LongitudinalReportingConfig( + "Foo" + i, // encoderId + 0, // probabilityF + 1, // probabilityP + 0.5); // probabilityQ + final LongitudinalReportingEncoder encoder4 + = LongitudinalReportingEncoder.createEncoder( + config4, makeTestingUserSecret("encoder4")); + boolean encodedFalse = encoder4.encodeBoolean(false)[0] > 0; + boolean encodedTrue = encoder4.encodeBoolean(true)[0] > 0; + // PRR should always give the same value when all parameters are the same + assertEquals(encodedTrue, encodedFalse); + if (j == 0) { + firstResult = encodedTrue; + } else { + assertEquals(firstResult, encodedTrue); + } + if (encodedTrue) { + hasTrueResult1 = true; + } else { + hasFalseResult1 = true; + } + } + } + // Ensure it has both true and false results when encoder id is different + assertTrue(hasTrueResult1); + assertTrue(hasFalseResult1); + + // PRR should give different value when secret is changed + boolean hasFalseResult2 = false; + boolean hasTrueResult2 = false; + for (int i = 0; i < 50; i++) { + boolean firstResult = false; + for (int j = 0; j < 10; j++) { + final LongitudinalReportingConfig config5 = new LongitudinalReportingConfig( + "Foo", // encoderId + 0, // probabilityF + 1, // probabilityP + 0.5); // probabilityQ + final LongitudinalReportingEncoder encoder5 + = LongitudinalReportingEncoder.createEncoder( + config5, makeTestingUserSecret("encoder" + i)); + boolean encodedFalse = encoder5.encodeBoolean(false)[0] > 0; + boolean encodedTrue = encoder5.encodeBoolean(true)[0] > 0; + // PRR should always give the same value when parameters are the same + assertEquals(encodedTrue, encodedFalse); + if (j == 0) { + firstResult = encodedTrue; + } else { + assertEquals(firstResult, encodedTrue); + } + if (encodedTrue) { + hasTrueResult2 = true; + } else { + hasFalseResult2 = true; + } + } + } + // Ensure it has both true and false results when encoder id is different + assertTrue(hasTrueResult2); + assertTrue(hasFalseResult2); + + // Confirm if PRR randomizer is working correctly + final int n1 = 1000; + final double p1 = 0.8; + final double expectedTrueSum1 = n1 * p1; + final double valueRange1 = 5 * Math.sqrt(n1 * p1 * (1 - p1)); + int trueSum1 = 0; + for (int i = 0; i < n1; i++) { + final LongitudinalReportingConfig config6 = new LongitudinalReportingConfig( + "Foo", // encoderId + 0, // probabilityF + p1, // probabilityP + 1); // probabilityQ + final LongitudinalReportingEncoder encoder6 + = LongitudinalReportingEncoder.createEncoder( + config6, makeTestingUserSecret("encoder" + i)); + boolean encodedFalse = encoder6.encodeBoolean(false)[0] > 0; + if (encodedFalse) { + trueSum1 += 1; + } + } + // Total number of true(s) should be around the mean (1000 * 0.8) + assertTrue(trueSum1 < expectedTrueSum1 + valueRange1); + assertTrue(trueSum1 > expectedTrueSum1 - valueRange1); + + // Confirm if PRR randomizer is working correctly + final int n2 = 1000; + final double p2 = 0.2; + final double expectedTrueSum2 = n2 * p2; + final double valueRange2 = 5 * Math.sqrt(n2 * p2 * (1 - p2)); + int trueSum2 = 0; + for (int i = 0; i < n2; i++) { + final LongitudinalReportingConfig config7 = new LongitudinalReportingConfig( + "Foo", // encoderId + 0, // probabilityF + p2, // probabilityP + 1); // probabilityQ + final LongitudinalReportingEncoder encoder7 + = LongitudinalReportingEncoder.createEncoder( + config7, makeTestingUserSecret("encoder" + i)); + boolean encodedFalse = encoder7.encodeBoolean(false)[0] > 0; + if (encodedFalse) { + trueSum2 += 1; + } + } + // Total number of true(s) should be around the mean (1000 * 0.2) + assertTrue(trueSum2 < expectedTrueSum2 + valueRange2); + assertTrue(trueSum2 > expectedTrueSum2 - valueRange2); + } + + @Test + public void testLongitudinalReportingEncoder_basicIRRwithPRRTest() throws Exception { + // Verify PRR result will run IRR + boolean hasFalseResult1 = false; + boolean hasTrueResult1 = false; + for (int i = 0; i < 50; i++) { + final LongitudinalReportingConfig config1 = new LongitudinalReportingConfig( + "Foo", // encoderId + 0.5, // probabilityF + 1, // probabilityP + 1); // probabilityQ + final LongitudinalReportingEncoder encoder1 + = LongitudinalReportingEncoder.createEncoder( + config1, makeTestingUserSecret("encoder1")); + if (encoder1.encodeBoolean(false)[0] > 0) { + hasTrueResult1 = true; + } else { + hasFalseResult1 = true; + } + } + assertTrue(hasTrueResult1); + assertTrue(hasFalseResult1); + + // When secret is different, some device should use PRR result, some should use IRR result + boolean hasFalseResult2 = false; + boolean hasTrueResult2 = false; + for (int i = 0; i < 50; i++) { + final LongitudinalReportingConfig config2 = new LongitudinalReportingConfig( + "Foo", // encoderId + 1, // probabilityF + 0.5, // probabilityP + 1); // probabilityQ + final LongitudinalReportingEncoder encoder2 + = LongitudinalReportingEncoder.createEncoder( + config2, makeTestingUserSecret("encoder" + i)); + if (encoder2.encodeBoolean(false)[0] > 0) { + hasTrueResult2 = true; + } else { + hasFalseResult2 = true; + } + } + assertTrue(hasTrueResult2); + assertTrue(hasFalseResult2); + } + + @Test + public void testLongTermRandomizedResult() throws Exception { + // Verify getLongTermRandomizedResult can return expected result when parameters are fixed. + final boolean[] expectedResult = + new boolean[]{true, false, true, true, true, + false, false, false, true, false, + false, false, false, true, true, + true, true, false, true, true, + true, true, false, true, true}; + for (int i = 0; i < 5; i++) { + for (int j = 0; j < 5; j++) { + boolean result = LongitudinalReportingEncoder.getLongTermRandomizedResult(0.5, + true, makeTestingUserSecret("secret" + i), "encoder" + j); + assertEquals(expectedResult[i * 5 + j], result); + } + } + } + + private static byte[] makeTestingUserSecret(String testingSecret) throws Exception { + // We generate the fake user secret by concatenating three copies of the + // 16 byte MD5 hash of the testingSecret string encoded in UTF 8. + MessageDigest md5 = MessageDigest.getInstance("MD5"); + byte[] digest = md5.digest(testingSecret.getBytes(StandardCharsets.UTF_8)); + assertEquals(16, digest.length); + return ByteBuffer.allocate(48).put(digest).put(digest).put(digest).array(); + } +} diff --git a/core/tests/privacytests/src/android/privacy/RapporEncoderTest.java b/core/tests/privacytests/src/android/privacy/RapporEncoderTest.java new file mode 100644 index 0000000000000..dad98b8e4a35d --- /dev/null +++ b/core/tests/privacytests/src/android/privacy/RapporEncoderTest.java @@ -0,0 +1,204 @@ +/* + * Copyright (C) 2017 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 android.privacy; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import android.privacy.internal.rappor.RapporConfig; +import android.privacy.internal.rappor.RapporEncoder; +import android.support.test.filters.SmallTest; +import android.support.test.runner.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +/** + * Unit test for the {@link RapporEncoder}. + * Most of the tests are done in external/rappor/client/javatest/ already. + * Tests here are just make sure the {@link RapporEncoder} wrap Rappor correctly. + */ +@RunWith(AndroidJUnit4.class) +@SmallTest +public class RapporEncoderTest { + + @Test + public void testRapporEncoder_config() throws Exception { + final RapporConfig config = new RapporConfig( + "Foo", // encoderId + 8, // numBits, + 13.0 / 128.0, // probabilityF + 0.25, // probabilityP + 0.75, // probabilityQ + 1, // numCohorts + 2); // numBloomHashes) + final RapporEncoder encoder = RapporEncoder.createEncoder(config, + makeTestingUserSecret("encoder1")); + assertEquals("Rappor", encoder.getConfig().getAlgorithm()); + assertEquals("EncoderId: Foo, NumBits: 8, ProbabilityF: 0.102, " + + "ProbabilityP: 0.250, ProbabilityQ: 0.750, NumCohorts: 1, " + + "NumBloomHashes: 2", encoder.getConfig().toString()); + } + + @Test + public void testRapporEncoder_basicIRRTest() throws Exception { + final RapporConfig config = new RapporConfig( + "Foo", // encoderId + 12, // numBits, + 0, // probabilityF + 0, // probabilityP + 1, // probabilityQ + 1, // numCohorts (so must be cohort 0) + 2); // numBloomHashes + // Use insecure encoder here as we want to get the exact output. + final RapporEncoder encoder = RapporEncoder.createInsecureEncoderForTest(config); + assertEquals(768, toLong(encoder.encodeString("Testing"))); + } + + @Test + public void testRapporEncoder_IRRWithPRR() throws Exception { + int numBits = 8; + final long inputValue = 254L; + final long prrValue = 250L; + final long prrAndIrrValue = 184L; + + final RapporConfig config1 = new RapporConfig( + "Foo", // encoderId + numBits, // numBits, + 0.25, // probabilityF + 0, // probabilityP + 1, // probabilityQ + 1, // numCohorts + 2); // numBloomHashes + // Use insecure encoder here as we want to get the exact output. + final RapporEncoder encoder1 = RapporEncoder.createInsecureEncoderForTest(config1); + // Verify that PRR is working as expected. + assertEquals(prrValue, toLong(encoder1.encodeBits(toBytes(inputValue)))); + assertTrue(encoder1.isInsecureEncoderForTest()); + + // Verify that IRR is working as expected. + final RapporConfig config2 = new RapporConfig( + "Foo", // encoderId + numBits, // numBits, + 0, // probabilityF + 0.3, // probabilityP + 0.7, // probabilityQ + 1, // numCohorts + 2); // numBloomHashes + // Use insecure encoder here as we want to get the exact output. + final RapporEncoder encoder2 = RapporEncoder.createInsecureEncoderForTest(config2); + assertEquals(prrAndIrrValue, toLong(encoder2.encodeBits(toBytes(prrValue)))); + + // Test that end-to-end is the result of PRR + IRR. + final RapporConfig config3 = new RapporConfig( + "Foo", // encoderId + numBits, // numBits, + 0.25, // probabilityF + 0.3, // probabilityP + 0.7, // probabilityQ + 1, // numCohorts + 2); // numBloomHashes + final RapporEncoder encoder3 = RapporEncoder.createInsecureEncoderForTest(config3); + // Verify that PRR is working as expected. + assertEquals(prrAndIrrValue, toLong(encoder3.encodeBits(toBytes(inputValue)))); + } + + @Test + public void testRapporEncoder_ensureSecureEncoderIsSecure() throws Exception { + int numBits = 8; + final long inputValue = 254L; + final long prrValue = 250L; + final long prrAndIrrValue = 184L; + + final RapporConfig config1 = new RapporConfig( + "Foo", // encoderId + numBits, // numBits, + 0.25, // probabilityF + 0, // probabilityP + 1, // probabilityQ + 1, // numCohorts + 2); // numBloomHashes + final RapporEncoder encoder1 = RapporEncoder.createEncoder(config1, + makeTestingUserSecret("secret1")); + // Verify that PRR is working as expected, not affected by random seed. + assertEquals(prrValue, toLong(encoder1.encodeBits(toBytes(inputValue)))); + assertFalse(encoder1.isInsecureEncoderForTest()); + + boolean hasDifferentResult2 = false; + for (int i = 0; i < 5; i++) { + final RapporConfig config2 = new RapporConfig( + "Foo", // encoderId + numBits, // numBits, + 0, // probabilityF + 0.3, // probabilityP + 0.7, // probabilityQ + 1, // numCohorts + 2); // numBloomHashes + final RapporEncoder encoder2 = RapporEncoder.createEncoder(config2, + makeTestingUserSecret("secret1")); + hasDifferentResult2 |= (prrAndIrrValue != toLong( + encoder2.encodeBits(toBytes(prrValue)))); + } + // Ensure it's not getting same result as it has random seed while encoder id and secret + // is the same. + assertTrue(hasDifferentResult2); + + boolean hasDifferentResults3 = false; + for (int i = 0; i < 5; i++) { + final RapporConfig config3 = new RapporConfig( + "Foo", // encoderId + numBits, // numBits, + 0.25, // probabilityF + 0.3, // probabilityP + 0.7, // probabilityQ + 1, // numCohorts + 2); // numBloomHashes + final RapporEncoder encoder3 = RapporEncoder.createEncoder(config3, + makeTestingUserSecret("secret1")); + hasDifferentResults3 |= (prrAndIrrValue != toLong( + encoder3.encodeBits(toBytes(inputValue)))); + } + // Ensure it's not getting same result as it has random seed while encoder id and secret + // is the same. + assertTrue(hasDifferentResults3); + } + + private static byte[] toBytes(long value) { + return ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putLong(value).array(); + } + + private static long toLong(byte[] bytes) { + ByteBuffer buffer = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).put(bytes); + buffer.rewind(); + return buffer.getLong(); + } + + private static byte[] makeTestingUserSecret(String testingSecret) throws Exception { + // We generate the fake user secret by concatenating three copies of the + // 16 byte MD5 hash of the testingSecret string encoded in UTF 8. + MessageDigest md5 = MessageDigest.getInstance("MD5"); + byte[] digest = md5.digest(testingSecret.getBytes(StandardCharsets.UTF_8)); + assertEquals(16, digest.length); + return ByteBuffer.allocate(48).put(digest).put(digest).put(digest).array(); + } +}