From f3068b90558dfd4fde75ce2aaa4514713b99068c Mon Sep 17 00:00:00 2001 From: Nikolay Edigaryev Date: Thu, 26 May 2022 16:31:46 +0300 Subject: [PATCH] tart login: verify credentials (#102) * tart login: verify credentials * Make DictionaryCredentialsProvider fileprivate to Login.swift --- Sources/tart/Commands/Login.swift | 32 +++++++- Sources/tart/Credentials.swift | 82 ------------------- .../Credentials/CredentialsProvider.swift | 10 +++ .../KeychainCredentialsProvider.swift | 54 ++++++++++++ .../tart/Credentials/StdinCredentials.swift | 31 +++++++ Sources/tart/OCI/Registry.swift | 21 +++-- 6 files changed, 140 insertions(+), 90 deletions(-) delete mode 100644 Sources/tart/Credentials.swift create mode 100644 Sources/tart/Credentials/CredentialsProvider.swift create mode 100644 Sources/tart/Credentials/KeychainCredentialsProvider.swift create mode 100644 Sources/tart/Credentials/StdinCredentials.swift diff --git a/Sources/tart/Commands/Login.swift b/Sources/tart/Commands/Login.swift index f45ea77..23e75d9 100644 --- a/Sources/tart/Commands/Login.swift +++ b/Sources/tart/Commands/Login.swift @@ -10,9 +10,21 @@ struct Login: AsyncParsableCommand { func run() async throws { do { - let (user, password) = try Credentials.retrieveStdin() + let (user, password) = try StdinCredentials.retrieve() + let credentialsProvider = DictionaryCredentialsProvider([ + host: (user, password) + ]) - try Credentials.store(host: host, user: user, password: password) + do { + let registry = try Registry(host: host, namespace: "", credentialsProvider: credentialsProvider) + try await registry.ping() + } catch { + print("invalid credentials: \(error)") + + Foundation.exit(1) + } + + try KeychainCredentialsProvider().store(host: host, user: user, password: password) Foundation.exit(0) } catch { @@ -22,3 +34,19 @@ struct Login: AsyncParsableCommand { } } } + +fileprivate class DictionaryCredentialsProvider: CredentialsProvider { + var credentials: Dictionary + + init(_ credentials: Dictionary) { + self.credentials = credentials + } + + func retrieve(host: String) throws -> (String, String)? { + credentials[host] + } + + func store(host: String, user: String, password: String) throws { + credentials[host] = (user, password) + } +} diff --git a/Sources/tart/Credentials.swift b/Sources/tart/Credentials.swift deleted file mode 100644 index 233af52..0000000 --- a/Sources/tart/Credentials.swift +++ /dev/null @@ -1,82 +0,0 @@ -import Foundation - -enum CredentialsError: Error { - case CredentialRequired(which: String) - case CredentialTooLong(message: String) -} - -class Credentials { - static func retrieveKeychain(host: String) throws -> (String, String)? { - let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword, - kSecAttrProtocol as String: kSecAttrProtocolHTTPS, - kSecAttrServer as String: host, - kSecMatchLimit as String: kSecMatchLimitOne, - kSecReturnAttributes as String: true, - kSecReturnData as String: true, - kSecAttrLabel as String: "Tart Credentials", - ] - - var item: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &item) - - if status != errSecSuccess { - if status == errSecItemNotFound { - return nil - } - - throw RegistryError.AuthFailed(why: "Keychain returned unsuccessful status \(status)") - } - - guard let item = item as? [String: Any], - let user = item[kSecAttrAccount as String] as? String, - let passwordData = item[kSecValueData as String] as? Data, - let password = String(data: passwordData, encoding: .utf8) - else { - throw RegistryError.AuthFailed(why: "Keychain item has unexpected format") - } - - return (user, password) - } - - static func retrieveStdin() throws -> (String, String) { - let user = try readStdinCredential(name: "username", prompt: "User: ", isSensitive: false) - let password = try readStdinCredential(name: "password", prompt: "Password: ", isSensitive: true) - - return (user, password) - } - - private static func readStdinCredential(name: String, prompt: String, maxCharacters: Int = 255, isSensitive: Bool) throws -> String { - var buf = [CChar](repeating: 0, count: maxCharacters + 1 /* sentinel */ + 1 /* NUL */) - guard let rawCredential = readpassphrase(prompt, &buf, buf.count, isSensitive ? RPP_ECHO_OFF : RPP_ECHO_ON) else { - throw CredentialsError.CredentialRequired(which: name) - } - - let credential = String(cString: rawCredential).trimmingCharacters(in: .newlines) - - if credential.count > maxCharacters { - throw CredentialsError.CredentialTooLong( - message: "\(name) should contain no more than \(maxCharacters) characters") - } - - return credential - } - - static func store(host: String, user: String, password: String) throws { - let attributes: [String: Any] = [kSecClass as String: kSecClassInternetPassword, - kSecAttrAccount as String: user, - kSecAttrProtocol as String: kSecAttrProtocolHTTPS, - kSecAttrServer as String: host, - kSecValueData as String: password, - kSecAttrLabel as String: "Tart Credentials", - ] - - let status = SecItemAdd(attributes as CFDictionary, nil) - - switch status { - case errSecSuccess, errSecDuplicateItem: - return - default: - throw RegistryError.AuthFailed(why: "Keychain returned unsuccessful status \(status)") - } - } -} diff --git a/Sources/tart/Credentials/CredentialsProvider.swift b/Sources/tart/Credentials/CredentialsProvider.swift new file mode 100644 index 0000000..8f12fe0 --- /dev/null +++ b/Sources/tart/Credentials/CredentialsProvider.swift @@ -0,0 +1,10 @@ +import Foundation + +enum CredentialsProviderError: Error { + case Failed(message: String) +} + +protocol CredentialsProvider { + func retrieve(host: String) throws -> (String, String)? + func store(host: String, user: String, password: String) throws +} diff --git a/Sources/tart/Credentials/KeychainCredentialsProvider.swift b/Sources/tart/Credentials/KeychainCredentialsProvider.swift new file mode 100644 index 0000000..2cf2e4b --- /dev/null +++ b/Sources/tart/Credentials/KeychainCredentialsProvider.swift @@ -0,0 +1,54 @@ +import Foundation + +class KeychainCredentialsProvider: CredentialsProvider { + func retrieve(host: String) throws -> (String, String)? { + let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword, + kSecAttrProtocol as String: kSecAttrProtocolHTTPS, + kSecAttrServer as String: host, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnAttributes as String: true, + kSecReturnData as String: true, + kSecAttrLabel as String: "Tart Credentials", + ] + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + + if status != errSecSuccess { + if status == errSecItemNotFound { + return nil + } + + throw CredentialsProviderError.Failed(message: "Keychain returned unsuccessful status \(status)") + } + + guard let item = item as? [String: Any], + let user = item[kSecAttrAccount as String] as? String, + let passwordData = item[kSecValueData as String] as? Data, + let password = String(data: passwordData, encoding: .utf8) + else { + throw CredentialsProviderError.Failed(message: "Keychain item has unexpected format") + } + + return (user, password) + } + + func store(host: String, user: String, password: String) throws { + let attributes: [String: Any] = [kSecClass as String: kSecClassInternetPassword, + kSecAttrAccount as String: user, + kSecAttrProtocol as String: kSecAttrProtocolHTTPS, + kSecAttrServer as String: host, + kSecValueData as String: password, + kSecAttrLabel as String: "Tart Credentials", + ] + + let status = SecItemAdd(attributes as CFDictionary, nil) + + switch status { + case errSecSuccess, errSecDuplicateItem: + return + default: + throw CredentialsProviderError.Failed(message: "Keychain returned unsuccessful status \(status)") + } + } +} diff --git a/Sources/tart/Credentials/StdinCredentials.swift b/Sources/tart/Credentials/StdinCredentials.swift new file mode 100644 index 0000000..49ec9d9 --- /dev/null +++ b/Sources/tart/Credentials/StdinCredentials.swift @@ -0,0 +1,31 @@ +import Foundation + +enum StdinCredentialsError: Error { + case CredentialRequired(which: String) + case CredentialTooLong(message: String) +} + +class StdinCredentials { + static func retrieve() throws -> (String, String) { + let user = try readStdinCredential(name: "username", prompt: "User: ", isSensitive: false) + let password = try readStdinCredential(name: "password", prompt: "Password: ", isSensitive: true) + + return (user, password) + } + + private static func readStdinCredential(name: String, prompt: String, maxCharacters: Int = 255, isSensitive: Bool) throws -> String { + var buf = [CChar](repeating: 0, count: maxCharacters + 1 /* sentinel */ + 1 /* NUL */) + guard let rawCredential = readpassphrase(prompt, &buf, buf.count, isSensitive ? RPP_ECHO_OFF : RPP_ECHO_ON) else { + throw StdinCredentialsError.CredentialRequired(which: name) + } + + let credential = String(cString: rawCredential).trimmingCharacters(in: .newlines) + + if credential.count > maxCharacters { + throw StdinCredentialsError.CredentialTooLong( + message: "\(name) should contain no more than \(maxCharacters) characters") + } + + return credential + } +} diff --git a/Sources/tart/OCI/Registry.swift b/Sources/tart/OCI/Registry.swift index 3fc43e6..74e2bb8 100644 --- a/Sources/tart/OCI/Registry.swift +++ b/Sources/tart/OCI/Registry.swift @@ -79,24 +79,33 @@ class Registry { try! httpClient.syncShutdown() } - var baseURL: URL - var namespace: String + let baseURL: URL + let namespace: String + let credentialsProvider: CredentialsProvider var currentAuthToken: TokenResponse? = nil - init(urlComponents: URLComponents, namespace: String) throws { + init(urlComponents: URLComponents, + namespace: String, + credentialsProvider: CredentialsProvider = KeychainCredentialsProvider() + ) throws { baseURL = urlComponents.url! self.namespace = namespace + self.credentialsProvider = credentialsProvider } - convenience init(host: String, namespace: String) throws { + convenience init( + host: String, + namespace: String, + credentialsProvider: CredentialsProvider = KeychainCredentialsProvider() + ) throws { var baseURLComponents = URLComponents() baseURLComponents.scheme = "https" baseURLComponents.host = host baseURLComponents.path = "/v2/" - try self.init(urlComponents: baseURLComponents, namespace: namespace) + try self.init(urlComponents: baseURLComponents, namespace: namespace, credentialsProvider: credentialsProvider) } func ping() async throws { @@ -285,7 +294,7 @@ class Registry { var headers: Dictionary = Dictionary() - if let (user, password) = try Credentials.retrieveKeychain(host: baseURL.host!) { + if let (user, password) = try credentialsProvider.retrieve(host: baseURL.host!) { let encodedCredentials = "\(user):\(password)".data(using: .utf8)?.base64EncodedString() headers["Authorization"] = "Basic \(encodedCredentials!)" }