From e83a54025ebf154747cd5b1f989a92a7ee46f921 Mon Sep 17 00:00:00 2001 From: mosherBT Date: Thu, 13 Aug 2026 13:23:28 -0400 Subject: [PATCH 1/5] feat: accept pre-encoded identifiers (HEMs) Adds an OptableIdentifier.raw case for identifiers that are already type-prefixed and encoded, such as a hashed Email obtained from a backend or a newsletter link. The value is sent as-is. This also fixes tryIdentifyFromURL, which pulled the SHA256 oeid out of the URL and then passed it through .emailAddress, hashing it a second time and sending an EID that could never match. The Objective-C identifierWithString: factory had the same problem. Mirrors OptableIdentifier.Raw in the Android SDK. --- Source/Core/OptableIdentifierEncoder.swift | 6 ++++ Source/OptableSDK.swift | 5 ++-- .../OptableSDKIdentifier-Bridge.swift | 1 + .../Public/ObjCSupport/OptableSDKIdentifier.h | 7 +++-- .../Public/ObjCSupport/OptableSDKIdentifier.m | 8 ++++- Source/Public/OptableIdentifier.swift | 11 +++++-- .../Unit/OptableIdentifierEncoderTests.swift | 21 +++++++++++++ Tests/Unit/OptableIdentifiersTests.swift | 30 +++++++++++++++++++ 8 files changed, 82 insertions(+), 7 deletions(-) diff --git a/Source/Core/OptableIdentifierEncoder.swift b/Source/Core/OptableIdentifierEncoder.swift index ddcdf4c..ee4dd9b 100644 --- a/Source/Core/OptableIdentifierEncoder.swift +++ b/Source/Core/OptableIdentifierEncoder.swift @@ -31,10 +31,16 @@ enum OptableIdentifierEncoder { case let .utiq(value): utiq(prefix, value) case let .custom(idx, value): custom(prefix, idx: idx ?? 0, value) case let .optableVID(value): vid(prefix, value) + case let .raw(value): raw(value) } return eid } + /// Passes through an already type-prefixed Extended Identifier without re-encoding it. + static func raw(_ eid: String) -> String { + return eid.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + } + /// Builds Extended Identifier from Email address static func email(_ prefix: String, _ email: String) -> String { let normalizedData = Data(email.components(separatedBy: CharacterSet.whitespacesAndNewlines).joined().lowercased().utf8) diff --git a/Source/OptableSDK.swift b/Source/OptableSDK.swift index 5ea6aeb..8aabe39 100644 --- a/Source/OptableSDK.swift +++ b/Source/OptableSDK.swift @@ -276,9 +276,10 @@ public extension OptableSDK { func tryIdentifyFromURL(_ urlString: String) throws { let eidStr = OptableIdentifierEncoder.eidFromURL(urlString) - guard let eid = OptableIdentifier(extendedIdentifier: eidStr) else { return } + guard eidStr.isEmpty == false else { return } - try self._identify([eid], completion: { _ in /* no-op */ }) + // The oeid is already a SHA256 of the Email, so it must not be hashed again. + try self._identify([.raw(eidStr)], completion: { _ in /* no-op */ }) } } diff --git a/Source/Public/ObjCSupport/OptableSDKIdentifier-Bridge.swift b/Source/Public/ObjCSupport/OptableSDKIdentifier-Bridge.swift index c868fe9..5074894 100644 --- a/Source/Public/ObjCSupport/OptableSDKIdentifier-Bridge.swift +++ b/Source/Public/ObjCSupport/OptableSDKIdentifier-Bridge.swift @@ -24,6 +24,7 @@ public extension OptableIdentifier { case .ID5: swiftType = .id5(identifier.value) case .UTIQ: swiftType = .utiq(identifier.value) case .optableVID: swiftType = .optableVID(identifier.value) + case .raw: swiftType = .raw(identifier.value) case .custom: swiftType = .custom(identifier.customIdx?.intValue, identifier.value) @unknown default: diff --git a/Source/Public/ObjCSupport/OptableSDKIdentifier.h b/Source/Public/ObjCSupport/OptableSDKIdentifier.h index 4077165..131950d 100644 --- a/Source/Public/ObjCSupport/OptableSDKIdentifier.h +++ b/Source/Public/ObjCSupport/OptableSDKIdentifier.h @@ -31,8 +31,11 @@ typedef NS_ENUM(NSInteger, OptableSDKIdentifierType) { OptableSDKIdentifierType_UTIQ, OptableSDKIdentifierType_Custom, - - OptableSDKIdentifierType_OptableVID + + OptableSDKIdentifierType_OptableVID, + + /// Pre-encoded EID sent as-is, e.g. a hashed Email "e:". + OptableSDKIdentifierType_Raw }; @interface OptableSDKIdentifier : NSObject diff --git a/Source/Public/ObjCSupport/OptableSDKIdentifier.m b/Source/Public/ObjCSupport/OptableSDKIdentifier.m index 55c7953..7d8a362 100644 --- a/Source/Public/ObjCSupport/OptableSDKIdentifier.m +++ b/Source/Public/ObjCSupport/OptableSDKIdentifier.m @@ -112,7 +112,13 @@ + (nullable instancetype)identifierWithString:(NSString *)string NSString *typeRaw = [string substringToIndex:range.location]; NSString *value = [string substringFromIndex:range.location + 1]; - return [[self alloc] initWithTypeRawValue:typeRaw value:value]; + // Validate the prefix, then keep the string pre-encoded so that hash-based + // types are not hashed a second time. + if ([[self alloc] initWithTypeRawValue:typeRaw value:value] == nil) return nil; + + return [[self alloc] initWithType:OptableSDKIdentifierType_Raw + value:string + customIdx:nil]; } @end diff --git a/Source/Public/OptableIdentifier.swift b/Source/Public/OptableIdentifier.swift index 3152a4e..265cb9e 100644 --- a/Source/Public/OptableIdentifier.swift +++ b/Source/Public/OptableIdentifier.swift @@ -43,6 +43,10 @@ public enum OptableIdentifier { // Optable VID case optableVID(String) // v + /// Pre-encoded EID sent as-is, e.g. a hashed Email `"e:"`. + /// No encoding is applied, so never pass a plaintext value here. + case raw(String) + public var prefix: String { switch self { case .emailAddress: return "e" @@ -61,6 +65,9 @@ public enum OptableIdentifier { case .custom(nil, _): return "c" case let .custom(n?, _): return abs(n) == 0 ? "c" : "c\(abs(n))" case .optableVID: return "v" + case let .raw(value): + guard let separator = value.firstIndex(of: ":") else { return "" } + return String(value[value.startIndex ..< separator]) } } @@ -86,6 +93,7 @@ extension OptableIdentifier: Encodable { // MARK: - Init with ExtendedIdentifier public extension OptableIdentifier { + /// Hash-based types (`e`, `p`) resolve to `.raw` so re-encoding does not hash twice. init?(extendedIdentifier: String) { let parts = extendedIdentifier.split(separator: ":", maxSplits: 1).map(String.init) guard parts.count == 2 else { return nil } @@ -94,8 +102,7 @@ public extension OptableIdentifier { let value = parts[1] switch prefix { - case "e": self = .emailAddress(value) - case "p": self = .phoneNumber(value) + case "e", "p": self = .raw(extendedIdentifier) case "z": self = .postalCode(value) case "i4": self = .ipv4Address(value) case "i6": self = .ipv6Address(value) diff --git a/Tests/Unit/OptableIdentifierEncoderTests.swift b/Tests/Unit/OptableIdentifierEncoderTests.swift index 8bfb909..16fb207 100644 --- a/Tests/Unit/OptableIdentifierEncoderTests.swift +++ b/Tests/Unit/OptableIdentifierEncoderTests.swift @@ -142,6 +142,27 @@ class OptableIdentifierEncoderTests: XCTestCase { XCTAssertNotEqual(unexpected, SUT.custom(prefix, "foobarBAZ-01234#98765.!!!")) } + func test_raw() { + let hem = "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3" + + XCTAssertEqual("e:\(hem)", SUT.raw("e:\(hem)")) + XCTAssertEqual("e:\(hem)", SUT.raw(" e:\(hem)\n")) + XCTAssertEqual("c1:AaaZza.dh012", SUT.raw("c1:AaaZza.dh012")) + } + + func test_raw_isNotHashedAgain() { + let hem = "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3" + + XCTAssertEqual("e:\(hem)", OptableIdentifier.raw("e:\(hem)").extendedIdentifier) + XCTAssertNotEqual(SUT.email("e", hem), OptableIdentifier.raw("e:\(hem)").extendedIdentifier) + } + + func test_raw_prefix() { + XCTAssertEqual("e", OptableIdentifier.raw("e:abc").prefix) + XCTAssertEqual("c2", OptableIdentifier.raw("c2:abc").prefix) + XCTAssertEqual("", OptableIdentifier.raw("no-separator").prefix) + } + // MARK: Legacy func test_eidFromURL_isCorrect() { let url = "http://some.domain.com/some/path?some=query&something=else&oeid=a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3&foo=bar&baz" diff --git a/Tests/Unit/OptableIdentifiersTests.swift b/Tests/Unit/OptableIdentifiersTests.swift index 0160427..24ab6c9 100644 --- a/Tests/Unit/OptableIdentifiersTests.swift +++ b/Tests/Unit/OptableIdentifiersTests.swift @@ -11,6 +11,7 @@ import XCTest class OptableIdentifiersTests: XCTestCase { func test_json_identifier() throws { let oids: [OptableIdentifier] = [ + .raw("e:a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3"), .emailAddress("foo@bar.com"), .phoneNumber("+15123465890"), .postalCode("M5V 3L9"), @@ -34,6 +35,7 @@ class OptableIdentifiersTests: XCTestCase { let decodedData = try JSONDecoder().decode([String].self, from: encodedData) // Test existance + XCTAssertTrue(decodedData.contains(where: { $0 == "e:a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3" })) XCTAssertTrue(decodedData.contains(where: { $0 == "e:0c7e6a405862e402eb76a70f8a26fc732d07c32931e9fae9ab1582911d2e8a3b" })) XCTAssertTrue(decodedData.contains(where: { $0 == "p:f45562169005d99cdbb6908607fd5b50b66fd835a132a8225cc361d5692a8bd2" })) XCTAssertTrue(decodedData.contains(where: { $0 == "z:m5v 3l9" })) @@ -63,4 +65,32 @@ class OptableIdentifiersTests: XCTestCase { XCTAssert(c_Idx < c2_Idx) XCTAssert(c2_Idx < c1_Idx) } + + func test_extendedIdentifier_roundTrips() throws { + let eids = [ + "e:a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3", + "p:ebad3b64ae96005048fca1af2f15e5251ad3844d00fb80252711de9b651c8e46", + "z:m5v 3l9", + "i4:8.8.8.8", + "i6:2001:0db8:85a3:0000:0000:8a2e:0370:7334", + "a:496f5db5-681f-4392-acd5-0d4f6e2f6b88", + "g:64873d9f-d5af-4770-8bcb-167a220eb17d", + "r:0b179df0-6cd5-49f1-be21-425d002e0d22", + "s:e0ef86a8-6ebf-4c9d-9127-e69407fe748d", + "f:6e853799-ef31-4a30-8706-9742be254d38", + "n:_YV2v2Uhx3vqeH47Rrhzgr-4c3VNsxis4M1WY9qn--QTbVapax5VM2HJykoGAyWcwS5lKQ", + "id5:ID5*UDWnp3JOtWV0ky-bHvEeU4xOVHXCmYeg24YigF8iAymUHplfYSElM3fy79h8p-Fg", + "utiq:496f5db5-681f-4392-acd5-0d4f6e2f6b88", + "v:d29c551097b9dd0b82423827f65161232efaf7fc", + "c:d29c551097b9dd0b82423827f65161232efaf7fc", + "c1:AaaZza.dh012", + ] + + for eid in eids { + let identifier = try XCTUnwrap(OptableIdentifier(extendedIdentifier: eid), eid) + XCTAssertEqual(eid, identifier.extendedIdentifier) + } + + XCTAssertNil(OptableIdentifier(extendedIdentifier: "no-separator")) + } } From 6da1eec0f828c781f2acfcb8fc9548eba79465eb Mon Sep 17 00:00:00 2001 From: mosherBT Date: Thu, 13 Aug 2026 13:34:39 -0400 Subject: [PATCH 2/5] Support passing already-hashed Emails and Phone numbers Adds .hashedEmailAddress and .hashedPhoneNumber for callers that already hold a SHA256 of the value, such as a HEM supplied by a backend or CRM. The value is normalized (whitespace removed, lowercased) and prefixed, but not hashed a second time. Also fixes init?(extendedIdentifier:), which mapped an encoded 'e:'/'p:' value back onto .emailAddress/.phoneNumber and therefore hashed it again. This is what made tryIdentifyFromURL send sha256(sha256(email)) for the oeid in newsletter links. --- Source/Core/OptableIdentifierEncoder.swift | 10 +++--- Source/OptableSDK.swift | 7 +++-- .../OptableSDKIdentifier-Bridge.swift | 3 +- .../Public/ObjCSupport/OptableSDKIdentifier.h | 5 +-- .../Public/ObjCSupport/OptableSDKIdentifier.m | 8 +---- Source/Public/OptableIdentifier.swift | 16 +++++----- .../Unit/OptableIdentifierEncoderTests.swift | 31 ++++++++++++------- Tests/Unit/OptableIdentifiersTests.swift | 4 ++- 8 files changed, 46 insertions(+), 38 deletions(-) diff --git a/Source/Core/OptableIdentifierEncoder.swift b/Source/Core/OptableIdentifierEncoder.swift index ee4dd9b..0673812 100644 --- a/Source/Core/OptableIdentifierEncoder.swift +++ b/Source/Core/OptableIdentifierEncoder.swift @@ -31,14 +31,16 @@ enum OptableIdentifierEncoder { case let .utiq(value): utiq(prefix, value) case let .custom(idx, value): custom(prefix, idx: idx ?? 0, value) case let .optableVID(value): vid(prefix, value) - case let .raw(value): raw(value) + case let .hashedEmailAddress(value): hashed(prefix, value) + case let .hashedPhoneNumber(value): hashed(prefix, value) } return eid } - /// Passes through an already type-prefixed Extended Identifier without re-encoding it. - static func raw(_ eid: String) -> String { - return eid.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + /// Builds Extended Identifier from an already-hashed Email address or Phone number. + static func hashed(_ prefix: String, _ hash: String) -> String { + let identifier = hash.components(separatedBy: CharacterSet.whitespacesAndNewlines).joined().lowercased() + return "\(prefix):\(identifier)" } /// Builds Extended Identifier from Email address diff --git a/Source/OptableSDK.swift b/Source/OptableSDK.swift index 8aabe39..de1c91c 100644 --- a/Source/OptableSDK.swift +++ b/Source/OptableSDK.swift @@ -276,10 +276,11 @@ public extension OptableSDK { func tryIdentifyFromURL(_ urlString: String) throws { let eidStr = OptableIdentifierEncoder.eidFromURL(urlString) - guard eidStr.isEmpty == false else { return } + // The oeid is already a SHA256 of the Email, so it resolves to .hashedEmailAddress + // and is not hashed again. + guard let eid = OptableIdentifier(extendedIdentifier: eidStr) else { return } - // The oeid is already a SHA256 of the Email, so it must not be hashed again. - try self._identify([.raw(eidStr)], completion: { _ in /* no-op */ }) + try self._identify([eid], completion: { _ in /* no-op */ }) } } diff --git a/Source/Public/ObjCSupport/OptableSDKIdentifier-Bridge.swift b/Source/Public/ObjCSupport/OptableSDKIdentifier-Bridge.swift index 5074894..969a8a4 100644 --- a/Source/Public/ObjCSupport/OptableSDKIdentifier-Bridge.swift +++ b/Source/Public/ObjCSupport/OptableSDKIdentifier-Bridge.swift @@ -24,7 +24,8 @@ public extension OptableIdentifier { case .ID5: swiftType = .id5(identifier.value) case .UTIQ: swiftType = .utiq(identifier.value) case .optableVID: swiftType = .optableVID(identifier.value) - case .raw: swiftType = .raw(identifier.value) + case .hashedEmailAddress: swiftType = .hashedEmailAddress(identifier.value) + case .hashedPhoneNumber: swiftType = .hashedPhoneNumber(identifier.value) case .custom: swiftType = .custom(identifier.customIdx?.intValue, identifier.value) @unknown default: diff --git a/Source/Public/ObjCSupport/OptableSDKIdentifier.h b/Source/Public/ObjCSupport/OptableSDKIdentifier.h index 131950d..f9d2a94 100644 --- a/Source/Public/ObjCSupport/OptableSDKIdentifier.h +++ b/Source/Public/ObjCSupport/OptableSDKIdentifier.h @@ -34,8 +34,9 @@ typedef NS_ENUM(NSInteger, OptableSDKIdentifierType) { OptableSDKIdentifierType_OptableVID, - /// Pre-encoded EID sent as-is, e.g. a hashed Email "e:". - OptableSDKIdentifierType_Raw + /// Already-hashed values, normalized but not hashed again. + OptableSDKIdentifierType_HashedEmailAddress, + OptableSDKIdentifierType_HashedPhoneNumber }; @interface OptableSDKIdentifier : NSObject diff --git a/Source/Public/ObjCSupport/OptableSDKIdentifier.m b/Source/Public/ObjCSupport/OptableSDKIdentifier.m index 7d8a362..55c7953 100644 --- a/Source/Public/ObjCSupport/OptableSDKIdentifier.m +++ b/Source/Public/ObjCSupport/OptableSDKIdentifier.m @@ -112,13 +112,7 @@ + (nullable instancetype)identifierWithString:(NSString *)string NSString *typeRaw = [string substringToIndex:range.location]; NSString *value = [string substringFromIndex:range.location + 1]; - // Validate the prefix, then keep the string pre-encoded so that hash-based - // types are not hashed a second time. - if ([[self alloc] initWithTypeRawValue:typeRaw value:value] == nil) return nil; - - return [[self alloc] initWithType:OptableSDKIdentifierType_Raw - value:string - customIdx:nil]; + return [[self alloc] initWithTypeRawValue:typeRaw value:value]; } @end diff --git a/Source/Public/OptableIdentifier.swift b/Source/Public/OptableIdentifier.swift index 265cb9e..247bbc4 100644 --- a/Source/Public/OptableIdentifier.swift +++ b/Source/Public/OptableIdentifier.swift @@ -43,9 +43,9 @@ public enum OptableIdentifier { // Optable VID case optableVID(String) // v - /// Pre-encoded EID sent as-is, e.g. a hashed Email `"e:"`. - /// No encoding is applied, so never pass a plaintext value here. - case raw(String) + // Already-hashed personal identifiers, normalized but not hashed again + case hashedEmailAddress(String) // e + case hashedPhoneNumber(String) // p public var prefix: String { switch self { @@ -65,9 +65,8 @@ public enum OptableIdentifier { case .custom(nil, _): return "c" case let .custom(n?, _): return abs(n) == 0 ? "c" : "c\(abs(n))" case .optableVID: return "v" - case let .raw(value): - guard let separator = value.firstIndex(of: ":") else { return "" } - return String(value[value.startIndex ..< separator]) + case .hashedEmailAddress: return "e" + case .hashedPhoneNumber: return "p" } } @@ -93,7 +92,7 @@ extension OptableIdentifier: Encodable { // MARK: - Init with ExtendedIdentifier public extension OptableIdentifier { - /// Hash-based types (`e`, `p`) resolve to `.raw` so re-encoding does not hash twice. + /// Hash-based types (`e`, `p`) resolve to their hashed cases so re-encoding does not hash twice. init?(extendedIdentifier: String) { let parts = extendedIdentifier.split(separator: ":", maxSplits: 1).map(String.init) guard parts.count == 2 else { return nil } @@ -102,7 +101,8 @@ public extension OptableIdentifier { let value = parts[1] switch prefix { - case "e", "p": self = .raw(extendedIdentifier) + case "e": self = .hashedEmailAddress(value) + case "p": self = .hashedPhoneNumber(value) case "z": self = .postalCode(value) case "i4": self = .ipv4Address(value) case "i6": self = .ipv6Address(value) diff --git a/Tests/Unit/OptableIdentifierEncoderTests.swift b/Tests/Unit/OptableIdentifierEncoderTests.swift index 16fb207..8974ad4 100644 --- a/Tests/Unit/OptableIdentifierEncoderTests.swift +++ b/Tests/Unit/OptableIdentifierEncoderTests.swift @@ -142,25 +142,32 @@ class OptableIdentifierEncoderTests: XCTestCase { XCTAssertNotEqual(unexpected, SUT.custom(prefix, "foobarBAZ-01234#98765.!!!")) } - func test_raw() { + func test_hashedEmailAddress() { + let prefix = OptableIdentifier.hashedEmailAddress("").prefix let hem = "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3" - XCTAssertEqual("e:\(hem)", SUT.raw("e:\(hem)")) - XCTAssertEqual("e:\(hem)", SUT.raw(" e:\(hem)\n")) - XCTAssertEqual("c1:AaaZza.dh012", SUT.raw("c1:AaaZza.dh012")) + XCTAssertEqual("e:\(hem)", SUT.hashed(prefix, hem)) + XCTAssertEqual("e:\(hem)", SUT.hashed(prefix, " \(hem) ")) + XCTAssertEqual("e:\(hem)", SUT.hashed(prefix, hem.uppercased())) } - func test_raw_isNotHashedAgain() { - let hem = "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3" + func test_hashedPhoneNumber() { + let prefix = OptableIdentifier.hashedPhoneNumber("").prefix + let hash = "ebad3b64ae96005048fca1af2f15e5251ad3844d00fb80252711de9b651c8e46" - XCTAssertEqual("e:\(hem)", OptableIdentifier.raw("e:\(hem)").extendedIdentifier) - XCTAssertNotEqual(SUT.email("e", hem), OptableIdentifier.raw("e:\(hem)").extendedIdentifier) + XCTAssertEqual("p:\(hash)", SUT.hashed(prefix, hash)) + XCTAssertEqual("p:\(hash)", SUT.hashed(prefix, " \(hash) ")) } - func test_raw_prefix() { - XCTAssertEqual("e", OptableIdentifier.raw("e:abc").prefix) - XCTAssertEqual("c2", OptableIdentifier.raw("c2:abc").prefix) - XCTAssertEqual("", OptableIdentifier.raw("no-separator").prefix) + func test_hashedEmailAddress_isNotHashedAgain() { + let email = "test@foobarbaz.com" + let hem = "9e9bff5609b2e4b721e682ce7a0759d4f042819bc15a698bcb99db7897555239" + + // Hashing the plaintext and supplying the hash must yield the same EID. + XCTAssertEqual( + OptableIdentifier.emailAddress(email).extendedIdentifier, + OptableIdentifier.hashedEmailAddress(hem).extendedIdentifier + ) } // MARK: Legacy diff --git a/Tests/Unit/OptableIdentifiersTests.swift b/Tests/Unit/OptableIdentifiersTests.swift index 24ab6c9..2f08a0b 100644 --- a/Tests/Unit/OptableIdentifiersTests.swift +++ b/Tests/Unit/OptableIdentifiersTests.swift @@ -11,7 +11,8 @@ import XCTest class OptableIdentifiersTests: XCTestCase { func test_json_identifier() throws { let oids: [OptableIdentifier] = [ - .raw("e:a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3"), + .hashedEmailAddress("a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3"), + .hashedPhoneNumber("ebad3b64ae96005048fca1af2f15e5251ad3844d00fb80252711de9b651c8e46"), .emailAddress("foo@bar.com"), .phoneNumber("+15123465890"), .postalCode("M5V 3L9"), @@ -36,6 +37,7 @@ class OptableIdentifiersTests: XCTestCase { // Test existance XCTAssertTrue(decodedData.contains(where: { $0 == "e:a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3" })) + XCTAssertTrue(decodedData.contains(where: { $0 == "p:ebad3b64ae96005048fca1af2f15e5251ad3844d00fb80252711de9b651c8e46" })) XCTAssertTrue(decodedData.contains(where: { $0 == "e:0c7e6a405862e402eb76a70f8a26fc732d07c32931e9fae9ab1582911d2e8a3b" })) XCTAssertTrue(decodedData.contains(where: { $0 == "p:f45562169005d99cdbb6908607fd5b50b66fd835a132a8225cc361d5692a8bd2" })) XCTAssertTrue(decodedData.contains(where: { $0 == "z:m5v 3l9" })) From 683c408ca5b79d23209f9eaed9cd256bff3d9353 Mon Sep 17 00:00:00 2001 From: mosherBT Date: Thu, 13 Aug 2026 13:35:56 -0400 Subject: [PATCH 3/5] Name the hashed Email case hem --- Source/Core/OptableIdentifierEncoder.swift | 2 +- .../Public/ObjCSupport/OptableSDKIdentifier-Bridge.swift | 2 +- Source/Public/ObjCSupport/OptableSDKIdentifier.h | 2 +- Source/Public/OptableIdentifier.swift | 6 +++--- Tests/Unit/OptableIdentifierEncoderTests.swift | 8 ++++---- Tests/Unit/OptableIdentifiersTests.swift | 2 +- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Source/Core/OptableIdentifierEncoder.swift b/Source/Core/OptableIdentifierEncoder.swift index 0673812..8dfec0e 100644 --- a/Source/Core/OptableIdentifierEncoder.swift +++ b/Source/Core/OptableIdentifierEncoder.swift @@ -31,7 +31,7 @@ enum OptableIdentifierEncoder { case let .utiq(value): utiq(prefix, value) case let .custom(idx, value): custom(prefix, idx: idx ?? 0, value) case let .optableVID(value): vid(prefix, value) - case let .hashedEmailAddress(value): hashed(prefix, value) + case let .hem(value): hashed(prefix, value) case let .hashedPhoneNumber(value): hashed(prefix, value) } return eid diff --git a/Source/Public/ObjCSupport/OptableSDKIdentifier-Bridge.swift b/Source/Public/ObjCSupport/OptableSDKIdentifier-Bridge.swift index 969a8a4..7b69668 100644 --- a/Source/Public/ObjCSupport/OptableSDKIdentifier-Bridge.swift +++ b/Source/Public/ObjCSupport/OptableSDKIdentifier-Bridge.swift @@ -24,7 +24,7 @@ public extension OptableIdentifier { case .ID5: swiftType = .id5(identifier.value) case .UTIQ: swiftType = .utiq(identifier.value) case .optableVID: swiftType = .optableVID(identifier.value) - case .hashedEmailAddress: swiftType = .hashedEmailAddress(identifier.value) + case .hem: swiftType = .hem(identifier.value) case .hashedPhoneNumber: swiftType = .hashedPhoneNumber(identifier.value) case .custom: swiftType = .custom(identifier.customIdx?.intValue, identifier.value) diff --git a/Source/Public/ObjCSupport/OptableSDKIdentifier.h b/Source/Public/ObjCSupport/OptableSDKIdentifier.h index f9d2a94..8043dba 100644 --- a/Source/Public/ObjCSupport/OptableSDKIdentifier.h +++ b/Source/Public/ObjCSupport/OptableSDKIdentifier.h @@ -35,7 +35,7 @@ typedef NS_ENUM(NSInteger, OptableSDKIdentifierType) { OptableSDKIdentifierType_OptableVID, /// Already-hashed values, normalized but not hashed again. - OptableSDKIdentifierType_HashedEmailAddress, + OptableSDKIdentifierType_HEM, OptableSDKIdentifierType_HashedPhoneNumber }; diff --git a/Source/Public/OptableIdentifier.swift b/Source/Public/OptableIdentifier.swift index 247bbc4..4bc5e51 100644 --- a/Source/Public/OptableIdentifier.swift +++ b/Source/Public/OptableIdentifier.swift @@ -44,7 +44,7 @@ public enum OptableIdentifier { case optableVID(String) // v // Already-hashed personal identifiers, normalized but not hashed again - case hashedEmailAddress(String) // e + case hem(String) // e, Hashed Email: SHA256 of the normalized Email address case hashedPhoneNumber(String) // p public var prefix: String { @@ -65,7 +65,7 @@ public enum OptableIdentifier { case .custom(nil, _): return "c" case let .custom(n?, _): return abs(n) == 0 ? "c" : "c\(abs(n))" case .optableVID: return "v" - case .hashedEmailAddress: return "e" + case .hem: return "e" case .hashedPhoneNumber: return "p" } } @@ -101,7 +101,7 @@ public extension OptableIdentifier { let value = parts[1] switch prefix { - case "e": self = .hashedEmailAddress(value) + case "e": self = .hem(value) case "p": self = .hashedPhoneNumber(value) case "z": self = .postalCode(value) case "i4": self = .ipv4Address(value) diff --git a/Tests/Unit/OptableIdentifierEncoderTests.swift b/Tests/Unit/OptableIdentifierEncoderTests.swift index 8974ad4..866741e 100644 --- a/Tests/Unit/OptableIdentifierEncoderTests.swift +++ b/Tests/Unit/OptableIdentifierEncoderTests.swift @@ -142,8 +142,8 @@ class OptableIdentifierEncoderTests: XCTestCase { XCTAssertNotEqual(unexpected, SUT.custom(prefix, "foobarBAZ-01234#98765.!!!")) } - func test_hashedEmailAddress() { - let prefix = OptableIdentifier.hashedEmailAddress("").prefix + func test_hem() { + let prefix = OptableIdentifier.hem("").prefix let hem = "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3" XCTAssertEqual("e:\(hem)", SUT.hashed(prefix, hem)) @@ -159,14 +159,14 @@ class OptableIdentifierEncoderTests: XCTestCase { XCTAssertEqual("p:\(hash)", SUT.hashed(prefix, " \(hash) ")) } - func test_hashedEmailAddress_isNotHashedAgain() { + func test_hem_isNotHashedAgain() { let email = "test@foobarbaz.com" let hem = "9e9bff5609b2e4b721e682ce7a0759d4f042819bc15a698bcb99db7897555239" // Hashing the plaintext and supplying the hash must yield the same EID. XCTAssertEqual( OptableIdentifier.emailAddress(email).extendedIdentifier, - OptableIdentifier.hashedEmailAddress(hem).extendedIdentifier + OptableIdentifier.hem(hem).extendedIdentifier ) } diff --git a/Tests/Unit/OptableIdentifiersTests.swift b/Tests/Unit/OptableIdentifiersTests.swift index 2f08a0b..a90c21e 100644 --- a/Tests/Unit/OptableIdentifiersTests.swift +++ b/Tests/Unit/OptableIdentifiersTests.swift @@ -11,7 +11,7 @@ import XCTest class OptableIdentifiersTests: XCTestCase { func test_json_identifier() throws { let oids: [OptableIdentifier] = [ - .hashedEmailAddress("a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3"), + .hem("a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3"), .hashedPhoneNumber("ebad3b64ae96005048fca1af2f15e5251ad3844d00fb80252711de9b651c8e46"), .emailAddress("foo@bar.com"), .phoneNumber("+15123465890"), From a961e076fa4535f8541b09b9e848db49150bdb0f Mon Sep 17 00:00:00 2001 From: mosherBT Date: Thu, 13 Aug 2026 13:54:02 -0400 Subject: [PATCH 4/5] fix: use the imported ObjC case name .HEM in the bridge Swift imports all-caps NS_ENUM tokens verbatim, so OptableSDKIdentifierType_HEM is .HEM, matching the existing .ID5 and .UTIQ cases rather than the lowerCamelCase ones. --- Source/Public/ObjCSupport/OptableSDKIdentifier-Bridge.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/Public/ObjCSupport/OptableSDKIdentifier-Bridge.swift b/Source/Public/ObjCSupport/OptableSDKIdentifier-Bridge.swift index 7b69668..caf20a9 100644 --- a/Source/Public/ObjCSupport/OptableSDKIdentifier-Bridge.swift +++ b/Source/Public/ObjCSupport/OptableSDKIdentifier-Bridge.swift @@ -24,7 +24,7 @@ public extension OptableIdentifier { case .ID5: swiftType = .id5(identifier.value) case .UTIQ: swiftType = .utiq(identifier.value) case .optableVID: swiftType = .optableVID(identifier.value) - case .hem: swiftType = .hem(identifier.value) + case .HEM: swiftType = .hem(identifier.value) case .hashedPhoneNumber: swiftType = .hashedPhoneNumber(identifier.value) case .custom: swiftType = .custom(identifier.customIdx?.intValue, identifier.value) From 6d7bcc2c6a48c8f48be5cb97b79bc44585b98d24 Mon Sep 17 00:00:00 2001 From: mosherBT Date: Thu, 13 Aug 2026 15:46:53 -0400 Subject: [PATCH 5/5] Address comments --- Source/Core/EdgeAPI.swift | 5 +-- Source/Core/OptableIdentifierEncoder.swift | 32 ++++++++++++++++--- .../Public/ObjCSupport/OptableSDKIdentifier.m | 29 ++++++++++++++++- .../Unit/OptableIdentifierEncoderTests.swift | 23 ++++++++++--- Tests/Unit/OptableIdentifiersTests.swift | 20 ++++++++++++ 5 files changed, 96 insertions(+), 13 deletions(-) diff --git a/Source/Core/EdgeAPI.swift b/Source/Core/EdgeAPI.swift index 893c4ba..f456e54 100644 --- a/Source/Core/EdgeAPI.swift +++ b/Source/Core/EdgeAPI.swift @@ -41,7 +41,7 @@ final class EdgeAPI { // MARK: Endpoints func identify(ids: [OptableIdentifier]) throws -> URLRequest? { guard let url = buildEdgeAPIURL(endpoint: "identify") else { return nil } - let jsonData = try jsonEncoder.encode(ids) + let jsonData = try jsonEncoder.encode(ids.filter({ $0.extendedIdentifier.isEmpty == false })) let request = try buildRequest(.POST, url: url, headers: resolveHeaders(), data: jsonData) return request } @@ -67,7 +67,8 @@ final class EdgeAPI { guard var url = buildEdgeAPIURL(endpoint: "targeting") else { return nil } let queryItems = ids - .compactMap({ $0.extendedIdentifier }) + .map({ $0.extendedIdentifier }) + .filter({ $0.isEmpty == false }) .compactMap({ URLQueryItem(name: "id", value: $0) }) url.compatAppend(queryItems: queryItems) diff --git a/Source/Core/OptableIdentifierEncoder.swift b/Source/Core/OptableIdentifierEncoder.swift index 8dfec0e..0590761 100644 --- a/Source/Core/OptableIdentifierEncoder.swift +++ b/Source/Core/OptableIdentifierEncoder.swift @@ -31,15 +31,24 @@ enum OptableIdentifierEncoder { case let .utiq(value): utiq(prefix, value) case let .custom(idx, value): custom(prefix, idx: idx ?? 0, value) case let .optableVID(value): vid(prefix, value) - case let .hem(value): hashed(prefix, value) - case let .hashedPhoneNumber(value): hashed(prefix, value) + case let .hem(value): hem(prefix, value) + case let .hashedPhoneNumber(value): hashedPhoneNumber(prefix, value) } return eid } - /// Builds Extended Identifier from an already-hashed Email address or Phone number. - static func hashed(_ prefix: String, _ hash: String) -> String { - let identifier = hash.components(separatedBy: CharacterSet.whitespacesAndNewlines).joined().lowercased() + /// Builds Extended Identifier from an already-hashed Email address (HEM). + /// Returns an empty string when the value is not a SHA256 digest, so that a + /// plaintext Email is never sent over the wire. + static func hem(_ prefix: String, _ hash: String) -> String { + guard let identifier = validSHA256(hash) else { return "" } + return "\(prefix):\(identifier)" + } + + /// Builds Extended Identifier from an already-hashed Phone number. + /// Returns an empty string when the value is not a SHA256 digest. + static func hashedPhoneNumber(_ prefix: String, _ hash: String) -> String { + guard let identifier = validSHA256(hash) else { return "" } return "\(prefix):\(identifier)" } @@ -173,6 +182,19 @@ enum OptableIdentifierEncoder { } // MARK: - Private + private static let sha256HexCharacters = CharacterSet(charactersIn: "0123456789abcdef") + + /// Normalizes an already-hashed value, returning nil unless it is a SHA256 hex digest. + private static func validSHA256(_ hash: String) -> String? { + let identifier = hash.components(separatedBy: CharacterSet.whitespacesAndNewlines).joined().lowercased() + + guard identifier.count == 64, + identifier.rangeOfCharacter(from: sha256HexCharacters.inverted) == nil + else { return nil } + + return identifier + } + private static func sha256(data: Data) -> String { #if canImport(CryptoKit) if #available(iOS 13.0, *) { diff --git a/Source/Public/ObjCSupport/OptableSDKIdentifier.m b/Source/Public/ObjCSupport/OptableSDKIdentifier.m index 55c7953..cb6b933 100644 --- a/Source/Public/ObjCSupport/OptableSDKIdentifier.m +++ b/Source/Public/ObjCSupport/OptableSDKIdentifier.m @@ -7,6 +7,18 @@ #import "OptableSDKIdentifier.h" +/// An Email address always contains "@", which is not a hex character, so a +/// plaintext value can never be mistaken for a SHA256 digest. +static BOOL OptableIsSHA256Hex(NSString *value) +{ + if (value.length != 64) return NO; + + NSCharacterSet *nonHex = + [[NSCharacterSet characterSetWithCharactersInString:@"0123456789abcdefABCDEF"] invertedSet]; + + return [value rangeOfCharacterFromSet:nonHex].location == NSNotFound; +} + @implementation OptableSDKIdentifier { OptableSDKIdentifierType _type; NSString *_value; @@ -112,7 +124,22 @@ + (nullable instancetype)identifierWithString:(NSString *)string NSString *typeRaw = [string substringToIndex:range.location]; NSString *value = [string substringFromIndex:range.location + 1]; - return [[self alloc] initWithTypeRawValue:typeRaw value:value]; + OptableSDKIdentifier *identifier = [[self alloc] initWithTypeRawValue:typeRaw value:value]; + if (identifier == nil) return nil; + + // The packed form carries either a plaintext value or one that is already + // hashed, so the value itself decides which. Without this, a caller passing + // "e:" would have it hashed a second time. + if (OptableIsSHA256Hex(value)) { + if (identifier.type == OptableSDKIdentifierType_EmailAddress) { + return [self identifierWithType:OptableSDKIdentifierType_HEM value:value]; + } + if (identifier.type == OptableSDKIdentifierType_PhoneNumber) { + return [self identifierWithType:OptableSDKIdentifierType_HashedPhoneNumber value:value]; + } + } + + return identifier; } @end diff --git a/Tests/Unit/OptableIdentifierEncoderTests.swift b/Tests/Unit/OptableIdentifierEncoderTests.swift index 866741e..ea44ed1 100644 --- a/Tests/Unit/OptableIdentifierEncoderTests.swift +++ b/Tests/Unit/OptableIdentifierEncoderTests.swift @@ -146,17 +146,30 @@ class OptableIdentifierEncoderTests: XCTestCase { let prefix = OptableIdentifier.hem("").prefix let hem = "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3" - XCTAssertEqual("e:\(hem)", SUT.hashed(prefix, hem)) - XCTAssertEqual("e:\(hem)", SUT.hashed(prefix, " \(hem) ")) - XCTAssertEqual("e:\(hem)", SUT.hashed(prefix, hem.uppercased())) + XCTAssertEqual("e:\(hem)", SUT.hem(prefix, hem)) + XCTAssertEqual("e:\(hem)", SUT.hem(prefix, " \(hem) ")) + XCTAssertEqual("e:\(hem)", SUT.hem(prefix, hem.uppercased())) + } + + func test_hem_rejectsAnythingNotASHA256() { + let prefix = OptableIdentifier.hem("").prefix + let hem = "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3" + + // A plaintext Email must never reach the wire. + XCTAssertEqual("", SUT.hem(prefix, "test@foobarbaz.com")) + XCTAssertEqual("", SUT.hem(prefix, "")) + XCTAssertEqual("", SUT.hem(prefix, String(hem.dropLast()))) // too short + XCTAssertEqual("", SUT.hem(prefix, hem + "a")) // too long + XCTAssertEqual("", SUT.hem(prefix, String(hem.dropLast()) + "z")) // non-hex } func test_hashedPhoneNumber() { let prefix = OptableIdentifier.hashedPhoneNumber("").prefix let hash = "ebad3b64ae96005048fca1af2f15e5251ad3844d00fb80252711de9b651c8e46" - XCTAssertEqual("p:\(hash)", SUT.hashed(prefix, hash)) - XCTAssertEqual("p:\(hash)", SUT.hashed(prefix, " \(hash) ")) + XCTAssertEqual("p:\(hash)", SUT.hashedPhoneNumber(prefix, hash)) + XCTAssertEqual("p:\(hash)", SUT.hashedPhoneNumber(prefix, " \(hash) ")) + XCTAssertEqual("", SUT.hashedPhoneNumber(prefix, "+33555456789")) } func test_hem_isNotHashedAgain() { diff --git a/Tests/Unit/OptableIdentifiersTests.swift b/Tests/Unit/OptableIdentifiersTests.swift index a90c21e..a839baf 100644 --- a/Tests/Unit/OptableIdentifiersTests.swift +++ b/Tests/Unit/OptableIdentifiersTests.swift @@ -95,4 +95,24 @@ class OptableIdentifiersTests: XCTestCase { XCTAssertNil(OptableIdentifier(extendedIdentifier: "no-separator")) } + + func test_objc_identifierWithString_detectsAlreadyHashedValues() throws { + let hem = "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3" + let email = "test@foobarbaz.com" + + func eid(_ string: String) throws -> String { + let objc = try XCTUnwrap(OptableSDKIdentifier(string: string), string) + return try XCTUnwrap(OptableIdentifier(objc: objc), string).extendedIdentifier + } + + // A SHA256 is passed through, not hashed a second time. + XCTAssertEqual("e:\(hem)", try eid("e:\(hem)")) + XCTAssertEqual("p:\(hem)", try eid("p:\(hem)")) + + // A plaintext value still gets hashed, as it did before. + XCTAssertEqual(OptableIdentifier.emailAddress(email).extendedIdentifier, try eid("e:\(email)")) + + // Types that are never hashed are unaffected. + XCTAssertEqual("c9:custom-9-id", try eid("c9:custom-9-id")) + } }