Skip to content
This repository was archived by the owner on Feb 4, 2025. It is now read-only.

Commit a749ee4

Browse files
authored
Merge pull request #1654 from wordpress-mobile/feature/encrypted-logs-master
Encrypted logs
2 parents 3d40637 + 62f2839 commit a749ee4

File tree

24 files changed

+1326
-5
lines changed

24 files changed

+1326
-5
lines changed

build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ allprojects {
2121
repositories {
2222
google()
2323
jcenter()
24+
maven { url "http://dl.bintray.com/terl/lazysodium-maven" }
2425
}
2526

2627
task checkstyle(type: Checkstyle) {

example/build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ android {
2222

2323
defaultConfig {
2424
applicationId "org.wordpress.android.fluxc.example"
25-
minSdkVersion 15
25+
minSdkVersion 16
2626
// Keep the targetSdkVersion 22 so we don't need to grant runtime permissions to the tests and the example app
2727
// An alternative would be granting the permissions via adb before running the test, like here:
2828
// https://afterecho.uk/blog/granting-marshmallow-permissions-for-testing-flavoured-builds.html
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
package org.wordpress.android.fluxc
2+
3+
import android.util.Base64
4+
import android.util.Base64.DEFAULT
5+
import com.goterl.lazycode.lazysodium.interfaces.SecretStream
6+
import com.goterl.lazycode.lazysodium.utils.KeyPair
7+
import org.json.JSONObject
8+
import org.junit.Assert.assertEquals
9+
import org.junit.Assert.assertNotNull
10+
import org.junit.Before
11+
import org.junit.Test
12+
import org.wordpress.android.fluxc.model.encryptedlogging.EncryptedLoggingKey
13+
import org.wordpress.android.fluxc.model.encryptedlogging.EncryptedSecretStreamKey
14+
import org.wordpress.android.fluxc.model.encryptedlogging.EncryptionUtils
15+
import org.wordpress.android.fluxc.model.encryptedlogging.LogEncrypter
16+
import org.wordpress.android.fluxc.model.encryptedlogging.SecretStreamKey
17+
import java.util.UUID
18+
import kotlin.random.Random.Default.nextInt
19+
20+
class LogEncrypterTest {
21+
private lateinit var keypair: KeyPair
22+
private val logDecrypter: LogDecrypter = LogDecrypter()
23+
24+
@Before
25+
fun setup() {
26+
keypair = EncryptionUtils.sodium.cryptoBoxKeypair()
27+
}
28+
29+
@Test
30+
@Throws
31+
fun testThatEncryptedLogsMatchV1FileFormat() {
32+
val testLogString = UUID.randomUUID().toString()
33+
val encryptedLog = encryptContent(testLogString)
34+
35+
val json = JSONObject(encryptedLog)
36+
assertEquals(
37+
"`keyedWith` must ALWAYS be v1 in this version of the file format",
38+
"v1",
39+
json.getString("keyedWith")
40+
)
41+
42+
assertNotNull(
43+
"The UUID must be valid",
44+
UUID.fromString(json.getString("uuid"))
45+
)
46+
47+
assertEquals(
48+
"The header must be 32 bytes long",
49+
32,
50+
json.getString("header").count()
51+
)
52+
53+
assertEquals(
54+
"The encrypted key should be 108 bytes long",
55+
108,
56+
json.getString("encryptedKey").count()
57+
)
58+
59+
assertEquals(
60+
"There should be one message and the closing tag",
61+
2,
62+
json.getJSONArray("messages").length()
63+
)
64+
}
65+
66+
@Test
67+
fun testThatLogsCanBeDecrypted() {
68+
val testLogString = UUID.randomUUID().toString()
69+
assertEquals(testLogString, decryptContent(encryptContent(testLogString)))
70+
}
71+
72+
@Test
73+
fun testThatMultilineLogsCanBeDecrypted() {
74+
val testLogString = (0..(nextInt(100) + 2)).joinToString(separator = "\n") { UUID.randomUUID().toString() }
75+
assertEquals(testLogString, decryptContent(encryptContent(testLogString)))
76+
}
77+
78+
@Test
79+
fun testThatEmptyLogsCanBeEncrypted() {
80+
val testLogString = ""
81+
assertEquals(testLogString, decryptContent(encryptContent(testLogString)))
82+
}
83+
84+
@Test
85+
fun testThatExplicitUUIDsCanBeRetrievedFromEncryptedLogs() {
86+
val testUuid = UUID.randomUUID().toString()
87+
88+
val (_, uuid) = logDecrypter.decrypt(encryptContent("", testUuid), keypair)
89+
assertEquals(uuid, testUuid)
90+
}
91+
92+
// Helpers
93+
94+
private fun encryptContent(content: String, uuid: String = UUID.randomUUID().toString()): String {
95+
return LogEncrypter(EncryptedLoggingKey(keypair.publicKey)).encrypt(content, uuid)
96+
}
97+
98+
private fun decryptContent(encryptedText: String): String {
99+
return logDecrypter.decrypt(encryptedText, keypair).first
100+
}
101+
}
102+
103+
/**
104+
* EncryptedStream represents the encrypted stream once the key has been decrypted. It exists to separate
105+
* the key decryption from the stream decryption while decoding.
106+
*
107+
* @param key An unencrypted SecretStreamKey used to decrypt the remainder of the log.
108+
* @param header A `ByteArray` representing the stream header – it's used to initialize the decryption stream.
109+
* @param messages A `List<ByteArray>` of encrypted messages
110+
*/
111+
private class EncryptedStream(val key: SecretStreamKey, val header: ByteArray, val messages: List<ByteArray>)
112+
113+
private const val JSON_KEYED_WITH_KEY = "keyedWith"
114+
private const val JSON_UUID_KEY = "uuid"
115+
private const val JSON_HEADER_KEY = "header"
116+
private const val JSON_ENCRYPTED_KEY_KEY = "encryptedKey"
117+
private const val JSON_MESSAGES_KEY = "messages"
118+
119+
/**
120+
* LogDecrypter allows decrypting encrypted content.
121+
*/
122+
private class LogDecrypter {
123+
private val sodium = EncryptionUtils.sodium
124+
private val state = SecretStream.State.ByReference()
125+
126+
private fun encryptedStream(encryptedText: String, keyPair: KeyPair): Pair<EncryptedStream, String> {
127+
val json = JSONObject(encryptedText)
128+
129+
require(json.getString(JSON_KEYED_WITH_KEY) == "v1") {
130+
"This class can only parse files keyedWith the v1 implementation"
131+
}
132+
133+
val uuid = json.getString(JSON_UUID_KEY)
134+
val header = json.getString(JSON_HEADER_KEY).base64Decode()
135+
val encryptedKey = EncryptedSecretStreamKey(json.getString(JSON_ENCRYPTED_KEY_KEY).base64Decode())
136+
val messagesJson = json.getJSONArray(JSON_MESSAGES_KEY)
137+
138+
val messages = (0 until messagesJson.length()).map { messagesJson.getString(it).base64Decode() }
139+
140+
val encryptedStream = EncryptedStream(encryptedKey.decrypt(keyPair), header, messages)
141+
check(sodium.cryptoSecretStreamInitPull(state, encryptedStream.header, encryptedStream.key.bytes))
142+
return Pair(encryptedStream, uuid)
143+
}
144+
145+
/**
146+
* Decrypts and returns the log file as a String.
147+
*
148+
* @param encryptedText The encrypted text to decrypt.
149+
* @param keyPair The public and secret key pair associated with this file. Both are required to decrypt the file.
150+
*/
151+
fun decrypt(encryptedText: String, keyPair: KeyPair): Pair<String, String> {
152+
val (encryptedStream, uuid) = encryptedStream(encryptedText, keyPair)
153+
val decryptedText = encryptedStream.messages.fold("") { accumulated: String, cipherBytes: ByteArray ->
154+
String
155+
val plainBytes = ByteArray(cipherBytes.size - SecretStream.ABYTES)
156+
157+
val tag = ByteArray(1) // Stores the extracted tag. This implementation doesn't do anything with it.
158+
check(sodium.cryptoSecretStreamPull(state, plainBytes, tag, cipherBytes, cipherBytes.size.toLong()))
159+
160+
accumulated + String(plainBytes)
161+
}
162+
return Pair(decryptedText, uuid)
163+
}
164+
}
165+
166+
// On Android base64 has lots of options, so define an extension to make it easier to avoid decoding issues.
167+
private fun String.base64Decode(): ByteArray {
168+
return Base64.decode(this, DEFAULT)
169+
}

example/src/androidTest/java/org/wordpress/android/fluxc/release/ReleaseStack_AppComponent.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,4 +68,5 @@ public interface ReleaseStack_AppComponent {
6868
void inject(ReleaseStack_PostSchedulingTestJetpack test);
6969
void inject(ReleaseStack_ReactNativeWPAPIRequestTest test);
7070
void inject(ReleaseStack_ReactNativeWPComRequestTest test);
71+
void inject(ReleaseStack_EncryptedLogTest test);
7172
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package org.wordpress.android.fluxc.release
2+
3+
import org.greenrobot.eventbus.Subscribe
4+
import org.hamcrest.CoreMatchers.`is`
5+
import org.hamcrest.CoreMatchers.hasItem
6+
import org.junit.Assert.assertThat
7+
import org.junit.Assert.assertTrue
8+
import org.junit.Test
9+
import org.wordpress.android.fluxc.TestUtils
10+
import org.wordpress.android.fluxc.generated.EncryptedLogActionBuilder
11+
import org.wordpress.android.fluxc.release.ReleaseStack_EncryptedLogTest.TestEvents.ENCRYPTED_LOG_UPLOADED_SUCCESSFULLY
12+
import org.wordpress.android.fluxc.release.ReleaseStack_EncryptedLogTest.TestEvents.ENCRYPTED_LOG_UPLOAD_FAILED_WITH_INVALID_UUID
13+
import org.wordpress.android.fluxc.store.EncryptedLogStore
14+
import org.wordpress.android.fluxc.store.EncryptedLogStore.OnEncryptedLogUploaded
15+
import org.wordpress.android.fluxc.store.EncryptedLogStore.OnEncryptedLogUploaded.EncryptedLogFailedToUpload
16+
import org.wordpress.android.fluxc.store.EncryptedLogStore.OnEncryptedLogUploaded.EncryptedLogUploadedSuccessfully
17+
import org.wordpress.android.fluxc.store.EncryptedLogStore.UploadEncryptedLogError.InvalidRequest
18+
import org.wordpress.android.fluxc.store.EncryptedLogStore.UploadEncryptedLogError.TooManyRequests
19+
import org.wordpress.android.fluxc.store.EncryptedLogStore.UploadEncryptedLogPayload
20+
import java.io.File
21+
import java.util.concurrent.CountDownLatch
22+
import java.util.concurrent.TimeUnit
23+
import javax.inject.Inject
24+
25+
private const val NUMBER_OF_LOGS_TO_UPLOAD = 3
26+
private const val TEST_UUID_PREFIX = "TEST-UUID-"
27+
private const val INVALID_UUID = "INVALID_UUID" // Underscore is not allowed
28+
29+
class ReleaseStack_EncryptedLogTest : ReleaseStack_Base() {
30+
@Inject lateinit var encryptedLogStore: EncryptedLogStore
31+
32+
private var nextEvent: TestEvents? = null
33+
34+
private enum class TestEvents {
35+
NONE,
36+
ENCRYPTED_LOG_UPLOADED_SUCCESSFULLY,
37+
ENCRYPTED_LOG_UPLOAD_FAILED_WITH_INVALID_UUID
38+
}
39+
40+
@Throws(Exception::class)
41+
override fun setUp() {
42+
super.setUp()
43+
mReleaseStackAppComponent.inject(this)
44+
init()
45+
nextEvent = TestEvents.NONE
46+
}
47+
48+
@Test
49+
fun testQueueForUpload() {
50+
nextEvent = ENCRYPTED_LOG_UPLOADED_SUCCESSFULLY
51+
52+
val testIds = testIds()
53+
mCountDownLatch = CountDownLatch(testIds.size)
54+
testIds.forEach { uuid ->
55+
val payload = UploadEncryptedLogPayload(
56+
uuid = uuid,
57+
file = createTempFileWithContent(suffix = uuid, content = "Testing FluxC log upload for $uuid"),
58+
shouldStartUploadImmediately = true
59+
)
60+
mDispatcher.dispatch(EncryptedLogActionBuilder.newUploadLogAction(payload))
61+
}
62+
assertTrue(mCountDownLatch.await(TestUtils.DEFAULT_TIMEOUT_MS.toLong(), TimeUnit.MILLISECONDS))
63+
}
64+
65+
@Test
66+
fun testQueueForUploadForInvalidUuid() {
67+
nextEvent = ENCRYPTED_LOG_UPLOAD_FAILED_WITH_INVALID_UUID
68+
69+
mCountDownLatch = CountDownLatch(1)
70+
val payload = UploadEncryptedLogPayload(
71+
uuid = INVALID_UUID,
72+
file = createTempFile(suffix = INVALID_UUID),
73+
shouldStartUploadImmediately = true
74+
)
75+
mDispatcher.dispatch(EncryptedLogActionBuilder.newUploadLogAction(payload))
76+
assertTrue(mCountDownLatch.await(TestUtils.DEFAULT_TIMEOUT_MS.toLong(), TimeUnit.MILLISECONDS))
77+
}
78+
79+
@Suppress("unused")
80+
@Subscribe
81+
fun onEncryptedLogUploaded(event: OnEncryptedLogUploaded) {
82+
when (event) {
83+
is EncryptedLogUploadedSuccessfully -> {
84+
assertThat(nextEvent, `is`(ENCRYPTED_LOG_UPLOADED_SUCCESSFULLY))
85+
assertThat(testIds(), hasItem(event.uuid))
86+
}
87+
is EncryptedLogFailedToUpload -> {
88+
when (event.error) {
89+
is TooManyRequests -> {
90+
// If we are hitting too many requests, we just ignore the test as restarting it will not help
91+
assertThat(event.willRetry, `is`(true))
92+
}
93+
is InvalidRequest -> {
94+
assertThat(nextEvent, `is`(ENCRYPTED_LOG_UPLOAD_FAILED_WITH_INVALID_UUID))
95+
assertThat(event.willRetry, `is`(false))
96+
}
97+
else -> {
98+
throw AssertionError("Unexpected error occurred in onEncryptedLogUploaded: ${event.error}")
99+
}
100+
}
101+
}
102+
}
103+
mCountDownLatch.countDown()
104+
}
105+
106+
private fun testIds() = (1..NUMBER_OF_LOGS_TO_UPLOAD).map { i ->
107+
"$TEST_UUID_PREFIX$i"
108+
}
109+
110+
private fun createTempFileWithContent(suffix: String, content: String): File {
111+
val file = createTempFile(suffix = suffix)
112+
file.writeText(content)
113+
return file
114+
}
115+
}

example/src/main/java/org/wordpress/android/fluxc/example/di/AppConfigModule.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33
import android.content.Context;
44
import android.text.TextUtils;
55

6+
import com.goterl.lazycode.lazysodium.exceptions.SodiumException;
7+
68
import org.wordpress.android.fluxc.example.BuildConfig;
9+
import org.wordpress.android.fluxc.model.encryptedlogging.EncryptedLoggingKey;
10+
import org.wordpress.android.fluxc.model.encryptedlogging.EncryptionUtils;
711
import org.wordpress.android.fluxc.network.UserAgent;
812
import org.wordpress.android.fluxc.network.rest.wpcom.auth.AppSecrets;
913
import org.wordpress.android.util.AppLog;
@@ -40,4 +44,14 @@ public AppSecrets provideAppSecrets() {
4044
public UserAgent provideUserAgent(Context appContext) {
4145
return new UserAgent(appContext, "fluxc-example-android");
4246
}
47+
48+
@Provides
49+
public EncryptedLoggingKey provideEncryptedLoggingKey() {
50+
try {
51+
return new EncryptedLoggingKey(EncryptionUtils.getSodium().cryptoBoxKeypair().getPublicKey());
52+
} catch (SodiumException e) {
53+
e.printStackTrace();
54+
return null;
55+
}
56+
}
4357
}

0 commit comments

Comments
 (0)