Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions Source/Core/EdgeAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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)

Expand Down
30 changes: 30 additions & 0 deletions Source/Core/OptableIdentifierEncoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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, *) {
Expand Down
2 changes: 2 additions & 0 deletions Source/OptableSDK.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 */ })
Expand Down
2 changes: 2 additions & 0 deletions Source/Public/ObjCSupport/OptableSDKIdentifier-Bridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 6 additions & 2 deletions Source/Public/ObjCSupport/OptableSDKIdentifier.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
mosherBT marked this conversation as resolved.
};

@interface OptableSDKIdentifier : NSObject
Expand Down
29 changes: 28 additions & 1 deletion Source/Public/ObjCSupport/OptableSDKIdentifier.m
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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:<sha256>" 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
11 changes: 9 additions & 2 deletions Source/Public/OptableIdentifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
}
}

Expand All @@ -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 }
Expand All @@ -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)
Expand Down
41 changes: 41 additions & 0 deletions Tests/Unit/OptableIdentifierEncoderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
52 changes: 52 additions & 0 deletions Tests/Unit/OptableIdentifiersTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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" }))
Expand Down Expand Up @@ -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"))
}
}
Loading