Last active
September 30, 2018 11:42
-
-
Save insidegui/c78e648409e824dff0c404637b12d639 to your computer and use it in GitHub Desktop.
Uses the pwnedpasswords API to verify password integrity
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
By default, this class requires https://github.com/idrougge/sha1-swift to work, | |
you can replace the default SHA1 implementation by setting the hash property to a function | |
that takes a String and returns an optional String (the SHA1 hex string of the input) | |
*/ | |
/// Uses the pwnedpasswords API to verify password integrity | |
public final class PwnageVerifier { | |
/// The base URL for the pwnedpasswords service (a default is provided by the initializer) | |
public let baseURL: URL | |
/// Initialize a new pwnage verifier with a base URL for the pwnedpasswords service (or the default one) | |
public init(baseURL: URL = URL(string: "https://api.pwnedpasswords.com/range")!) { | |
self.baseURL = baseURL | |
} | |
/// Hashing function to use when hashing passwords for pwnedpasswords, you can set this to your own implementation if you don't want to use the implementation from sha1-swift. You have to define USE_CUSTOM_HASHING for this to work | |
public var hash: ((String) -> String?)? | |
/// Errors returned in the verify completion handler | |
public enum Failure: Error { | |
/// The hashing failed | |
case hashing | |
/// An HTTP error occurred (includes the error code) | |
case http(Int) | |
/// A low-level networking error occurred at the URLSession level | |
case networking(Error) | |
/// Verifier failed to parse the data returned from the service | |
case parsing | |
public var localizedDescription: String { | |
switch self { | |
case .hashing: return "Failed to hash the input password" | |
case .http(let code): return "HTTP error \(code)" | |
case .networking(let err): return "Connection failed with error: \(err.localizedDescription)" | |
case .parsing: return "Failed to parse results returned from the server" | |
} | |
} | |
} | |
/// Represents a pwnage verification result | |
public enum Result: CustomStringConvertible { | |
/// Failed to verify password | |
case error(Failure) | |
/// The password has been pwned (includes count of times pwned) | |
case pwned(Int) | |
/// The password has not been pwned | |
case safe | |
public var description: String { | |
switch self { | |
case .safe: | |
return "This password has not appeared in any known data breaches" | |
case .pwned(let count): | |
return "This password has appeared in data breaches \(count) times" | |
case .error(let failure): | |
return failure.localizedDescription | |
} | |
} | |
} | |
private func hashRanges(from password: String) -> (String, String)? { | |
#if USE_CUSTOM_HASHING | |
guard let hashFunc = self.hash else { | |
fatalError("USE_CUSTOM_HASHING is defined but hash property has not been set!") | |
} | |
guard let hash = hashFunc(password) else { | |
return nil | |
} | |
#else | |
guard let hash = SHA1.hexString(from: password) else { | |
return nil | |
} | |
#endif | |
let effectiveHash = hash.replacingOccurrences(of: " ", with: "") | |
let endIndex = effectiveHash.index(effectiveHash.startIndex, offsetBy: 5) | |
let k = String(effectiveHash[effectiveHash.startIndex..<endIndex]) | |
let r = String(effectiveHash[endIndex...]) | |
return (k, r) | |
} | |
/// Performs a pwnage verification for the input password, the completion handler is called on the main queue | |
public func verify(password: String, completion: @escaping (Result) -> Void) { | |
guard let (verifyRange, matchRange) = hashRanges(from: password) else { | |
completion(.error(.hashing)) | |
return | |
} | |
let url = baseURL.appendingPathComponent(verifyRange) | |
let task = URLSession.shared.dataTask(with: url) { [weak self] data, response, error in | |
guard let `self` = self else { return } | |
var result: Result | |
defer { | |
DispatchQueue.main.async { | |
completion(result) | |
} | |
} | |
if let error = error { | |
result = .error(.networking(error)) | |
return | |
} | |
guard let response = response as? HTTPURLResponse else { | |
result = .error(.parsing) | |
return | |
} | |
guard response.statusCode == 200 else { | |
result = .error(.http(response.statusCode)) | |
return | |
} | |
guard let data = data else { | |
result = .error(.parsing) | |
return | |
} | |
guard let contents = String(data: data, encoding: .utf8) else { | |
result = .error(.parsing) | |
return | |
} | |
result = self.process(response: contents, for: matchRange) | |
} | |
task.resume() | |
} | |
private func process(response: String, for hash: String) -> Result { | |
let lines = response.components(separatedBy: "\r\n") | |
if let match = lines.first(where: { $0.contains(hash) }) { | |
let components = match.components(separatedBy: ":") | |
guard components.count > 1 else { return .error(.parsing) } | |
return .pwned(Int(components[1]) ?? -1) | |
} else { | |
return .safe | |
} | |
} | |
} | |
/* USAGE EXAMPLE: | |
let verifier = PwnageVerifier() | |
verifier.verify(password: "abc123") { result in | |
switch result { | |
case .pwned(let count): | |
print("Pwned \(count) times") | |
case .safe: | |
print("Not pwned (yet)") | |
case .error(let err): | |
print("Failed to check: \(result.description)") | |
} | |
} | |
*/ |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment