diff --git a/Pods b/Pods index 7c62088f5..653107b63 160000 --- a/Pods +++ b/Pods @@ -1 +1 @@ -Subproject commit 7c62088f5a59230b1d3435c12ab4273b97c594e9 +Subproject commit 653107b632ab7b3e8449bfaad591ac950eae41ff diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index 9b359b79f..f3fb7501e 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -431,6 +431,7 @@ 4C13C9F620E57BA30089A98B /* ColorPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C13C9F520E57BA30089A98B /* ColorPickerViewController.swift */; }; 4C20B2B720CA0034001BAC90 /* ThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4542DF51208B82E9007B4E76 /* ThreadViewModel.swift */; }; 4C20B2B920CA10DE001BAC90 /* ConversationSearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C20B2B820CA10DE001BAC90 /* ConversationSearchViewController.swift */; }; + 4C3EF7FD2107DDEE0007EBF7 /* ParamParserTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C3EF7FC2107DDEE0007EBF7 /* ParamParserTest.swift */; }; 4C3EF802210918740007EBF7 /* SSKEnvelopeTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C3EF801210918740007EBF7 /* SSKEnvelopeTest.swift */; }; 4C4AEC4520EC343B0020E72B /* DismissableTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4AEC4420EC343B0020E72B /* DismissableTextField.swift */; }; 4C4BC6C32102D697004040C9 /* ContactDiscoveryOperationTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C4BC6C22102D697004040C9 /* ContactDiscoveryOperationTest.swift */; }; @@ -1111,6 +1112,7 @@ 4C11AA4F20FD59C700351FBD /* MessageStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageStatusView.swift; sourceTree = ""; }; 4C13C9F520E57BA30089A98B /* ColorPickerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPickerViewController.swift; sourceTree = ""; }; 4C20B2B820CA10DE001BAC90 /* ConversationSearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationSearchViewController.swift; sourceTree = ""; }; + 4C3EF7FC2107DDEE0007EBF7 /* ParamParserTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParamParserTest.swift; sourceTree = ""; }; 4C3EF801210918740007EBF7 /* SSKEnvelopeTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSKEnvelopeTest.swift; sourceTree = ""; }; 4C4AEC4420EC343B0020E72B /* DismissableTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DismissableTextField.swift; sourceTree = ""; }; 4C4BC6C22102D697004040C9 /* ContactDiscoveryOperationTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ContactDiscoveryOperationTest.swift; path = contact/ContactDiscoveryOperationTest.swift; sourceTree = ""; }; @@ -2114,6 +2116,7 @@ 4C3EF8002109184A0007EBF7 /* SSKTests */ = { isa = PBXGroup; children = ( + 4C3EF7FC2107DDEE0007EBF7 /* ParamParserTest.swift */, 4C3EF801210918740007EBF7 /* SSKEnvelopeTest.swift */, ); path = SSKTests; @@ -3463,6 +3466,7 @@ 45666F581D9B2880008FE134 /* OWSScrubbingLogFormatterTest.m in Sources */, B660F6E01C29868000687D6E /* UtilTest.m in Sources */, B660F6DA1C29868000687D6E /* ExceptionsTest.m in Sources */, + 4C3EF7FD2107DDEE0007EBF7 /* ParamParserTest.swift in Sources */, B660F6DB1C29868000687D6E /* FunctionalUtilTest.m in Sources */, 45E7A6A81E71CA7E00D44FB5 /* DisplayableTextFilterTest.swift in Sources */, 4C3EF802210918740007EBF7 /* SSKEnvelopeTest.swift in Sources */, diff --git a/Signal/src/Jobs/MessageFetcherJob.swift b/Signal/src/Jobs/MessageFetcherJob.swift index 8a8d45d99..ceca5d806 100644 --- a/Signal/src/Jobs/MessageFetcherJob.swift +++ b/Signal/src/Jobs/MessageFetcherJob.swift @@ -110,7 +110,7 @@ public class MessageFetcherJob: NSObject { } }() - let envelopes = messageDicts.map { buildEnvelope(messageDict: $0) }.filter { $0 != nil }.map { $0! } + let envelopes: [SSKEnvelope] = messageDicts.compactMap { buildEnvelope(messageDict: $0) } return ( envelopes: envelopes, @@ -119,53 +119,26 @@ public class MessageFetcherJob: NSObject { } private func buildEnvelope(messageDict: [String: Any]) -> SSKEnvelope? { + do { + let params = ParamParser(dictionary: messageDict) - guard let typeInt = messageDict["type"] as? Int32 else { - Logger.error("\(self.logTag) message body didn't have type") - return nil - } - - guard let type: SSKEnvelope.SSKEnvelopeType = SSKEnvelope.SSKEnvelopeType(rawValue: typeInt) else { - Logger.error("\(self.logTag) message body type was invalid") - return nil - } - - guard let timestamp = messageDict["timestamp"] as? UInt64 else { - Logger.error("\(self.logTag) message body didn't have timestamp") - return nil - } - - guard let source = messageDict["source"] as? String else { - Logger.error("\(self.logTag) message body didn't have source") - return nil - } - - guard let sourceDevice = messageDict["sourceDevice"] as? UInt32 else { - Logger.error("\(self.logTag) message body didn't have sourceDevice") - return nil - } - - let legacyMessage: Data? = { - if let encodedLegacyMessage = messageDict["message"] as? String { - Logger.debug("\(self.logTag) message body had legacyMessage") - if let legacyMessage = Data(base64Encoded: encodedLegacyMessage) { - return legacyMessage - } + let typeInt: Int32 = try params.required(key: "type") + guard let type: SSKEnvelope.SSKEnvelopeType = SSKEnvelope.SSKEnvelopeType(rawValue: typeInt) else { + Logger.error("\(self.logTag) `typeInt` was invalid: \(typeInt)") + throw ParamParser.ParseError.invalidFormat("type") } - return nil - }() - let content: Data? = { - if let encodedContent = messageDict["content"] as? String { - Logger.debug("\(self.logTag) message body had content") - if let content = Data(base64Encoded: encodedContent) { - return content - } - } - return nil - }() + let timestamp: UInt64 = try params.required(key: "timestamp") + let source: String = try params.required(key: "source") + let sourceDevice: UInt32 = try params.required(key: "sourceDevice") + let legacyMessage = try params.optionalBase64EncodedData(key: "message") + let content: Data? = try params.optionalBase64EncodedData(key: "content") - return SSKEnvelope(timestamp: timestamp, source: source, sourceDevice: sourceDevice, type: type, content: content, legacyMessage: legacyMessage) + return SSKEnvelope(timestamp: UInt64(timestamp), source: source, sourceDevice: sourceDevice, type: type, content: content, legacyMessage: legacyMessage) + } catch { + owsFail("\(self.logTag) in \(#function) error building envelope: \(error)") + return nil + } } private func fetchUndeliveredMessages() -> Promise<(envelopes: [SSKEnvelope], more: Bool)> { diff --git a/Signal/test/SSKTests/ParamParserTest.swift b/Signal/test/SSKTests/ParamParserTest.swift new file mode 100644 index 000000000..34e93a0c2 --- /dev/null +++ b/Signal/test/SSKTests/ParamParserTest.swift @@ -0,0 +1,89 @@ +// +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// + +import XCTest + +class ParamParserTest: XCTestCase { + + override func setUp() { + super.setUp() + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDown() { + // Put teardown code here. This method is called after the invocation of each test method in the class. + super.tearDown() + } + + let dict: [String: Any] = ["some_int": 11, "some_string": "asdf", "large_int": Int64.max, "negative_int": -10] + var parser: ParamParser { + return ParamParser(dictionary: dict) + } + + func testExample() { + XCTAssertEqual(11, try parser.required(key: "some_int")) + XCTAssertEqual(11, try parser.optional(key: "some_int")) + + let expectedString: String = "asdf" + XCTAssertEqual(expectedString, try parser.required(key: "some_string")) + XCTAssertEqual(expectedString, try parser.optional(key: "some_string")) + + XCTAssertEqual(nil, try parser.optional(key: "does_not_exist") as String?) + XCTAssertThrowsError(try parser.required(key: "does_not_exist") as String) + } + + func testNumeric() { + let expectedInt32: Int32 = 11 + XCTAssertEqual(expectedInt32, try parser.required(key: "some_int")) + XCTAssertEqual(expectedInt32, try parser.optional(key: "some_int")) + + let expectedInt64: Int64 = 11 + XCTAssertEqual(expectedInt64, try parser.required(key: "some_int")) + XCTAssertEqual(expectedInt64, try parser.optional(key: "some_int")) + } + + func testNumericSizeFailures() { + XCTAssertThrowsError(try { + let _: Int32 = try parser.required(key: "large_int") + }()) + + XCTAssertThrowsError(try { + let _: Int32? = try parser.optional(key: "large_int") + }()) + + XCTAssertNoThrow(try { + let _: Int64 = try parser.required(key: "large_int") + }()) + } + + func testNumericSignFailures() { + XCTAssertNoThrow(try { + let _: Int = try parser.required(key: "negative_int") + }()) + + XCTAssertNoThrow(try { + let _: Int64 = try parser.required(key: "negative_int") + }()) + + XCTAssertThrowsError(try { + let _: UInt64 = try parser.required(key: "negative_int") + }()) + } + + func testBase64Data() { + let originalString = "asdf" + let utf8Data: Data = originalString.data(using: .utf8)! + let base64EncodedString = utf8Data.base64EncodedString() + + let dict: [String: Any] = ["some_data": base64EncodedString] + let parser = ParamParser(dictionary: dict) + + XCTAssertEqual(utf8Data, try parser.requiredBase64EncodedData(key: "some_data")) + XCTAssertEqual(utf8Data, try parser.optionalBase64EncodedData(key: "some_data")) + + let data: Data = try! parser.requiredBase64EncodedData(key: "some_data") + let roundTripString = String(data: data, encoding: .utf8) + XCTAssertEqual(originalString, roundTripString) + } +} diff --git a/SignalServiceKit/src/Contacts/OWSContactDiscoveryOperation.swift b/SignalServiceKit/src/Contacts/OWSContactDiscoveryOperation.swift index f66c00e2e..bbbfb08ce 100644 --- a/SignalServiceKit/src/Contacts/OWSContactDiscoveryOperation.swift +++ b/SignalServiceKit/src/Contacts/OWSContactDiscoveryOperation.swift @@ -400,9 +400,11 @@ class CDSBatchOperation: OWSOperation { throw ContactDiscoveryError.parseError(description: "missing response dict") } - let cipherText = try responseDict.expectBase64EncodedData(key: "data") - let initializationVector = try responseDict.expectBase64EncodedData(key: "iv") - let authTag = try responseDict.expectBase64EncodedData(key: "mac") + let params = ParamParser(dictionary: responseDict) + + let cipherText = try params.requiredBase64EncodedData(key: "data") + let initializationVector = try params.requiredBase64EncodedData(key: "iv") + let authTag = try params.requiredBase64EncodedData(key: "mac") guard let plainText = Cryptography.decryptAESGCM(withInitializationVector: initializationVector, ciphertext: cipherText, @@ -504,23 +506,3 @@ extension Array { } } } - -extension Dictionary where Key: Hashable { - - enum DictionaryError: Error { - case missingField(Key) - case invalidFormat(Key) - } - - public func expectBase64EncodedData(key: Key) throws -> Data { - guard let encodedData = self[key] as? String else { - throw DictionaryError.missingField(key) - } - - guard let data = Data(base64Encoded: encodedData) else { - throw DictionaryError.invalidFormat(key) - } - - return data - } -} diff --git a/SignalServiceKit/src/Util/ParamParser.swift b/SignalServiceKit/src/Util/ParamParser.swift new file mode 100644 index 000000000..e8501e85f --- /dev/null +++ b/SignalServiceKit/src/Util/ParamParser.swift @@ -0,0 +1,126 @@ +// +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// + +import Foundation + +// A DSL for parsing expected and optional values from a Dictionary, appropriate for +// validating a service response. +// +// Additionally it includes some helpers to DRY up common conversions. +// +// Rather than exhaustively enumerate accessors for types like `requireUInt32`, `requireInt64`, etc. +// We instead leverage generics at the call site. +// +// do { +// // Required +// let name: String = try paramParser.required(key: "name") +// let count: UInt32 = try paramParser.required(key: "count") +// +// // Optional +// let last_seen: Date? = try paramParser.optional(key: "last_seen") +// +// return Foo(name: name, count: count, isNew: lastSeen == nil) +// } catch { +// handleInvalidResponse(error: error) +// } +// +public class ParamParser { + public typealias Key = AnyHashable + + let dictionary: Dictionary + + public init(dictionary: Dictionary) { + self.dictionary = dictionary + } + + // MARK: Errors + + public enum ParseError: Error { + case missingField(Key) + case invalidFormat(Key) + } + + private func invalid(key: Key) -> ParseError { + return ParseError.invalidFormat(key) + } + + private func missing(key: Key) -> ParseError { + return ParseError.missingField(key) + } + + // MARK: - Public API + + public func required(key: Key) throws -> T { + guard let value: T = try optional(key: key) else { + throw missing(key: key) + } + + return value + } + + public func optional(key: Key) throws -> T? { + guard let someValue = dictionary[key] else { + return nil + } + + guard let typedValue = someValue as? T else { + throw invalid(key: key) + } + + return typedValue + } + + // MARK: FixedWidthIntegers (e.g. Int, Int32, UInt, UInt32, etc.) + + // You can't blindly cast accross Integer types, so we need to specify and validate which Int type we want. + // In general, you'll find numeric types parsed into a Dictionary as `Int`. + + public func required(key: Key) throws -> T where T: FixedWidthInteger { + guard let value: T = try optional(key: key) else { + throw missing(key: key) + } + + return value + } + + public func optional(key: Key) throws -> T? where T: FixedWidthInteger { + guard let someValue = dictionary[key] else { + return nil + } + + switch someValue { + case let typedValue as T: + return typedValue + case let int as Int: + guard int >= T.min, int <= T.max else { + throw invalid(key: key) + } + return T(int) + default: + throw invalid(key: key) + } + } + + // MARK: Base64 Data + + public func requiredBase64EncodedData(key: Key) throws -> Data { + guard let data: Data = try optionalBase64EncodedData(key: key) else { + throw ParseError.missingField(key) + } + + return data + } + + public func optionalBase64EncodedData(key: Key) throws -> Data? { + guard let encodedData: String = try self.optional(key: key) else { + return nil + } + + guard let data = Data(base64Encoded: encodedData) else { + throw ParseError.invalidFormat(key) + } + + return data + } +}