diff --git a/README.md b/README.md index f590f7b..2590d19 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,9 @@ Sensitive data as email and phone number encrypted using SHA256. val ids = listOf( OptableIdentifier.Email("john.doe+test@example.com"), OptableIdentifier.PhoneNumber("+1(555)1234567"), + // Already hashed? Pass it directly, it will not be hashed again: + OptableIdentifier.Hem("da146c4d301ce4a9bf01c8384a5b2f8966b3ed853adf1ee08746be5703ebeebf"), + OptableIdentifier.HashedPhoneNumber("e421d168fe94a7e385cf38608d8de870a5233cc6b14596dd307819b8491c4d5b"), OptableIdentifier.PostalCode("12345"), OptableIdentifier.IPv4("192.168.0.1"), OptableIdentifier.IPv6("2001:db8::1"), @@ -211,6 +214,9 @@ val ids = listOf( ArrayList ids = Lists.newArrayList( new OptableIdentifier.Email("john.doe+test@example.com"), new OptableIdentifier.PhoneNumber("+1(555)1234567"), + // Already hashed? Pass it directly, it will not be hashed again: + new OptableIdentifier.Hem("da146c4d301ce4a9bf01c8384a5b2f8966b3ed853adf1ee08746be5703ebeebf"), + new OptableIdentifier.HashedPhoneNumber("e421d168fe94a7e385cf38608d8de870a5233cc6b14596dd307819b8491c4d5b"), new OptableIdentifier.PostalCode("12345"), new OptableIdentifier.IPv4("192.168.0.1"), new OptableIdentifier.IPv6("2001:db8::1"), diff --git a/android_sdk/src/main/java/co/optable/sdk/OptableIdentifier.kt b/android_sdk/src/main/java/co/optable/sdk/OptableIdentifier.kt index 3a229fe..be5a496 100644 --- a/android_sdk/src/main/java/co/optable/sdk/OptableIdentifier.kt +++ b/android_sdk/src/main/java/co/optable/sdk/OptableIdentifier.kt @@ -18,6 +18,20 @@ sealed class OptableIdentifier { */ data class PhoneNumber(val value: String) : OptableIdentifier() + /** + * Already-hashed Email address (HEM). + * Encoding: normalized (whitespace removed, lowercased), never hashed again. + * Dropped if the value is not a SHA-256 digest, so a plaintext Email is never sent. + */ + data class Hem(val value: String) : OptableIdentifier() + + /** + * Already-hashed Phone number. + * Encoding: normalized (whitespace removed, lowercased), never hashed again. + * Dropped if the value is not a SHA-256 digest. + */ + data class HashedPhoneNumber(val value: String) : OptableIdentifier() + /** * Postal/ZIP code. * Encoding: normalized (whitespace removed, lowercased). diff --git a/android_sdk/src/main/java/co/optable/sdk/core/IdentifiersEncoder.kt b/android_sdk/src/main/java/co/optable/sdk/core/IdentifiersEncoder.kt index eacdb0a..17f2736 100644 --- a/android_sdk/src/main/java/co/optable/sdk/core/IdentifiersEncoder.kt +++ b/android_sdk/src/main/java/co/optable/sdk/core/IdentifiersEncoder.kt @@ -43,6 +43,8 @@ internal class IdentifiersEncoder( when (identifier) { is OptableIdentifier.Email -> result.addIfNotNull(EMAIL, identifier.value, ::encrypt) is OptableIdentifier.PhoneNumber -> result.addIfNotNull(PHONE, identifier.value, ::encrypt) + is OptableIdentifier.Hem -> result.addIfValidHash(EMAIL, identifier.value) + is OptableIdentifier.HashedPhoneNumber -> result.addIfValidHash(PHONE, identifier.value) is OptableIdentifier.PostalCode -> result.addIfNotNull(POSTAL, identifier.value, ::normalize) is OptableIdentifier.IPv4 -> result.addIfNotNull(IPV4, identifier.value, ::removeWhitespaces) is OptableIdentifier.IPv6 -> result.addIfNotNull(IPV6, identifier.value, ::normalize) @@ -85,6 +87,19 @@ internal class IdentifiersEncoder( this.add(result) } + /** + * Adds an already-hashed value, skipping it entirely unless it is a SHA-256 + * digest. This keeps a plaintext Email or Phone number off the wire. + */ + private fun MutableList.addIfValidHash(key: String, value: String?) { + if (value == null) return + + val hash = normalize(value) + if (hash.length != 64 || !hash.matches("^[a-f0-9]+$".toRegex())) return + + this.add("$key:$hash") + } + /** * Returns a type-prefixed ID based on the query string * oeid=sha256value parameters in the specified uri, if one is found. diff --git a/android_sdk/src/test/java/co/optable/sdk/OptableIdentifiersTest.kt b/android_sdk/src/test/java/co/optable/sdk/OptableIdentifiersTest.kt index ff12d8b..96cda77 100644 --- a/android_sdk/src/test/java/co/optable/sdk/OptableIdentifiersTest.kt +++ b/android_sdk/src/test/java/co/optable/sdk/OptableIdentifiersTest.kt @@ -48,6 +48,52 @@ class OptableIdentifiersTest { assertEquals(expected, actual) } + @Test + fun `encode hem`() { + val email = " John.DOE+test@example.COM " + val hem = sha256(normalize(email)) + + // Supplying the hash must match hashing the plaintext ourselves. + assertEquals( + identifiersEncoder.encode(listOf(OptableIdentifier.Email(email))), + identifiersEncoder.encode(listOf(OptableIdentifier.Hem(hem))) + ) + assertEquals( + listOf("e:$hem"), + identifiersEncoder.encode(listOf(OptableIdentifier.Hem(" ${hem.uppercase(Locale.ROOT)} "))) + ) + } + + @Test + fun `encode hashedPhoneNumber`() { + val phone = " +1 (555) 123 45 67 " + val hash = sha256(normalize(phone)) + + assertEquals( + identifiersEncoder.encode(listOf(OptableIdentifier.PhoneNumber(phone))), + identifiersEncoder.encode(listOf(OptableIdentifier.HashedPhoneNumber(hash))) + ) + } + + @Test + fun `encode drops hashed values that are not a SHA-256`() { + val hem = sha256("john.doe@example.com") + + listOf( + "john.doe@example.com", // plaintext must never reach the wire + "", + hem.dropLast(1), // too short + hem + "a", // too long + hem.dropLast(1) + "z" // not hex + ).forEach { value -> + assertEquals( + "expected $value to be dropped", + emptyList(), + identifiersEncoder.encode(listOf(OptableIdentifier.Hem(value))) + ) + } + } + @Test fun `encode postalCode`() { val postal = " 12 3 45 "