Separate parser and helpers from Password class for better testability

This commit is contained in:
Danny Moesch 2018-11-11 18:09:52 +01:00 committed by Bob Sun
parent 2abbceb2e9
commit 7c12263458
17 changed files with 913 additions and 537 deletions

View file

@ -0,0 +1,30 @@
//
// PasswordHelpers.swift
// passKit
//
// Created by Danny Moesch on 17.08.18.
// Copyright © 2018 Bob Sun. All rights reserved.
//
import OneTimePassword
public enum OtpType {
case totp, hotp, none
init(token: Token?) {
switch token?.generator.factor {
case .some(.counter):
self = .hotp
case .some(.timer):
self = .totp
default:
self = .none
}
}
}
enum PasswordChange: Int {
case path = 0x01
case content = 0x02
case none = 0x00
}

View file

@ -16,3 +16,9 @@ public extension String {
return addingPercentEncoding(withAllowedCharacters: allowed)
}
}
extension String {
static func | (left: String, right: String) -> String {
return right.isEmpty ? left : left + "\n" + right
}
}

View file

@ -6,163 +6,99 @@
// Copyright © 2017 Bob Sun. All rights reserved.
//
import Foundation
import SwiftyUserDefaults
import OneTimePassword
import Base32
import KeychainAccess
public struct AdditionField: Equatable {
public var title: String = ""
public var content: String = ""
var asString: String { return title.isEmpty ? content : title + ": " + content }
var asTuple: (String, String) { return (title, content) }
public static func == (first: AdditionField, second: AdditionField) -> Bool {
return first.asTuple == second.asTuple
}
}
public enum OtpType {
case totp, hotp, none
static func from(token: Token?) -> OtpType {
switch token?.generator.factor {
case .some(.counter):
return .hotp
case .some(.timer):
return .totp
default:
return .none
}
}
}
enum PasswordChange: Int {
case path = 0x01
case content = 0x02
case none = 0x00
}
public class Password {
public static let OTP_KEYWORDS = ["otp_secret", "otp_type", "otp_algorithm", "otp_period", "otp_digits", "otp_counter", "otpauth"]
public static let BLANK = " "
public static let MULTILINE_WITH_LINE_BREAK_INDICATOR = "|"
public static let MULTILINE_WITH_LINE_BREAK_SEPARATOR = "\n"
public static let MULTILINE_WITHOUT_LINE_BREAK_INDICATOR = ">"
public static let MULTILINE_WITHOUT_LINE_BREAK_SEPARATOR = BLANK
private static let OTPAUTH = "otpauth"
private static let OTPAUTH_URL_START = "\(OTPAUTH)://"
private static let PASSWORD_KEYWORD = "password"
private static let USERNAME_KEYWORD = "username"
private static let LOGIN_KEYWORD = "login"
private static let URL_KEYWORD = "url"
private static let UNKNOWN = "unknown"
public var name: String
public var url: URL
public var namePath: String { return url.deletingPathExtension().path }
public var plainText: String
public var password = ""
public var changed: Int = 0
public var plainText = ""
public var plainData: Data { return plainText.data(using: .utf8)! }
public var username: String? { return getAdditionValue(withKey: Password.USERNAME_KEYWORD, caseSensitive: false) }
public var login: String? { return getAdditionValue(withKey: Password.LOGIN_KEYWORD, caseSensitive: false) }
public var urlString: String? { return getAdditionValue(withKey: Password.URL_KEYWORD, caseSensitive: false) }
public var otpType: OtpType = .none
private var parser = Parser(plainText: "")
private var additions = [AdditionField]()
private var firstLineIsOTPField = false
private var otpToken: Token?
public var otpType: OtpType { return OtpType.from(token: self.otpToken) }
private var otpToken: Token? {
didSet {
otpType = OtpType(token: otpToken)
}
}
public var namePath: String {
return url.deletingPathExtension().path
}
public var password: String {
return parser.firstLine
}
public var plainData: Data {
return plainText.data(using: .utf8)!
}
public var additionsPlainText: String {
return parser.additionsSection
}
public var username: String? {
return getAdditionValue(withKey: Constants.USERNAME_KEYWORD)
}
public var login: String? {
return getAdditionValue(withKey: Constants.LOGIN_KEYWORD)
}
public var urlString: String? {
return getAdditionValue(withKey: Constants.URL_KEYWORD)
}
public init(name: String, url: URL, plainText: String) {
self.name = name
self.url = url
self.initEverything(name: name, url: url, plainText: plainText)
}
public func updatePassword(name: String, url: URL, plainText: String) {
if self.plainText != plainText || self.url != url {
if self.plainText != plainText {
changed = changed|PasswordChange.content.rawValue
}
if self.url != url {
changed = changed|PasswordChange.path.rawValue
}
self.initEverything(name: name, url: url, plainText: plainText)
}
}
private func initEverything(name: String, url: URL, plainText: String) {
self.name = name
self.url = url
self.plainText = plainText
additions.removeAll()
// split the plain text
let plainTextSplit = self.plainText
.split(omittingEmptySubsequences: false) { $0 == "\n" || $0 == "\r\n" }
.map(String.init)
initEverything()
}
// get password
password = plainTextSplit.first ?? ""
public func updatePassword(name: String, url: URL, plainText: String) {
guard self.plainText != plainText || self.url != url else {
return
}
// get remaining lines (filter out empty lines)
let additionalLines = plainTextSplit[1...].filter { !$0.isEmpty }
if self.plainText != plainText {
self.plainText = plainText
changed = changed|PasswordChange.content.rawValue
}
if self.url != url {
self.url = url
changed = changed|PasswordChange.path.rawValue
}
// parse lines to get key-value pairs
parseDataFrom(lines: additionalLines)
self.name = name
initEverything()
}
// check whether the first line looks like an otp entry
private func initEverything() {
parser = Parser(plainText: self.plainText)
additions = parser.additionFields
// Check whether the first line looks like an otp entry.
checkPasswordForOtpToken()
// construct the otp token
// Construct the otp token.
updateOtpToken()
}
private func parseDataFrom(lines: [String]) {
var unknownIndex = 0
var i = lines.startIndex
while i < lines.count {
let line = lines[i]
i += 1
var (key, value) = Password.getKeyValuePair(from: line)
if key == nil {
unknownIndex += 1
key = "\(Password.UNKNOWN) \(unknownIndex)"
} else if value == Password.MULTILINE_WITH_LINE_BREAK_INDICATOR {
value = gatherMultilineValue(from: lines, startingAt: &i, removingLineBreaks: false)
} else if value == Password.MULTILINE_WITHOUT_LINE_BREAK_INDICATOR {
value = gatherMultilineValue(from: lines, startingAt: &i, removingLineBreaks: true)
}
additions.append(AdditionField(title: key!, content: value))
}
}
private func gatherMultilineValue(from content: [String], startingAt i: inout Int, removingLineBreaks: Bool) -> String {
var result = ""
guard i < content.count else { return result }
let numberInitialBlanks = content[i].enumerated().first(where: { $1 != Character(Password.BLANK) })?.0 ?? content[i].count
guard numberInitialBlanks != 0 else { return result }
let initialBlanks = String(repeating: Password.BLANK, count: numberInitialBlanks)
while i < content.count && content[i].starts(with: initialBlanks) {
result.append(String(content[i].dropFirst(numberInitialBlanks)))
result.append(removingLineBreaks ? Password.MULTILINE_WITHOUT_LINE_BREAK_SEPARATOR : Password.MULTILINE_WITH_LINE_BREAK_SEPARATOR)
i += 1
}
return result.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func checkPasswordForOtpToken() {
let (key, value) = Password.getKeyValuePair(from: self.password)
if Password.OTP_KEYWORDS.contains(key ?? "") {
let (key, value) = Parser.getKeyValuePair(from: password)
if Constants.OTP_KEYWORDS.contains(key ?? "") {
firstLineIsOTPField = true
self.additions.append(AdditionField(title: key!, content: value))
additions.append(key! => value)
} else {
firstLineIsOTPField = false
}
@ -170,48 +106,17 @@ public class Password {
public func getFilteredAdditions() -> [AdditionField] {
return additions.filter { field in
field.title.lowercased() != Password.USERNAME_KEYWORD
&& field.title.lowercased() != Password.LOGIN_KEYWORD
&& field.title.lowercased() != Password.PASSWORD_KEYWORD
&& (!field.title.hasPrefix(Password.UNKNOWN) || !SharedDefaults[.isHideUnknownOn])
&& (!Password.OTP_KEYWORDS.contains(field.title) || !SharedDefaults[.isHideOTPOn])
field.title.lowercased() != Constants.USERNAME_KEYWORD
&& field.title.lowercased() != Constants.LOGIN_KEYWORD
&& field.title.lowercased() != Constants.PASSWORD_KEYWORD
&& (!field.title.hasPrefix(Constants.UNKNOWN) || !SharedDefaults[.isHideUnknownOn])
&& (!Constants.OTP_KEYWORDS.contains(field.title) || !SharedDefaults[.isHideOTPOn])
}
}
// return a key-value pair from the line
// key might be nil, if there is no ":" in the line
private static func getKeyValuePair(from line: String) -> (key: String?, value: String) {
let items = line.components(separatedBy: ": ").map{String($0).trimmingCharacters(in: .whitespaces)}
var key : String? = nil
var value = ""
if items.count == 1 || (items[0].isEmpty && items[1].isEmpty) {
// no ": " found, or empty on both sides of ": "
value = line
// otpauth special case
if value.hasPrefix(Password.OTPAUTH_URL_START) {
key = Password.OTPAUTH
}
} else {
if !items[0].isEmpty {
key = items[0]
}
value = items[1]
}
return (key, value)
}
public func getAdditionsPlainText() -> String {
// lines starting from the second
let plainTextSplit = plainText.split(maxSplits: 1, omittingEmptySubsequences: false) {
$0 == "\n" || $0 == "\r\n"
}.map(String.init)
return plainTextSplit.count == 1 ? "" : plainTextSplit[1]
}
private func getAdditionValue(withKey key: String, caseSensitive: Bool = true) -> String? {
let searchKey = caseSensitive ? key : key.lowercased()
let matchingField = additions.first { (caseSensitive ? $0.title : $0.title.lowercased()) == searchKey }
return matchingField?.content
private func getAdditionValue(withKey key: String, caseSensitive: Bool = false) -> String? {
let toLowercase = { (string: String) -> String in return caseSensitive ? string : string.lowercased() }
return additions.first(where: { toLowercase($0.title) == toLowercase(key) })?.content
}
/*
@ -239,9 +144,9 @@ public class Password {
self.otpToken = nil
// get otpauth, if we are able to generate a token, return
if var otpauthString = getAdditionValue(withKey: Password.OTPAUTH) {
if !otpauthString.hasPrefix("\(Password.OTPAUTH):") {
otpauthString = "\(Password.OTPAUTH):\(otpauthString)"
if var otpauthString = getAdditionValue(withKey: Constants.OTPAUTH, caseSensitive: true) {
if !otpauthString.hasPrefix("\(Constants.OTPAUTH):") {
otpauthString = "\(Constants.OTPAUTH):\(otpauthString)"
}
if let otpauthUrl = URL(string: otpauthString),
let token = Token(url: otpauthUrl) {
@ -358,10 +263,10 @@ public class Password {
var lines : [String] = []
self.plainText.enumerateLines() { line, _ in
let (key, _) = Password.getKeyValuePair(from: line)
if !Password.OTP_KEYWORDS.contains(key ?? "") {
let (key, _) = Parser.getKeyValuePair(from: line)
if !Constants.OTP_KEYWORDS.contains(key ?? "") {
lines.append(line)
} else if key == Password.OTPAUTH && newOtpauth != nil {
} else if key == Constants.OTPAUTH && newOtpauth != nil {
lines.append(newOtpauth!)
// set to nil to prevent duplication
newOtpauth = nil
@ -376,11 +281,6 @@ public class Password {
return self.otpToken?.currentPassword
}
public static func LooksLikeOTP(line: String) -> Bool {
let (key, _) = getKeyValuePair(from: line)
return Password.OTP_KEYWORDS.contains(key ?? "")
}
public static func generatePassword(length: Int) -> String{
switch SharedDefaults[.passwordGeneratorFlavor] {
case "Random":

View file

@ -0,0 +1,47 @@
//
// AdditionField.swift
// passKit
//
// Created by Danny Moesch on 30.09.18.
// Copyright © 2018 Bob Sun. All rights reserved.
//
public struct AdditionField: Hashable {
public let title: String, content: String
var asString: String {
return title.isEmpty ? content : title + ": " + content
}
var asTuple: (String, String) {
return (title, content)
}
}
extension AdditionField {
static func | (left: String, right: AdditionField) -> String {
return left | right.asString
}
static func | (left: AdditionField, right: String) -> String {
return left.asString | right
}
static func | (left: AdditionField, right: AdditionField) -> String {
return left.asString | right
}
}
extension AdditionField: Equatable {
public static func == (first: AdditionField, second: AdditionField) -> Bool {
return first.asTuple == second.asTuple
}
}
infix operator =>: MultiplicationPrecedence
func => (key: String, value: String) -> AdditionField {
return AdditionField(title: key, content: value)
}

View file

@ -0,0 +1,47 @@
//
// Constants.swift
// passKit
//
// Created by Danny Moesch on 16.08.18.
// Copyright © 2018 Bob Sun. All rights reserved.
//
public struct Constants {
public static let OTP_KEYWORDS = [
"otp_secret",
"otp_type",
"otp_algorithm",
"otp_period",
"otp_digits",
"otp_counter",
"otpauth",
]
static let BLANK = " "
static let MULTILINE_WITH_LINE_BREAK_INDICATOR = "|"
static let MULTILINE_WITH_LINE_BREAK_SEPARATOR = "\n"
static let MULTILINE_WITHOUT_LINE_BREAK_INDICATOR = ">"
static let MULTILINE_WITHOUT_LINE_BREAK_SEPARATOR = BLANK
static let OTPAUTH = "otpauth"
static let OTPAUTH_URL_START = "\(OTPAUTH)://"
static let PASSWORD_KEYWORD = "password"
static let USERNAME_KEYWORD = "username"
static let LOGIN_KEYWORD = "login"
static let URL_KEYWORD = "url"
static let UNKNOWN = "unknown"
public static func isOtpRelated(line: String) -> Bool {
let (key, _) = Parser.getKeyValuePair(from: line)
return OTP_KEYWORDS.contains(key ?? "")
}
static func unknown(_ number: UInt) -> String {
return "\(UNKNOWN) \(number)"
}
static func getSeparator(breakingLines: Bool) -> String {
return breakingLines ? MULTILINE_WITH_LINE_BREAK_SEPARATOR : MULTILINE_WITHOUT_LINE_BREAK_SEPARATOR
}
}

View file

@ -0,0 +1,93 @@
//
// Parser.swift
// passKit
//
// Created by Danny Moesch on 16.08.18.
// Copyright © 2018 Bob Sun. All rights reserved.
//
class Parser {
let firstLine: String
let additionsSection: String
let purgedAdditionalLines: [String]
// The parsing process is expensive. This field makes sure it is only done once if actually needed.
private(set) lazy var additionFields = getAdditionFields()
init(plainText: String) {
let splittedPlainText = plainText
.split(omittingEmptySubsequences: false) { $0 == "\n" || $0 == "\r\n" }
.map(String.init)
firstLine = splittedPlainText.first!
additionsSection = splittedPlainText[1...].joined(separator: "\n")
purgedAdditionalLines = splittedPlainText[1...].filter { !$0.isEmpty }
}
private func getAdditionFields() -> [AdditionField] {
var additions: [AdditionField] = []
var unknownIndex: UInt = 0
var i = purgedAdditionalLines.startIndex
while i < purgedAdditionalLines.count {
let line = purgedAdditionalLines[i]
i += 1
var (key, value) = Parser.getKeyValuePair(from: line)
if key == nil {
unknownIndex += 1
key = Constants.unknown(unknownIndex)
} else if value == Constants.MULTILINE_WITH_LINE_BREAK_INDICATOR {
value = gatherMultilineValue(startingAt: &i, removingLineBreaks: false)
} else if value == Constants.MULTILINE_WITHOUT_LINE_BREAK_INDICATOR {
value = gatherMultilineValue(startingAt: &i, removingLineBreaks: true)
}
additions.append(key! => value)
}
return additions
}
private func gatherMultilineValue(startingAt i: inout Int, removingLineBreaks: Bool) -> String {
var result = ""
guard i < purgedAdditionalLines.count else {
return result
}
let numberInitialBlanks = purgedAdditionalLines[i].enumerated().first {
$1 != Character(Constants.BLANK)
}?.0 ?? purgedAdditionalLines[i].count
guard numberInitialBlanks != 0 else {
return result
}
let initialBlanks = String(repeating: Constants.BLANK, count: numberInitialBlanks)
while i < purgedAdditionalLines.count && purgedAdditionalLines[i].starts(with: initialBlanks) {
result.append(String(purgedAdditionalLines[i].dropFirst(numberInitialBlanks)))
result.append(Constants.getSeparator(breakingLines: !removingLineBreaks))
i += 1
}
return result.trimmingCharacters(in: .whitespacesAndNewlines)
}
/// Split line from password file in to a key-value pair separted by `: `.
///
/// - Parameter line: Line from a password file
/// - Returns: Pair of two `String`s of which the first one can be 'nil'
static func getKeyValuePair(from line: String) -> (key: String?, value: String) {
let items = line.components(separatedBy: ": ").map { String($0).trimmingCharacters(in: .whitespaces) }
var key: String?
var value = ""
if items.count == 1 || (items[0].isEmpty && items[1].isEmpty) {
// No ': ' found, or empty on both sides of ': '.
value = line
// "otpauth" special case
if value.hasPrefix(Constants.OTPAUTH_URL_START) {
key = Constants.OTPAUTH
}
} else {
if !items[0].isEmpty {
key = items[0]
}
value = items[1]
}
return (key, value)
}
}