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 ddcdf4c..0590761 100644 --- a/Source/Core/OptableIdentifierEncoder.swift +++ b/Source/Core/OptableIdentifierEncoder.swift @@ -31,10 +31,27 @@ 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): hem(prefix, value) + case let .hashedPhoneNumber(value): hashedPhoneNumber(prefix, value) } return eid } + /// 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)" + } + /// 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) @@ -165,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/OptableSDK.swift b/Source/OptableSDK.swift index 5ea6aeb..de1c91c 100644 --- a/Source/OptableSDK.swift +++ b/Source/OptableSDK.swift @@ -276,6 +276,8 @@ public extension OptableSDK { func tryIdentifyFromURL(_ urlString: String) throws { let eidStr = OptableIdentifierEncoder.eidFromURL(urlString) + // 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 } 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 c868fe9..caf20a9 100644 --- a/Source/Public/ObjCSupport/OptableSDKIdentifier-Bridge.swift +++ b/Source/Public/ObjCSupport/OptableSDKIdentifier-Bridge.swift @@ -24,6 +24,8 @@ 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 .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 4077165..8043dba 100644 --- a/Source/Public/ObjCSupport/OptableSDKIdentifier.h +++ b/Source/Public/ObjCSupport/OptableSDKIdentifier.h @@ -31,8 +31,12 @@ typedef NS_ENUM(NSInteger, OptableSDKIdentifierType) { OptableSDKIdentifierType_UTIQ, OptableSDKIdentifierType_Custom, - - OptableSDKIdentifierType_OptableVID + + OptableSDKIdentifierType_OptableVID, + + /// Already-hashed values, normalized but not hashed again. + OptableSDKIdentifierType_HEM, + OptableSDKIdentifierType_HashedPhoneNumber }; @interface OptableSDKIdentifier : NSObject 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/Source/Public/OptableIdentifier.swift b/Source/Public/OptableIdentifier.swift index 3152a4e..4bc5e51 100644 --- a/Source/Public/OptableIdentifier.swift +++ b/Source/Public/OptableIdentifier.swift @@ -43,6 +43,10 @@ public enum OptableIdentifier { // Optable VID case optableVID(String) // v + // Already-hashed personal identifiers, normalized but not hashed again + case hem(String) // e, Hashed Email: SHA256 of the normalized Email address + case hashedPhoneNumber(String) // p + public var prefix: String { switch self { case .emailAddress: return "e" @@ -61,6 +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 .hem: return "e" + case .hashedPhoneNumber: return "p" } } @@ -86,6 +92,7 @@ extension OptableIdentifier: Encodable { // MARK: - Init with ExtendedIdentifier public extension OptableIdentifier { + /// 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 } @@ -94,8 +101,8 @@ public extension OptableIdentifier { let value = parts[1] switch prefix { - case "e": self = .emailAddress(value) - case "p": self = .phoneNumber(value) + case "e": self = .hem(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 8bfb909..ea44ed1 100644 --- a/Tests/Unit/OptableIdentifierEncoderTests.swift +++ b/Tests/Unit/OptableIdentifierEncoderTests.swift @@ -142,6 +142,47 @@ class OptableIdentifierEncoderTests: XCTestCase { XCTAssertNotEqual(unexpected, SUT.custom(prefix, "foobarBAZ-01234#98765.!!!")) } + func test_hem() { + let prefix = OptableIdentifier.hem("").prefix + let hem = "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3" + + 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.hashedPhoneNumber(prefix, hash)) + XCTAssertEqual("p:\(hash)", SUT.hashedPhoneNumber(prefix, " \(hash) ")) + XCTAssertEqual("", SUT.hashedPhoneNumber(prefix, "+33555456789")) + } + + 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.hem(hem).extendedIdentifier + ) + } + // 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..a839baf 100644 --- a/Tests/Unit/OptableIdentifiersTests.swift +++ b/Tests/Unit/OptableIdentifiersTests.swift @@ -11,6 +11,8 @@ import XCTest class OptableIdentifiersTests: XCTestCase { func test_json_identifier() throws { let oids: [OptableIdentifier] = [ + .hem("a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3"), + .hashedPhoneNumber("ebad3b64ae96005048fca1af2f15e5251ad3844d00fb80252711de9b651c8e46"), .emailAddress("foo@bar.com"), .phoneNumber("+15123465890"), .postalCode("M5V 3L9"), @@ -34,6 +36,8 @@ 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 == "p:ebad3b64ae96005048fca1af2f15e5251ad3844d00fb80252711de9b651c8e46" })) XCTAssertTrue(decodedData.contains(where: { $0 == "e:0c7e6a405862e402eb76a70f8a26fc732d07c32931e9fae9ab1582911d2e8a3b" })) XCTAssertTrue(decodedData.contains(where: { $0 == "p:f45562169005d99cdbb6908607fd5b50b66fd835a132a8225cc361d5692a8bd2" })) XCTAssertTrue(decodedData.contains(where: { $0 == "z:m5v 3l9" })) @@ -63,4 +67,52 @@ 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")) + } + + 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")) + } }