From 9ca558434700955b1187628be2011cb700ba81ba Mon Sep 17 00:00:00 2001 From: mosherBT Date: Fri, 14 Aug 2026 13:24:40 -0400 Subject: [PATCH 1/3] Add Hem and HashedPhoneNumber identifiers Mirrors the .hem helper added to the iOS SDK, so a caller holding a SHA256 of an Email can pass it directly instead of building a prefixed Raw string. The value is normalized but never hashed again, and is dropped unless it is a SHA-256 digest, which keeps a plaintext Email off the wire. Raw is left untouched. --- .../java/co/optable/sdk/OptableIdentifier.kt | 14 ++++++ .../co/optable/sdk/core/IdentifiersEncoder.kt | 15 ++++++ .../co/optable/sdk/OptableIdentifiersTest.kt | 46 +++++++++++++++++++ 3 files changed, 75 insertions(+) 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 " From e6e66bbbdf69b2fe2a6fd803e770af71996b77fa Mon Sep 17 00:00:00 2001 From: mosherBT Date: Fri, 14 Aug 2026 13:47:10 -0400 Subject: [PATCH 2/3] Document Hem in the README identifier examples --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index f590f7b..31fba5f 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,8 @@ 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.PostalCode("12345"), OptableIdentifier.IPv4("192.168.0.1"), OptableIdentifier.IPv6("2001:db8::1"), @@ -211,6 +213,8 @@ 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.PostalCode("12345"), new OptableIdentifier.IPv4("192.168.0.1"), new OptableIdentifier.IPv6("2001:db8::1"), From 8ae6098ec4c4c63ecaec1dfd8026612adda0269f Mon Sep 17 00:00:00 2001 From: mosherBT Date: Mon, 17 Aug 2026 13:10:38 -0400 Subject: [PATCH 3/3] comments --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 31fba5f..2590d19 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,7 @@ val ids = listOf( 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"), @@ -215,6 +216,7 @@ ArrayList ids = Lists.newArrayList( 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"),