Skip to content

Commit

Permalink
finalize encrypted storage
Browse files Browse the repository at this point in the history
  • Loading branch information
Wenxi Zeng committed Feb 3, 2025
1 parent 3bb1709 commit 047e46d
Show file tree
Hide file tree
Showing 5 changed files with 158 additions and 59 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ open class Analytics protected constructor(
}

// use lazy to avoid the instance being leak before fully initialized
val storage: Storage by lazy {
open val storage: Storage by lazy {
configuration.storageProvider.createStorage(this, configuration.application!!)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import sovran.kotlin.Store
data class Configuration(
val writeKey: String,
var application: Any? = null,
val storageProvider: StorageProvider = ConcreteStorageProvider,
var storageProvider: StorageProvider = ConcreteStorageProvider,
var collectDeviceId: Boolean = false,
var trackApplicationLifecycleEvents: Boolean = false,
var useLifecycleObserver: Boolean = false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ open class InMemoryEventStream: EventStream {
}

open class FileEventStream(
internal val directory: File
val directory: File
): EventStream {

init {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,96 +1,192 @@
package com.segment.analytics.next

import android.content.Context
import android.content.SharedPreferences
import com.segment.analytics.kotlin.android.utilities.AndroidKVS
import com.segment.analytics.kotlin.core.Analytics
import com.segment.analytics.kotlin.core.Storage
import com.segment.analytics.kotlin.core.StorageProvider
import com.segment.analytics.kotlin.core.utilities.FileEventStream
import com.segment.analytics.kotlin.core.utilities.PropertiesFile
import com.segment.analytics.kotlin.core.utilities.StorageImpl
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
import java.security.Key
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.CipherInputStream
import javax.crypto.CipherOutputStream
import javax.crypto.KeyGenerator
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec


class EncryptedEventStream(
directory: File,
private val key: Key
val key: ByteArray
) : FileEventStream(directory) {

private var cipherOutputStream: CipherOutputStream? = null

private val encryptedCipher = EncryptionUtil.getCipher(key, Cipher.ENCRYPT_MODE)

override var fs: FileOutputStream?
get() = super.fs
set(value) {
if (value == null) {
cipherOutputStream = null
}
else {
cipherOutputStream = CipherOutputStream(value, encryptedCipher)
}
}
private val ivSize = 16

override fun write(content: String) {
cipherOutputStream?.run {
write(content.toByteArray())
fs?.run {
// generate a different iv for every content
val iv = ByteArray(ivSize).apply {
SecureRandom().nextBytes(this)
}
val cipher = getCipher(Cipher.ENCRYPT_MODE, iv, key)
val encryptedContent = cipher.doFinal(content.toByteArray())

write(iv)
// write the size of the content, so decipher knows
// the length of the content
write(writeInt(encryptedContent.size))
write(encryptedContent)
flush()
}
}

override fun close() {
cipherOutputStream?.close()
super.close()
}

override fun readAsStream(source: String): InputStream? {
val stream = super.readAsStream(source)
return if (stream == null) {
null
} else {
val cipher = EncryptionUtil.getCipher(key, Cipher.DECRYPT_MODE)
CipherInputStream(super.readAsStream(source), cipher)
// the DecryptingInputStream decrypts the steam
// and uses a LimitedInputStream to read the exact
// bytes of a chunk of content
DecryptingInputStream(stream)
}
}


private fun getCipher(mode: Int, iv: ByteArray, key: ByteArray): Cipher {
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
val keySpec = SecretKeySpec(key, "AES")
val ivSpec = IvParameterSpec(iv)
cipher.init(mode, keySpec, ivSpec)
return cipher
}

private fun writeInt(value: Int): ByteArray {
return byteArrayOf(
(value ushr 24).toByte(),
(value ushr 16).toByte(),
(value ushr 8).toByte(),
value.toByte()
)
}

private fun readInt(input: InputStream): Int {
val bytes = input.readNBytes(4)
return (bytes[0].toInt() and 0xFF shl 24) or
(bytes[1].toInt() and 0xFF shl 16) or
(bytes[2].toInt() and 0xFF shl 8) or
(bytes[3].toInt() and 0xFF)
}

private inner class DecryptingInputStream(private val input: InputStream) : InputStream() {
private var currentCipherInputStream: CipherInputStream? = null
private var remainingBytes = 0
private var endOfStream = false

private fun setupNextBlock(): Boolean {
if (endOfStream) return false

try {
// Read IV
val iv = input.readNBytes(ivSize)
if (iv.size < ivSize) {
endOfStream = true
return false
}

// Read content size
remainingBytes = readInt(input)
if (remainingBytes <= 0) {
endOfStream = true
return false
}

// Setup cipher
val cipher = getCipher(Cipher.DECRYPT_MODE, iv, key)

// Create new cipher stream
currentCipherInputStream = CipherInputStream(
LimitedInputStream(input, remainingBytes.toLong()),
cipher
)
return true
} catch (e: Exception) {
endOfStream = true
return false
}
}

override fun read(): Int {
if (currentCipherInputStream == null && !setupNextBlock()) {
return -1
}

val byte = currentCipherInputStream?.read() ?: -1
if (byte == -1) {
currentCipherInputStream = null
return read() // Try next block
}
return byte
}

override fun close() {
currentCipherInputStream?.close()
input.close()
}
}

// Helper class to limit reading to current encrypted block
private class LimitedInputStream(
private val input: InputStream,
private var remaining: Long
) : InputStream() {
override fun read(): Int {
if (remaining <= 0) return -1
val result = input.read()
if (result >= 0) remaining--
return result
}

override fun read(b: ByteArray, off: Int, len: Int): Int {
if (remaining <= 0) return -1
val result = input.read(b, off, minOf(len, remaining.toInt()))
if (result >= 0) remaining -= result
return result
}

override fun close() {
// Don't close the underlying stream
}
}
}

class EncryptedStorageProvider(val key: Key) : StorageProvider {
class EncryptedStorageProvider(val key: ByteArray) : StorageProvider {

override fun createStorage(vararg params: Any): Storage {
if (params.isEmpty() || params[0] !is Analytics) {
throw IllegalArgumentException("Invalid parameters for ConcreteStorageProvider. ConcreteStorageProvider requires at least 1 parameter and the first argument has to be an instance of Analytics")

if (params.size < 2 || params[0] !is Analytics || params[1] !is Context) {
throw IllegalArgumentException("""
Invalid parameters for EncryptedStorageProvider.
EncryptedStorageProvider requires at least 2 parameters.
The first argument has to be an instance of Analytics,
an the second argument has to be an instance of Context
""".trimIndent())
}

val analytics = params[0] as Analytics
val context = params[1] as Context
val config = analytics.configuration

val directory = File("/tmp/analytics-kotlin/${config.writeKey}")
val eventDirectory = File(directory, "events")
val eventDirectory = context.getDir("segment-disk-queue", Context.MODE_PRIVATE)
val fileIndexKey = "segment.events.file.index.${config.writeKey}"
val userPrefs = File(directory, "analytics-kotlin-${config.writeKey}.properties")
val sharedPreferences: SharedPreferences =
context.getSharedPreferences("analytics-android-${config.writeKey}", Context.MODE_PRIVATE)

val propertiesFile = PropertiesFile(userPrefs)
val propertiesFile = AndroidKVS(sharedPreferences)
// use the key from constructor or get it from share preferences
val eventStream = EncryptedEventStream(eventDirectory, key)
return StorageImpl(propertiesFile, eventStream, analytics.store, config.writeKey, fileIndexKey, analytics.fileIODispatcher)
}
}

object EncryptionUtil {
private const val ALGORITHM = "AES"

fun generateKey(): Key {
val keyGen = KeyGenerator.getInstance(ALGORITHM)
keyGen.init(128) // AES 128-bit key
return keyGen.generateKey()
}

fun getCipher(key: Key, mode: Int): Cipher {
val cipher = Cipher.getInstance(ALGORITHM)
cipher.init(mode, key)
return cipher
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,18 @@ class MainApplication : Application() {
override fun onCreate() {
super.onCreate()

// Replace it with your key to the encrypted storage
val secretKey = ByteArray(32) { 1 }

analytics = Analytics("tteOFND0bb5ugJfALOJWpF0wu1tcxYgr", applicationContext) {
this.collectDeviceId = true
this.trackApplicationLifecycleEvents = true
this.trackDeepLinks = true
this.flushPolicies = listOf(
CountBasedFlushPolicy(3), // Flush after 3 events
FrequencyFlushPolicy(5000), // Flush after 5 secs
UnmeteredFlushPolicy(applicationContext) // Flush if network is not metered
CountBasedFlushPolicy(100), // Flush after 3 events
// FrequencyFlushPolicy(60000), // Flush after 5 secs
// UnmeteredFlushPolicy(applicationContext) // Flush if network is not metered
)
this.flushPolicies = listOf(UnmeteredFlushPolicy(applicationContext))
this.requestFactory = object : RequestFactory() {
override fun upload(apiHost: String): HttpURLConnection {
val connection: HttpURLConnection = openConnection("https://$apiHost/b")
Expand All @@ -41,6 +43,7 @@ class MainApplication : Application() {
return connection
}
}
this.storageProvider = EncryptedStorageProvider(secretKey)
}
analytics.add(AndroidRecordScreenPlugin())
analytics.add(object : Plugin {
Expand Down

0 comments on commit 047e46d

Please sign in to comment.