Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.onesignal.common

import java.security.MessageDigest

/**
* Deterministic SHA-256 hashing for PII fields (email, phone number) so that
* sensitive data stored in SharedPreferences is not readable in plain text on
* rooted devices or via ADB backup.
*
* The hash is hex-encoded and always 64 characters long.
*/
object PIIHasher {
Comment thread
abdulraqeeb33 marked this conversation as resolved.
private const val SHA256_HEX_LENGTH = 64
private val SHA256_HEX_REGEX = Regex("^[a-f0-9]{$SHA256_HEX_LENGTH}$")

/** Returns the lowercase hex-encoded SHA-256 hash of [value]. */
fun hash(value: String): String {
val digest = MessageDigest.getInstance("SHA-256")
val bytes = digest.digest(value.toByteArray(Charsets.UTF_8))
return bytes.joinToString("") { "%02x".format(it) }
Comment thread
sherwinski marked this conversation as resolved.
}

/** Returns `true` if [value] looks like a 64-char lowercase hex SHA-256 digest. */
fun isHashed(value: String): Boolean = SHA256_HEX_REGEX.matches(value)
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys
import com.onesignal.core.internal.preferences.PreferenceStores
import com.onesignal.debug.internal.logging.Logging
import org.json.JSONArray
import org.json.JSONObject

/**
* The abstract implementation of a model store. Implements all but the [create] method,
Expand Down Expand Up @@ -212,13 +213,22 @@ abstract class ModelStore<TModel>(
val jsonArray = JSONArray()
synchronized(models) {
for (model in models) {
jsonArray.put(model.toJSON())
jsonArray.put(transformJsonForPersistence(model, model.toJSON()))
}
}

_prefs.saveString(PreferenceStores.ONESIGNAL, PreferenceOneSignalKeys.MODEL_STORE_PREFIX + name, jsonArray.toString())
}

/**
* Hook for subclasses to transform a model's JSON representation before it is
* written to SharedPreferences. The default implementation returns the JSON unchanged.
*/
protected open fun transformJsonForPersistence(
model: TModel,
json: JSONObject,
): JSONObject = json

override fun subscribe(handler: IModelStoreChangeHandler<TModel>) = changeSubscription.subscribe(handler)

override fun unsubscribe(handler: IModelStoreChangeHandler<TModel>) = changeSubscription.unsubscribe(handler)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
package com.onesignal.user.internal

import com.onesignal.common.PIIHasher
Comment thread
sherwinski marked this conversation as resolved.
import com.onesignal.user.internal.subscriptions.SubscriptionModel
import com.onesignal.user.subscriptions.IEmailSubscription

internal class EmailSubscription(
model: SubscriptionModel,
) : Subscription(model), IEmailSubscription {
override val email: String
get() = model.address
get() {
val address = model.address
return if (PIIHasher.isHashed(address)) "" else address
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
package com.onesignal.user.internal

import com.onesignal.common.PIIHasher
import com.onesignal.user.internal.subscriptions.SubscriptionModel
import com.onesignal.user.subscriptions.ISmsSubscription

internal class SmsSubscription(
model: SubscriptionModel,
) : Subscription(model), ISmsSubscription {
override val number: String
get() = model.address
get() {
val address = model.address
return if (PIIHasher.isHashed(address)) "" else address
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why return empty, would that affect getBySMS check?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getBySMS reads model.address directly (not the public getter), so there's no impact. The empty return is to protect against leaking a hash to app developers during the cold-start-to-hydration window. I opted for this approach to signal to devs that the value isn't available yet as opposed to providing a confusing value.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instead of this can we pass an Enum back.
Something like

Hash("value")
Address("value")
Unknown

then its more clear and explicit

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with you, but I'd want to clarify one thing first.

ISmsSubscription and IEmailSubscription expose public getters for email and sms, so passing an enum back would technically be a breaking change. The wrinkle here is that the getters are not reachable because IUserManager doesn't expose them, although I believe we do access those values directly in the demo app.
Given this is the case, would we still be ok with that approach?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah right, these Interface are exposed in the IUserManager - would cause a breaking change. I think we should be with this although its might not super clear IMHO for a developer.

Can we update documentation that states -
Before hydration

  • if null then this is what it means

After hydration

  • if null then this is what it means

}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.onesignal.user.internal.subscriptions

import com.onesignal.common.PIIHasher
import com.onesignal.user.internal.Subscription
import com.onesignal.user.subscriptions.IEmailSubscription
import com.onesignal.user.subscriptions.IPushSubscription
import com.onesignal.user.subscriptions.ISmsSubscription
Expand Down Expand Up @@ -30,15 +32,27 @@ class SubscriptionList(val collection: List<ISubscription>, private val _fallbac

/**
* Retrieve the Email subscription with the matching email, if there is one.
* Compares against the underlying model address (raw or hashed) so lookups
* work both before and after server hydration.
*/
fun getByEmail(email: String): IEmailSubscription? {
return emails.firstOrNull { it.email == email }
val hashed = PIIHasher.hash(email)
return emails.firstOrNull {
val address = (it as Subscription).model.address
address == email || address == hashed
}
}

/**
* Retrieve the SMS subscription with the matching SMS number, if there is one.
* Compares against the underlying model address (raw or hashed) so lookups
* work both before and after server hydration.
*/
fun getBySMS(sms: String): ISmsSubscription? {
return smss.firstOrNull { it.number == sms }
val hashed = PIIHasher.hash(sms)
return smss.firstOrNull {
val address = (it as Subscription).model.address
address == sms || address == hashed
}
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package com.onesignal.user.internal.subscriptions

import com.onesignal.common.PIIHasher
import com.onesignal.common.modeling.ModelChangeTags
import com.onesignal.common.modeling.SimpleModelStore
import com.onesignal.core.internal.preferences.IPreferencesService
import org.json.JSONObject

open class SubscriptionModelStore(prefs: IPreferencesService) : SimpleModelStore<SubscriptionModel>({
SubscriptionModel()
Expand Down Expand Up @@ -32,4 +34,18 @@ open class SubscriptionModelStore(prefs: IPreferencesService) : SimpleModelStore
super.replaceAll(models, tag)
}
}

override fun transformJsonForPersistence(
model: SubscriptionModel,
json: JSONObject,
): JSONObject {
val type = json.optString("type", "")
if (type.isEmpty() || type == SubscriptionType.PUSH.toString()) return json
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what if we hash the PUSH token as well? Would that cause us any issues?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I think that would cause issues. On cold start (before server hydration), model.address holds whatever was loaded from SharedPreferences. If the push token were hashed, any network operation during that window would send the hash instead of the real FCM/HMS token, which could break push delivery.

Take CreateSubscriptionOperation for example, which sends model.address in the POST/PATCH request body to the API. If the push token were hashed on disk and loaded back as a hash on cold start, we would be sending the hashed value to the backend.

Is there a reason to hash the push token? It's not as personally-identifiable as email or phone number.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok makes sense. I was assuming that just like email and phone number these values are not really required and a one way hash would be applicable there as well. But we are using the token to Create the Subscription then yeah, we cant.

Yeah I was trying to push more so on the consistency side.


val address = json.optString("address", "")
if (address.isNotEmpty() && !PIIHasher.isHashed(address)) {
json.put("address", PIIHasher.hash(address))
}
return json
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import com.onesignal.common.AndroidUtils
import com.onesignal.common.DeviceUtils
import com.onesignal.common.IDManager
import com.onesignal.common.OneSignalUtils
import com.onesignal.common.PIIHasher
import com.onesignal.common.events.EventProducer
import com.onesignal.common.modeling.IModelStoreChangeHandler
import com.onesignal.common.modeling.ModelChangedArgs
Expand Down Expand Up @@ -93,15 +94,23 @@ internal class SubscriptionManager(
}

override fun removeEmailSubscription(email: String) {
val subscriptionToRem = subscriptions.emails.firstOrNull { it is EmailSubscription && it.email == email }
val hashedEmail = PIIHasher.hash(email)
val subscriptionToRem =
subscriptions.emails.firstOrNull {
it is EmailSubscription && (it.model.address == email || it.model.address == hashedEmail)
}

if (subscriptionToRem != null) {
removeSubscriptionFromModels(subscriptionToRem)
}
}

override fun removeSmsSubscription(sms: String) {
val subscriptionToRem = subscriptions.smss.firstOrNull { it is SmsSubscription && it.number == sms }
val hashedSms = PIIHasher.hash(sms)
val subscriptionToRem =
subscriptions.smss.firstOrNull {
it is SmsSubscription && (it.model.address == sms || it.model.address == hashedSms)
}

if (subscriptionToRem != null) {
removeSubscriptionFromModels(subscriptionToRem)
Expand All @@ -113,7 +122,8 @@ internal class SubscriptionManager(
address: String,
Comment thread
sherwinski marked this conversation as resolved.
status: SubscriptionStatus? = null,
) {
Logging.log(LogLevel.DEBUG, "SubscriptionManager.addSubscription(type: $type, address: $address)")
val logAddress = if (type != SubscriptionType.PUSH) PIIHasher.hash(address) else address
Logging.log(LogLevel.DEBUG, "SubscriptionManager.addSubscription(type: $type, address: $logAddress)")

val subscriptionModel = SubscriptionModel()
subscriptionModel.id = IDManager.createLocalId()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package com.onesignal.common

import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldHaveLength
import io.kotest.matchers.string.shouldMatch

class PIIHasherTests : FunSpec({

test("hash produces 64-char lowercase hex string") {
val result = PIIHasher.hash("test@example.com")
result shouldHaveLength 64
result shouldMatch Regex("^[a-f0-9]{64}$")
}

test("hash is deterministic") {
PIIHasher.hash("test@example.com") shouldBe PIIHasher.hash("test@example.com")
}

test("hash produces different output for different input") {
val hash1 = PIIHasher.hash("user1@example.com")
val hash2 = PIIHasher.hash("user2@example.com")
(hash1 != hash2) shouldBe true
}

test("hash matches known SHA-256 digest") {
// SHA-256 of "hello" is well-known
PIIHasher.hash("hello") shouldBe "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
}

test("isHashed returns true for valid 64-char hex string") {
val hashed = PIIHasher.hash("test@example.com")
PIIHasher.isHashed(hashed) shouldBe true
}

test("isHashed returns false for plain email") {
PIIHasher.isHashed("test@example.com") shouldBe false
}

test("isHashed returns false for phone number") {
PIIHasher.isHashed("+15558675309") shouldBe false
}

test("isHashed returns false for empty string") {
PIIHasher.isHashed("") shouldBe false
}

test("isHashed returns false for uppercase hex") {
val upper = PIIHasher.hash("test").uppercase()
PIIHasher.isHashed(upper) shouldBe false
}

test("isHashed returns false for 63-char hex string") {
PIIHasher.isHashed("a".repeat(63)) shouldBe false
}

test("isHashed returns false for 65-char hex string") {
PIIHasher.isHashed("a".repeat(65)) shouldBe false
}
})
Loading
Loading