Skip to content

Commit 6d7bcc2

Browse files
committed
Address comments
1 parent a961e07 commit 6d7bcc2

5 files changed

Lines changed: 96 additions & 13 deletions

File tree

Source/Core/EdgeAPI.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ final class EdgeAPI {
4141
// MARK: Endpoints
4242
func identify(ids: [OptableIdentifier]) throws -> URLRequest? {
4343
guard let url = buildEdgeAPIURL(endpoint: "identify") else { return nil }
44-
let jsonData = try jsonEncoder.encode(ids)
44+
let jsonData = try jsonEncoder.encode(ids.filter({ $0.extendedIdentifier.isEmpty == false }))
4545
let request = try buildRequest(.POST, url: url, headers: resolveHeaders(), data: jsonData)
4646
return request
4747
}
@@ -67,7 +67,8 @@ final class EdgeAPI {
6767
guard var url = buildEdgeAPIURL(endpoint: "targeting") else { return nil }
6868

6969
let queryItems = ids
70-
.compactMap({ $0.extendedIdentifier })
70+
.map({ $0.extendedIdentifier })
71+
.filter({ $0.isEmpty == false })
7172
.compactMap({ URLQueryItem(name: "id", value: $0) })
7273
url.compatAppend(queryItems: queryItems)
7374

Source/Core/OptableIdentifierEncoder.swift

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,24 @@ enum OptableIdentifierEncoder {
3131
case let .utiq(value): utiq(prefix, value)
3232
case let .custom(idx, value): custom(prefix, idx: idx ?? 0, value)
3333
case let .optableVID(value): vid(prefix, value)
34-
case let .hem(value): hashed(prefix, value)
35-
case let .hashedPhoneNumber(value): hashed(prefix, value)
34+
case let .hem(value): hem(prefix, value)
35+
case let .hashedPhoneNumber(value): hashedPhoneNumber(prefix, value)
3636
}
3737
return eid
3838
}
3939

40-
/// Builds Extended Identifier from an already-hashed Email address or Phone number.
41-
static func hashed(_ prefix: String, _ hash: String) -> String {
42-
let identifier = hash.components(separatedBy: CharacterSet.whitespacesAndNewlines).joined().lowercased()
40+
/// Builds Extended Identifier from an already-hashed Email address (HEM).
41+
/// Returns an empty string when the value is not a SHA256 digest, so that a
42+
/// plaintext Email is never sent over the wire.
43+
static func hem(_ prefix: String, _ hash: String) -> String {
44+
guard let identifier = validSHA256(hash) else { return "" }
45+
return "\(prefix):\(identifier)"
46+
}
47+
48+
/// Builds Extended Identifier from an already-hashed Phone number.
49+
/// Returns an empty string when the value is not a SHA256 digest.
50+
static func hashedPhoneNumber(_ prefix: String, _ hash: String) -> String {
51+
guard let identifier = validSHA256(hash) else { return "" }
4352
return "\(prefix):\(identifier)"
4453
}
4554

@@ -173,6 +182,19 @@ enum OptableIdentifierEncoder {
173182
}
174183

175184
// MARK: - Private
185+
private static let sha256HexCharacters = CharacterSet(charactersIn: "0123456789abcdef")
186+
187+
/// Normalizes an already-hashed value, returning nil unless it is a SHA256 hex digest.
188+
private static func validSHA256(_ hash: String) -> String? {
189+
let identifier = hash.components(separatedBy: CharacterSet.whitespacesAndNewlines).joined().lowercased()
190+
191+
guard identifier.count == 64,
192+
identifier.rangeOfCharacter(from: sha256HexCharacters.inverted) == nil
193+
else { return nil }
194+
195+
return identifier
196+
}
197+
176198
private static func sha256(data: Data) -> String {
177199
#if canImport(CryptoKit)
178200
if #available(iOS 13.0, *) {

Source/Public/ObjCSupport/OptableSDKIdentifier.m

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@
77

88
#import "OptableSDKIdentifier.h"
99

10+
/// An Email address always contains "@", which is not a hex character, so a
11+
/// plaintext value can never be mistaken for a SHA256 digest.
12+
static BOOL OptableIsSHA256Hex(NSString *value)
13+
{
14+
if (value.length != 64) return NO;
15+
16+
NSCharacterSet *nonHex =
17+
[[NSCharacterSet characterSetWithCharactersInString:@"0123456789abcdefABCDEF"] invertedSet];
18+
19+
return [value rangeOfCharacterFromSet:nonHex].location == NSNotFound;
20+
}
21+
1022
@implementation OptableSDKIdentifier {
1123
OptableSDKIdentifierType _type;
1224
NSString *_value;
@@ -112,7 +124,22 @@ + (nullable instancetype)identifierWithString:(NSString *)string
112124
NSString *typeRaw = [string substringToIndex:range.location];
113125
NSString *value = [string substringFromIndex:range.location + 1];
114126

115-
return [[self alloc] initWithTypeRawValue:typeRaw value:value];
127+
OptableSDKIdentifier *identifier = [[self alloc] initWithTypeRawValue:typeRaw value:value];
128+
if (identifier == nil) return nil;
129+
130+
// The packed form carries either a plaintext value or one that is already
131+
// hashed, so the value itself decides which. Without this, a caller passing
132+
// "e:<sha256>" would have it hashed a second time.
133+
if (OptableIsSHA256Hex(value)) {
134+
if (identifier.type == OptableSDKIdentifierType_EmailAddress) {
135+
return [self identifierWithType:OptableSDKIdentifierType_HEM value:value];
136+
}
137+
if (identifier.type == OptableSDKIdentifierType_PhoneNumber) {
138+
return [self identifierWithType:OptableSDKIdentifierType_HashedPhoneNumber value:value];
139+
}
140+
}
141+
142+
return identifier;
116143
}
117144

118145
@end

Tests/Unit/OptableIdentifierEncoderTests.swift

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -146,17 +146,30 @@ class OptableIdentifierEncoderTests: XCTestCase {
146146
let prefix = OptableIdentifier.hem("").prefix
147147
let hem = "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3"
148148

149-
XCTAssertEqual("e:\(hem)", SUT.hashed(prefix, hem))
150-
XCTAssertEqual("e:\(hem)", SUT.hashed(prefix, " \(hem) "))
151-
XCTAssertEqual("e:\(hem)", SUT.hashed(prefix, hem.uppercased()))
149+
XCTAssertEqual("e:\(hem)", SUT.hem(prefix, hem))
150+
XCTAssertEqual("e:\(hem)", SUT.hem(prefix, " \(hem) "))
151+
XCTAssertEqual("e:\(hem)", SUT.hem(prefix, hem.uppercased()))
152+
}
153+
154+
func test_hem_rejectsAnythingNotASHA256() {
155+
let prefix = OptableIdentifier.hem("").prefix
156+
let hem = "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3"
157+
158+
// A plaintext Email must never reach the wire.
159+
XCTAssertEqual("", SUT.hem(prefix, "test@foobarbaz.com"))
160+
XCTAssertEqual("", SUT.hem(prefix, ""))
161+
XCTAssertEqual("", SUT.hem(prefix, String(hem.dropLast()))) // too short
162+
XCTAssertEqual("", SUT.hem(prefix, hem + "a")) // too long
163+
XCTAssertEqual("", SUT.hem(prefix, String(hem.dropLast()) + "z")) // non-hex
152164
}
153165

154166
func test_hashedPhoneNumber() {
155167
let prefix = OptableIdentifier.hashedPhoneNumber("").prefix
156168
let hash = "ebad3b64ae96005048fca1af2f15e5251ad3844d00fb80252711de9b651c8e46"
157169

158-
XCTAssertEqual("p:\(hash)", SUT.hashed(prefix, hash))
159-
XCTAssertEqual("p:\(hash)", SUT.hashed(prefix, " \(hash) "))
170+
XCTAssertEqual("p:\(hash)", SUT.hashedPhoneNumber(prefix, hash))
171+
XCTAssertEqual("p:\(hash)", SUT.hashedPhoneNumber(prefix, " \(hash) "))
172+
XCTAssertEqual("", SUT.hashedPhoneNumber(prefix, "+33555456789"))
160173
}
161174

162175
func test_hem_isNotHashedAgain() {

Tests/Unit/OptableIdentifiersTests.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,4 +95,24 @@ class OptableIdentifiersTests: XCTestCase {
9595

9696
XCTAssertNil(OptableIdentifier(extendedIdentifier: "no-separator"))
9797
}
98+
99+
func test_objc_identifierWithString_detectsAlreadyHashedValues() throws {
100+
let hem = "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3"
101+
let email = "test@foobarbaz.com"
102+
103+
func eid(_ string: String) throws -> String {
104+
let objc = try XCTUnwrap(OptableSDKIdentifier(string: string), string)
105+
return try XCTUnwrap(OptableIdentifier(objc: objc), string).extendedIdentifier
106+
}
107+
108+
// A SHA256 is passed through, not hashed a second time.
109+
XCTAssertEqual("e:\(hem)", try eid("e:\(hem)"))
110+
XCTAssertEqual("p:\(hem)", try eid("p:\(hem)"))
111+
112+
// A plaintext value still gets hashed, as it did before.
113+
XCTAssertEqual(OptableIdentifier.emailAddress(email).extendedIdentifier, try eid("e:\(email)"))
114+
115+
// Types that are never hashed are unaffected.
116+
XCTAssertEqual("c9:custom-9-id", try eid("c9:custom-9-id"))
117+
}
98118
}

0 commit comments

Comments
 (0)