diff --git a/Package.swift b/Package.swift index de9ffebf..6ead6b8f 100644 --- a/Package.swift +++ b/Package.swift @@ -15,6 +15,7 @@ let package = Package( .package(url: "https://github.com/hummingbird-project/hummingbird-core.git", from: "0.13.3"), .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), .package(url: "https://github.com/apple/swift-argument-parser", from: "1.0.0"), + .package(url: "https://github.com/apple/swift-crypto.git", "1.0.0" ..< "3.0.0"), .package(url: "https://github.com/vapor/postgres-kit", from: "2.4.0"), .package(url: "https://github.com/vapor/mysql-kit", from: "4.3.0"), .package(url: "https://github.com/vapor/sqlite-kit", from: "4.0.0"), @@ -24,8 +25,8 @@ let package = Package( .package(url: "https://github.com/alchemy-swift/fusion", .upToNextMinor(from: "0.3.0")), .package(url: "https://github.com/alchemy-swift/cron.git", from: "2.3.2"), .package(url: "https://github.com/alchemy-swift/pluralize", from: "1.0.1"), - .package(url: "https://github.com/johnsundell/Plot.git", from: "0.8.0"), .package(url: "https://github.com/alchemy-swift/RediStack.git", branch: "ssl-support-1.2.0"), + .package(url: "https://github.com/johnsundell/Plot.git", from: "0.8.0"), .package(url: "https://github.com/onevcat/Rainbow", .upToNextMajor(from: "4.0.0")), .package(url: "https://github.com/vadymmarkov/Fakery", from: "5.0.0"), ], @@ -52,6 +53,7 @@ let package = Package( .product(name: "HummingbirdFoundation", package: "hummingbird"), .product(name: "HummingbirdHTTP2", package: "hummingbird-core"), .product(name: "HummingbirdTLS", package: "hummingbird-core"), + .product(name: "Crypto", package: "swift-crypto"), /// Internal dependencies .byName(name: "AlchemyC"), diff --git a/Sources/Alchemy/Alchemy+Papyrus/Endpoint+Request.swift b/Sources/Alchemy/Alchemy+Papyrus/Endpoint+Request.swift index 4aaefe18..6061fcf5 100644 --- a/Sources/Alchemy/Alchemy+Papyrus/Endpoint+Request.swift +++ b/Sources/Alchemy/Alchemy+Papyrus/Endpoint+Request.swift @@ -50,7 +50,6 @@ extension Client { let method = HTTPMethod(rawValue: rawRequest.method) let fullUrl = try rawRequest.fullURL() builder = builder.withBaseUrl(fullUrl).withMethod(method) - if let mockedResponse = endpoint.mockedResponse { let clientRequest = builder.clientRequest let clientResponse = Client.Response(request: clientRequest, host: "mock", status: .ok, version: .http1_1, headers: [:]) diff --git a/Sources/Alchemy/Auth/BasicAuthable.swift b/Sources/Alchemy/Auth/BasicAuthable.swift index ce4135a9..1b4e1525 100644 --- a/Sources/Alchemy/Auth/BasicAuthable.swift +++ b/Sources/Alchemy/Auth/BasicAuthable.swift @@ -74,7 +74,7 @@ extension BasicAuthable { /// - Returns: A `Bool` indicating if `password` matched /// `passwordHash`. public static func verify(password: String, passwordHash: String) throws -> Bool { - try Bcrypt.verifySync(password, created: passwordHash) + try Hash.verify(password, hash: passwordHash) } /// A `Middleware` configured to validate the diff --git a/Sources/Alchemy/Config/Service.swift b/Sources/Alchemy/Config/Service.swift index 39c92f58..d0d1c3ff 100644 --- a/Sources/Alchemy/Config/Service.swift +++ b/Sources/Alchemy/Config/Service.swift @@ -42,7 +42,7 @@ extension Service { // MARK: Resolve shorthand public static var `default`: Self { - Container.resolveAssert(Self.self, identifier: Database.Identifier.default) + .id(.default) } public static func id(_ identifier: Identifier) -> Self { diff --git a/Sources/Alchemy/Encryption/Encrypted.swift b/Sources/Alchemy/Encryption/Encrypted.swift new file mode 100644 index 00000000..479ab60c --- /dev/null +++ b/Sources/Alchemy/Encryption/Encrypted.swift @@ -0,0 +1,21 @@ +@propertyWrapper +public struct Encrypted: ModelProperty { + public var wrappedValue: String + + // MARK: ModelProperty + + public init(key: String, on row: SQLRowReader) throws { + let encrypted = try row.require(key).string() + guard let data = Data(base64Encoded: encrypted) else { + throw EncryptionError("could not decrypt data; it wasn't base64 encoded") + } + + wrappedValue = try Crypt.decrypt(data: data) + } + + public func store(key: String, on row: inout SQLRowWriter) throws { + let encrypted = try Crypt.encrypt(string: wrappedValue) + let string = encrypted.base64EncodedString() + row.put(.string(string), at: key) + } +} diff --git a/Sources/Alchemy/Encryption/Encrypter.swift b/Sources/Alchemy/Encryption/Encrypter.swift new file mode 100644 index 00000000..0333e9a3 --- /dev/null +++ b/Sources/Alchemy/Encryption/Encrypter.swift @@ -0,0 +1,58 @@ +import Crypto +import Foundation + +extension SymmetricKey { + public static var app: SymmetricKey = { + guard let appKey: String = Env.APP_KEY else { + fatalError("Unable to load APP_KEY from Environment. Please set an APP_KEY before encrypting any data with `Crypt` or provide a custom `SymmetricKey` using `Crypt(key:)`.") + } + + guard let data = Data(base64Encoded: appKey) else { + fatalError("Unable to create encryption key from APP_KEY. Please ensure APP_KEY is a base64 encoded String.") + } + + return SymmetricKey(data: data) + }() +} + +public struct Encrypter { + private let key: SymmetricKey + + public init(key: SymmetricKey) { + self.key = key + } + + public func encrypt(string: String) throws -> Data { + try encrypt(data: Data(string.utf8)) + } + + public func encrypt(data: D) throws -> Data { + guard let result = try AES.GCM.seal(data, using: key).combined else { + throw EncryptionError("could not encrypt the data") + } + + return result + } + + public func decrypt(base64Encoded string: String) throws -> String { + guard let data = Data(base64Encoded: string) else { + throw EncryptionError("the string wasn't base64 encoded") + } + + return try decrypt(data: data) + } + + public func decrypt(data: D) throws -> String { + let box = try AES.GCM.SealedBox(combined: data) + let data = try AES.GCM.open(box, using: key) + guard let string = String(data: data, encoding: .utf8) else { + throw EncryptionError("could not decrypt the data") + } + + return string + } + + public static func generateKeyString(size: SymmetricKeySize = .bits256) -> String { + SymmetricKey(size: size).withUnsafeBytes { Data($0) }.base64EncodedString() + } +} diff --git a/Sources/Alchemy/Encryption/EncryptionError.swift b/Sources/Alchemy/Encryption/EncryptionError.swift new file mode 100644 index 00000000..ea0cf24a --- /dev/null +++ b/Sources/Alchemy/Encryption/EncryptionError.swift @@ -0,0 +1,7 @@ +public struct EncryptionError: Error { + public let message: String + + public init(_ message: String) { + self.message = message + } +} diff --git a/Sources/Alchemy/Filesystem/File.swift b/Sources/Alchemy/Filesystem/File.swift index f41b399c..e12f8618 100644 --- a/Sources/Alchemy/Filesystem/File.swift +++ b/Sources/Alchemy/Filesystem/File.swift @@ -1,68 +1,139 @@ import MultipartKit import Papyrus +import NIOCore -/// Represents a file with a name and binary contents. +// File public struct File: Codable, ResponseConvertible { - // The name of the file, including the extension. + public enum Source { + // The file is stored in a `Filesystem` with the given path. + case filesystem(Filesystem? = nil, path: String) + // The file came with the given ContentType from an HTTP request. + case http(clientContentType: ContentType?) + + static var raw: Source { + .http(clientContentType: nil) + } + } + + /// The name of this file, including the extension public var name: String - // The size of the file, in bytes. - public let size: Int - // The binary contents of the file. - public var content: ByteContent + /// The source of this file, either from an HTTP request or from a Filesystem. + public var source: Source + public var content: ByteContent? + public let size: Int? + public let clientContentType: ContentType? /// The path extension of this file. - public var `extension`: String { name.components(separatedBy: ".").last ?? "" } - /// The content type of this file, based on it's extension. - public let contentType: ContentType + public var `extension`: String { + name.components(separatedBy: ".").last ?? "" + } - public init(name: String, contentType: ContentType? = nil, size: Int, content: ByteContent) { + public var contentType: ContentType { + name.components(separatedBy: ".").last.map { ContentType(fileExtension: $0) ?? .octetStream } ?? .octetStream + } + + public init(name: String, source: Source, content: ByteContent? = nil, size: Int? = nil) { self.name = name - self.size = size + self.source = source self.content = content - let _extension = name.components(separatedBy: ".").last ?? "" - self.contentType = contentType ?? ContentType(fileExtension: _extension) ?? .octetStream + self.size = size + self.clientContentType = nil } - /// Returns a copy of this file with a new name. - public func named(_ name: String) -> File { + public func _in(_ filesystem: Filesystem) -> File { var copy = self - copy.name = name + switch source { + case .filesystem(_, let path): + copy.source = .filesystem(filesystem, path: path) + default: + break + } + return copy } + // MARK: - Accessing Contents + + /// get a url for this resource + public func url() throws -> URL { + switch source { + case .filesystem(let filesystem, let path): + return try (filesystem ?? Storage).url(path) + case .http: + throw FileError.urlUnavailable + } + } + + /// get temporary url for this resource + public func temporaryUrl(expires: TimeAmount, headers: HTTPHeaders = [:]) async throws -> URL { + switch source { + case .filesystem(let filesystem, let path): + return try await (filesystem ?? Storage).temporaryURL(path, expires: expires, headers: headers) + default: + throw FileError.temporaryUrlNotAvailable + } + } + + public func getContent() async throws -> ByteContent { + guard let content = content else { + switch source { + case .http: + throw FileError.contentNotLoaded + case .filesystem(let filesystem, let path): + return try await (filesystem ?? Storage).get(path).getContent() + } + } + + return content + } + + // MARK: ModelProperty + + init(key: String, on row: SQLRowReader) throws { + let name = try row.require(key).string() + self.init(name: name, source: .filesystem(Storage, path: name)) + } + + func store(key: String, on row: inout SQLRowWriter) throws { + guard case .filesystem(_, let path) = source else { + throw RuneError("currently, only files saved in a `Filesystem` can be stored on a `Model`") + } + + row.put(.string(path), at: key) + } + // MARK: - ResponseConvertible public func response() async throws -> Response { - Response(status: .ok, headers: ["Content-Disposition":"inline; filename=\"\(name)\""]) + let content = try await getContent() + return Response(status: .ok, headers: ["Content-Disposition":"inline; filename=\"\(name)\""]) .withBody(content, type: contentType, length: size) } public func download() async throws -> Response { - Response(status: .ok, headers: ["Content-Disposition":"attachment; filename=\"\(name)\""]) + let content = try await getContent() + return Response(status: .ok, headers: ["Content-Disposition":"attachment; filename=\"\(name)\""]) .withBody(content, type: contentType, length: size) } - // MARK: - Decodable - - enum CodingKeys: String, CodingKey { - case name, size, content - } + // MARK: - Codable public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.name = try container.decode(String.self, forKey: .name) - self.size = try container.decode(Int.self, forKey: .size) - self.content = .data(try container.decode(Data.self, forKey: .content)) - let _extension = name.components(separatedBy: ".").last ?? "" - self.contentType = ContentType(fileExtension: _extension) ?? .octetStream + let container = try decoder.singleValueContainer() + let data = try container.decode(Data.self) + self.name = UUID().uuidString + self.source = .raw + self.content = .data(data) + self.size = data.count + self.clientContentType = nil } - // MARK: - Encodable - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(name, forKey: .name) - try container.encode(size, forKey: .size) - try container.encode(content.data(), forKey: .content) + var container = encoder.singleValueContainer() + guard let content = content else { + throw FileError.contentNotLoaded + } + + try container.encode(content.data()) } } @@ -70,10 +141,15 @@ public struct File: Codable, ResponseConvertible { extension File: MultipartPartConvertible { public var multipart: MultipartPart? { var headers: HTTPHeaders = [:] - headers.contentType = ContentType(fileExtension: `extension`) + headers.contentType = contentType headers.contentDisposition = HTTPHeaders.ContentDisposition(value: "form-data", name: nil, filename: name) headers.contentLength = size - return MultipartPart(headers: headers, body: content.buffer) + guard let content = self.content else { + Log.warning("Unable to convert a filesystem reference to a `MultipartPart`. Please load the contents of the file first.") + return nil + } + + return MultipartPart(headers: headers, body: content.data()) } public init?(multipart: MultipartPart) { @@ -86,6 +162,8 @@ extension File: MultipartPartConvertible { } // If there is no filename in the content disposition included (technically not required via RFC 7578) set to a random UUID. - self.init(name: (fileName ?? UUID().uuidString) + fileExtension, contentType: multipart.headers.contentType, size: fileSize, content: .buffer(multipart.body)) + let name = (fileName ?? UUID().uuidString) + fileExtension + let contentType = multipart.headers.contentType + self.init(name: name, source: .http(clientContentType: contentType), content: .buffer(multipart.body), size: fileSize) } } diff --git a/Sources/Alchemy/Filesystem/Filesystem.swift b/Sources/Alchemy/Filesystem/Filesystem.swift index eaa9a457..cb95f649 100644 --- a/Sources/Alchemy/Filesystem/Filesystem.swift +++ b/Sources/Alchemy/Filesystem/Filesystem.swift @@ -24,7 +24,7 @@ public struct Filesystem: Service { /// - Returns: The newly created file. @discardableResult public func create(_ filepath: String, content: ByteContent) async throws -> File { - try await provider.create(filepath, content: content) + try await provider.create(filepath, content: content)._in(self) } /// Returns whether a file with the given path exists. @@ -32,9 +32,9 @@ public struct Filesystem: Service { try await provider.exists(filepath) } - /// Gets a file with the given path. + /// Gets the contents of the file at the given path. public func get(_ filepath: String) async throws -> File { - try await provider.get(filepath) + try await provider.get(filepath)._in(self) } /// Delete a file at the given path. @@ -42,18 +42,34 @@ public struct Filesystem: Service { try await provider.delete(filepath) } - public func put(_ file: File, in directory: String? = nil) async throws { + @discardableResult + public func put(_ file: File, in directory: String? = nil, as name: String? = nil) async throws -> File { + let content = try await file.getContent() + let name = name ?? (UUID().uuidString + file.extension) guard let directory = directory, let directoryUrl = URL(string: directory) else { - try await create(file.name, content: file.content) - return + return try await create(name, content: content) } - try await create(directoryUrl.appendingPathComponent(file.name).path, content: file.content) + return try await create(directoryUrl.appendingPathComponent(name).path, content: content) + } + + public func temporaryURL(_ filepath: String, expires: TimeAmount, headers: HTTPHeaders = [:]) async throws -> URL { + try await provider.temporaryURL(filepath, expires: expires, headers: headers) + } + + public func url(_ filepath: String) throws -> URL { + try provider.url(filepath) + } + + public func directory(_ path: String) -> Filesystem { + Filesystem(provider: provider.directory(path)) } } extension File { - public func store(in directory: String? = nil, on filesystem: Filesystem = Storage) async throws { - try await filesystem.put(self, in: directory) + @discardableResult + public func store(on filesystem: Filesystem = Storage, in directory: String? = nil, as name: String? = nil) async throws -> File { + let name = name ?? (UUID().uuidString + `extension`) + return try await filesystem.put(self, in: directory, as: name) } } diff --git a/Sources/Alchemy/Filesystem/FilesystemError.swift b/Sources/Alchemy/Filesystem/FilesystemError.swift index 993c637d..0002f32d 100644 --- a/Sources/Alchemy/Filesystem/FilesystemError.swift +++ b/Sources/Alchemy/Filesystem/FilesystemError.swift @@ -1,5 +1,8 @@ public enum FileError: Error { case invalidFileUrl + case urlUnavailable case fileDoesntExist case filenameAlreadyExists + case temporaryUrlNotAvailable + case contentNotLoaded } diff --git a/Sources/Alchemy/Filesystem/Providers/FilesystemProvider.swift b/Sources/Alchemy/Filesystem/Providers/FilesystemProvider.swift index c37850f8..b0bb560c 100644 --- a/Sources/Alchemy/Filesystem/Providers/FilesystemProvider.swift +++ b/Sources/Alchemy/Filesystem/Providers/FilesystemProvider.swift @@ -20,4 +20,10 @@ public protocol FilesystemProvider { /// Delete a file at the given path. func delete(_ filepath: String) async throws + + func temporaryURL(_ filepath: String, expires: TimeAmount, headers: HTTPHeaders) async throws -> URL + + func url(_ filepath: String) throws -> URL + + func directory(_ path: String) -> FilesystemProvider } diff --git a/Sources/Alchemy/Filesystem/Providers/LocalFilesystem.swift b/Sources/Alchemy/Filesystem/Providers/LocalFilesystem.swift index 522fa93b..de8eeea1 100644 --- a/Sources/Alchemy/Filesystem/Providers/LocalFilesystem.swift +++ b/Sources/Alchemy/Filesystem/Providers/LocalFilesystem.swift @@ -42,7 +42,7 @@ struct LocalFilesystem: FilesystemProvider { return File( name: url.lastPathComponent, - size: fileSizeBytes, + source: .filesystem(path: filepath), content: .stream { writer in // Load the file in chunks, streaming it. let fileHandle = try NIOFileHandle(path: url.path) @@ -57,7 +57,8 @@ struct LocalFilesystem: FilesystemProvider { Loop.current.asyncSubmit { try await writer.write(chunk) } } ).get() - }) + }, + size: fileSizeBytes) } func create(_ filepath: String, content: ByteContent) async throws -> File { @@ -76,7 +77,7 @@ struct LocalFilesystem: FilesystemProvider { offset += Int64(buffer.writerIndex) } - return try await get(filepath) + return File(name: url.path, source: .filesystem(path: url.relativeString)) } func exists(_ filepath: String) async throws -> Bool { @@ -108,4 +109,23 @@ struct LocalFilesystem: FilesystemProvider { return url } + + func url(_ filepath: String) throws -> URL { + guard let url = URL(string: root + filepath) else { + throw FileError.urlUnavailable + } + + return url + } + + func temporaryURL(_ filepath: String, expires: TimeAmount, headers: HTTPHeaders = [:]) async throws -> URL { + throw FileError.temporaryUrlNotAvailable + } + + func directory(_ path: String) -> FilesystemProvider { + var copy = self + let pathToAppend = root.last == "/" ? path : "/\(path)" + copy.root.append(pathToAppend) + return copy + } } diff --git a/Sources/Alchemy/HTTP/Content/ByteContent.swift b/Sources/Alchemy/HTTP/Content/ByteContent.swift index 0e679e84..0f5ff666 100644 --- a/Sources/Alchemy/HTTP/Content/ByteContent.swift +++ b/Sources/Alchemy/HTTP/Content/ByteContent.swift @@ -69,7 +69,7 @@ public enum ByteContent: ExpressibleByStringLiteral { extension File { @discardableResult mutating func collect() async throws -> File { - self.content = .buffer(try await content.collect()) + self.content = .buffer(try await getContent().collect()) return self } } diff --git a/Sources/Alchemy/HTTP/Content/ContentCoding+Multipart.swift b/Sources/Alchemy/HTTP/Content/ContentCoding+Multipart.swift index 3fd38bc4..e65013b9 100644 --- a/Sources/Alchemy/HTTP/Content/ContentCoding+Multipart.swift +++ b/Sources/Alchemy/HTTP/Content/ContentCoding+Multipart.swift @@ -66,7 +66,7 @@ extension MultipartPart: ContentValue { public var file: File? { guard let disposition = headers.contentDisposition, let filename = disposition.filename else { return nil } - return File(name: filename, size: body.writerIndex, content: .buffer(body)) + return File(name: filename, source: .http(clientContentType: headers.contentType), content: .buffer(body), size: body.writerIndex) } } diff --git a/Sources/Alchemy/HTTP/Protocols/ContentBuilder.swift b/Sources/Alchemy/HTTP/Protocols/ContentBuilder.swift index f4340cf9..cc005049 100644 --- a/Sources/Alchemy/HTTP/Protocols/ContentBuilder.swift +++ b/Sources/Alchemy/HTTP/Protocols/ContentBuilder.swift @@ -75,7 +75,7 @@ extension ContentBuilder { } public func attach(_ name: String, contents: ByteBuffer, filename: String? = nil, encoder: FormDataEncoder = FormDataEncoder()) async throws -> Self { - let file = File(name: filename ?? name, size: contents.writerIndex, content: .buffer(contents)) + let file = File(name: filename ?? name, source: .raw, content: .buffer(contents), size: contents.writerIndex) return try withBody([name: file], encoder: encoder) } diff --git a/Sources/Alchemy/Utilities/BCrypt.swift b/Sources/Alchemy/Hashing/BCryptHasher.swift similarity index 70% rename from Sources/Alchemy/Utilities/BCrypt.swift rename to Sources/Alchemy/Hashing/BCryptHasher.swift index 039b5eb7..138bc668 100644 --- a/Sources/Alchemy/Utilities/BCrypt.swift +++ b/Sources/Alchemy/Hashing/BCryptHasher.swift @@ -1,46 +1,31 @@ -/// The MIT License (MIT) -/// -/// Copyright (c) 2020 Qutheory, LLC -/// -/// Permission is hereby granted, free of charge, to any person obtaining a copy -/// of this software and associated documentation files (the "Software"), to deal -/// in the Software without restriction, including without limitation the rights -/// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -/// copies of the Software, and to permit persons to whom the Software is -/// furnished to do so, subject to the following conditions: -/// -/// The above copyright notice and this permission notice shall be included in all -/// copies or substantial portions of the Software. -/// -/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -/// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -/// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -/// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -/// SOFTWARE. -/// -/// Courtesy of https://github.com/vapor/vapor -/// -/// This depends on the supplied `CBCrypt` C library. import CAlchemy -/// Creates and verifies BCrypt hashes. -/// -/// Use BCrypt to create hashes for sensitive information like passwords. -/// -/// try BCrypt.hash("vapor", cost: 4) -/// -/// BCrypt uses a random salt each time it creates a hash. To verify hashes, use the `verify(_:matches)` method. -/// -/// let hash = try BCrypt.hash("vapor", cost: 4) -/// try BCrypt.verify("vapor", created: hash) // true -/// -/// https://en.wikipedia.org/wiki/Bcrypt -public var Bcrypt: BCryptDigest { - return .init() +extension HashAlgorithm where Self == BCryptHasher { + public static var bcrypt: BCryptHasher { + BCryptHasher(rounds: 10) + } + + public static func bcrypt(rounds: Int) -> BCryptHasher { + BCryptHasher(rounds: rounds) + } } +public final class BCryptHasher: HashAlgorithm { + private let bcrypt = _BCrypt() + private let rounds: Int + + public init(rounds: Int) { + self.rounds = rounds + } + + public func verify(_ plaintext: String, hash: String) throws -> Bool { + try bcrypt.verify(plaintext, hash: hash) + } + + public func make(_ value: String) throws -> String { + try bcrypt.hash(value, cost: rounds) + } +} /// Creates and verifies BCrypt hashes. Normally you will not need to initialize one of these classes and you will /// use the global `BCrypt` convenience instead. @@ -48,24 +33,48 @@ public var Bcrypt: BCryptDigest { /// try BCrypt.hash("vapor", cost: 4) /// /// See `BCrypt` for more information. -public final class BCryptDigest { - /// Creates a new `BCryptDigest`. Use the global `BCrypt` convenience variable. - public init() { } - - /// Asynchronously hashes a password on a separate thread. - /// - /// - Parameter password: The password to hash. - /// - Returns: The hashed password. - public func hash(_ password: String) async throws -> String { - try await Thread.run { try Bcrypt.hashSync(password) } +private final class _BCrypt { + func hash(_ plaintext: String, cost: Int = 12) throws -> String { + guard cost >= BCRYPT_MINLOGROUNDS && cost <= 31 else { + throw BcryptError.invalidCost + } + + return try _hash(plaintext, salt: generateSalt(cost: cost)) } - public func hashSync(_ plaintext: String, cost: Int = 12) throws -> String { - guard cost >= BCRYPT_MINLOGROUNDS && cost <= 31 else { throw BcryptError.invalidCost } - return try self.hashSync(plaintext, salt: self.generateSalt(cost: cost)) + /// Verifies an existing BCrypt hash matches the supplied plaintext value. Verification works by parsing the salt and version from + /// the existing digest and using that information to hash the plaintext data. If hash digests match, this method returns `true`. + /// + /// let hash = try BCrypt.hash("vapor", cost: 4) + /// try BCrypt.verify("vapor", created: hash) // true + /// try BCrypt.verify("foo", created: hash) // false + /// + /// - parameters: + /// - plaintext: Plaintext data to digest and verify. + /// - hash: Existing BCrypt hash to parse version, salt, and existing digest from. + /// - throws: `CryptoError` if hashing fails or if data conversion fails. + /// - returns: `true` if the hash was created from the supplied plaintext data. + func verify(_ plaintext: String, hash: String) throws -> Bool { + guard let hashVersion = Algorithm(rawValue: String(hash.prefix(4))) else { + throw BcryptError.invalidHash + } + + let hashSalt = String(hash.prefix(hashVersion.fullSaltCount)) + guard !hashSalt.isEmpty, hashSalt.count == hashVersion.fullSaltCount else { + throw BcryptError.invalidHash + } + + let hashChecksum = String(hash.suffix(hashVersion.checksumCount)) + guard !hashChecksum.isEmpty, hashChecksum.count == hashVersion.checksumCount else { + throw BcryptError.invalidHash + } + + let messageHash = try _hash(plaintext, salt: hashSalt) + let messageHashChecksum = String(messageHash.suffix(hashVersion.checksumCount)) + return messageHashChecksum.secureCompare(to: hashChecksum) } - public func hashSync(_ plaintext: String, salt: String) throws -> String { + private func _hash(_ plaintext: String, salt: String) throws -> String { guard isSaltValid(salt) else { throw BcryptError.invalidSalt } @@ -110,50 +119,6 @@ public final class BCryptDigest { .dropFirst(originalAlgorithm.revisionCount) } - /// Asynchronously verifies a password & hash on a separate - /// thread. - /// - /// - Parameters: - /// - plaintext: The plaintext password. - /// - hashed: The hashed password to verify with. - /// - Returns: Whether the password and hash matched. - public func verify(plaintext: String, hashed: String) async throws -> Bool { - try await Thread.run { try Bcrypt.verifySync(plaintext, created: hashed) } - } - - /// Verifies an existing BCrypt hash matches the supplied plaintext value. Verification works by parsing the salt and version from - /// the existing digest and using that information to hash the plaintext data. If hash digests match, this method returns `true`. - /// - /// let hash = try BCrypt.hash("vapor", cost: 4) - /// try BCrypt.verify("vapor", created: hash) // true - /// try BCrypt.verify("foo", created: hash) // false - /// - /// - parameters: - /// - plaintext: Plaintext data to digest and verify. - /// - hash: Existing BCrypt hash to parse version, salt, and existing digest from. - /// - throws: `CryptoError` if hashing fails or if data conversion fails. - /// - returns: `true` if the hash was created from the supplied plaintext data. - public func verifySync(_ plaintext: String, created hash: String) throws -> Bool { - guard let hashVersion = Algorithm(rawValue: String(hash.prefix(4))) else { - throw BcryptError.invalidHash - } - - let hashSalt = String(hash.prefix(hashVersion.fullSaltCount)) - guard !hashSalt.isEmpty, hashSalt.count == hashVersion.fullSaltCount else { - throw BcryptError.invalidHash - } - - let hashChecksum = String(hash.suffix(hashVersion.checksumCount)) - guard !hashChecksum.isEmpty, hashChecksum.count == hashVersion.checksumCount else { - throw BcryptError.invalidHash - } - - let messageHash = try self.hashSync(plaintext, salt: hashSalt) - let messageHashChecksum = String(messageHash.suffix(hashVersion.checksumCount)) - return messageHashChecksum.secureCompare(to: hashChecksum) - } - - // MARK: Private /// Generates string (29 chars total) containing the algorithm information + the cost + base-64 encoded 22 character salt /// /// E.g: $2b$05$J/dtt5ybYUTCJ/dtt5ybYO @@ -253,11 +218,11 @@ public enum BcryptError: Swift.Error, CustomStringConvertible, LocalizedError { case invalidHash public var errorDescription: String? { - return self.description + return description } public var description: String { - return "Bcrypt error: \(self.reason)" + return "Bcrypt error: \(reason)" } var reason: String { @@ -288,7 +253,7 @@ extension Collection where Element: Equatable { /// - parameters: /// - other: Collection to compare to. /// - returns: `true` if the collections are equal. - public func secureCompare(to other: C) -> Bool where C: Collection, C.Element == Element { + fileprivate func secureCompare(to other: C) -> Bool where C: Collection, C.Element == Element { let chk = self let sig = other @@ -309,16 +274,10 @@ extension Collection where Element: Equatable { } } -extension FixedWidthInteger { - public static func random() -> Self { - return Self.random(in: .min ... .max) - } -} - extension Array where Element: FixedWidthInteger { - public static func random(count: Int) -> [Element] { + fileprivate static func random(count: Int) -> [Element] { var array: [Element] = .init(repeating: 0, count: count) - (0.. Bool + func make(_ value: String) throws -> String +} diff --git a/Sources/Alchemy/Hashing/Hasher.swift b/Sources/Alchemy/Hashing/Hasher.swift new file mode 100644 index 00000000..b871c5d7 --- /dev/null +++ b/Sources/Alchemy/Hashing/Hasher.swift @@ -0,0 +1,25 @@ +public struct Hasher { + let algorithm: Algorithm + + public init(algorithm: Algorithm) { + self.algorithm = algorithm + } + + public func make(_ value: String) throws -> String { + try algorithm.make(value) + } + + public func verify(_ plaintext: String, hash: String) throws -> Bool { + try algorithm.verify(plaintext, hash: hash) + } + + // MARK: async Support + + public func makeAsync(_ value: String) async throws -> String { + try await Thread.run { try make(value) } + } + + public func verifyAsync(_ plaintext: String, hash: String) async throws -> Bool { + try await Thread.run { try verify(plaintext, hash: hash) } + } +} diff --git a/Sources/Alchemy/Hashing/SHA256Hasher.swift b/Sources/Alchemy/Hashing/SHA256Hasher.swift new file mode 100644 index 00000000..179e8419 --- /dev/null +++ b/Sources/Alchemy/Hashing/SHA256Hasher.swift @@ -0,0 +1,43 @@ +import Crypto +import Foundation + +extension HashAlgorithm where Self == SHA256Hasher { + public static var sha256: SHA256Hasher { + SHA256Hasher() + } +} + +public final class SHA256Hasher: HashAlgorithm { + fileprivate var sha256 = SHA256() + + public func update(_ value: D) { + sha256.update(data: value) + } + + public func digest() -> String { + sha256.finalize().description + } + + public func verify(_ plaintext: String, hash: String) -> Bool { + make(plaintext) == hash + } + + public func make(_ value: String) -> String { + update(Data(value.utf8)) + return digest() + } +} + +extension Hasher where Algorithm == SHA256Hasher { + public func update(_ value: String) { + algorithm.update(Data(value.utf8)) + } + + public func update(_ data: D) { + algorithm.update(data) + } + + public func digest() -> String { + algorithm.digest() + } +} diff --git a/Sources/Alchemy/Utilities/Aliases.swift b/Sources/Alchemy/Utilities/Aliases.swift index 5dce4937..ce5cdda5 100644 --- a/Sources/Alchemy/Utilities/Aliases.swift +++ b/Sources/Alchemy/Utilities/Aliases.swift @@ -1,3 +1,5 @@ +import Crypto + /// The default configured Client public var Http: Client.Builder { Client.id(.default).builder() } public func Http(_ id: Client.Identifier) -> Client.Builder { Client.id(id).builder() } @@ -25,3 +27,11 @@ public func Redis(_ id: RedisClient.Identifier) -> RedisClient { .id(id) } /// Accessor for firing events; applications should listen to events via /// `Application.schedule(events: EventBus)`. public var Events: EventBus { .id(.default) } + +/// Accessors for Hashing +public var Hash: Hasher { Hasher(algorithm: .bcrypt) } +public func Hash(_ algorithm: Algorithm) -> Hasher { Hasher(algorithm: algorithm) } + +/// Accessor for encryption +public var Crypt: Encrypter { Encrypter(key: .app) } +public func Crypt(key: SymmetricKey) -> Encrypter { Encrypter(key: key) } diff --git a/Sources/Alchemy/Utilities/Extensions/Bcrypt+Async.swift b/Sources/Alchemy/Utilities/Extensions/Bcrypt+Async.swift deleted file mode 100644 index acc4d857..00000000 --- a/Sources/Alchemy/Utilities/Extensions/Bcrypt+Async.swift +++ /dev/null @@ -1,3 +0,0 @@ -import Foundation -import NIO - diff --git a/Sources/AlchemyTest/Stubs/Database/StubDatabase.swift b/Sources/AlchemyTest/Stubs/Database/StubDatabase.swift index 09bceb12..fcc52749 100644 --- a/Sources/AlchemyTest/Stubs/Database/StubDatabase.swift +++ b/Sources/AlchemyTest/Stubs/Database/StubDatabase.swift @@ -1,4 +1,5 @@ import Alchemy + public final class StubDatabase: DatabaseProvider { private var isShutdown = false private var stubs: [[SQLRow]] = [] diff --git a/Sources/AlchemyTest/Stubs/Env+Stub.swift b/Sources/AlchemyTest/Stubs/Env+Stub.swift new file mode 100644 index 00000000..12857e51 --- /dev/null +++ b/Sources/AlchemyTest/Stubs/Env+Stub.swift @@ -0,0 +1,9 @@ +@testable +import Alchemy +import Foundation + +extension Env { + public static func stub(_ values: [String: String]) { + Env.current = Env(name: "stub", dotEnvVariables: values) + } +} diff --git a/Tests/Alchemy/Auth/BasicAuthableTests.swift b/Tests/Alchemy/Auth/BasicAuthableTests.swift index c0d6fa6f..04844183 100644 --- a/Tests/Alchemy/Auth/BasicAuthableTests.swift +++ b/Tests/Alchemy/Auth/BasicAuthableTests.swift @@ -7,7 +7,7 @@ final class BasicAuthableTests: TestCase { app.use(AuthModel.basicAuthMiddleware()) app.get("/user") { try $0.get(AuthModel.self) } - try await AuthModel(email: "test@withapollo.com", password: Bcrypt.hash("password")).insert() + try await AuthModel(email: "test@withapollo.com", password: Hash.make("password")).insert() try await Test.get("/user") .assertUnauthorized() diff --git a/Tests/Alchemy/Auth/TokenAuthableTests.swift b/Tests/Alchemy/Auth/TokenAuthableTests.swift index fa0aef07..5c1d81f2 100644 --- a/Tests/Alchemy/Auth/TokenAuthableTests.swift +++ b/Tests/Alchemy/Auth/TokenAuthableTests.swift @@ -10,7 +10,7 @@ final class TokenAuthableTests: TestCase { return try req.get(TokenModel.self).value } - let auth = try await AuthModel(email: "test@withapollo.com", password: Bcrypt.hash("password")).insertReturn() + let auth = try await AuthModel(email: "test@withapollo.com", password: Hash.make("password")).insertReturn() let token = try await TokenModel(authModel: auth).insertReturn() try await Test.get("/user") diff --git a/Tests/Alchemy/Encryption/EncryptionTests.swift b/Tests/Alchemy/Encryption/EncryptionTests.swift new file mode 100644 index 00000000..447eb0a2 --- /dev/null +++ b/Tests/Alchemy/Encryption/EncryptionTests.swift @@ -0,0 +1,96 @@ +@testable import Alchemy +import AlchemyTest +import Crypto + +final class EncryptionTests: XCTestCase { + func testEncrypter() throws { + let initialKey = SymmetricKey(size: .bits256) + let initialEncryptor = Encrypter(key: initialKey) + let initialCipher = try initialEncryptor.encrypt(string: "FOO") + + let keyString = initialKey.withUnsafeBytes { Data($0) }.base64EncodedString() + guard let keyData = Data(base64Encoded: keyString) else { + return XCTFail("couldn't decode") + } + + let recreatedKey = SymmetricKey(data: keyData) + let encrypter = Encrypter(key: recreatedKey) + let cipher = try encrypter.encrypt(string: "FOO") + let decrypted = try encrypter.decrypt(data: cipher) + let initialDecrypted = try encrypter.decrypt(data: initialCipher) + XCTAssertEqual("FOO", decrypted) + XCTAssertEqual("FOO", initialDecrypted) + } + + func testDecryptStringNotBase64Throws() { + let key = SymmetricKey(size: .bits256) + let encrypter = Encrypter(key: key) + XCTAssertThrowsError(try encrypter.decrypt(base64Encoded: "foo")) + } + + func testEncrypted() throws { + Env.stub(["APP_KEY": Encrypter.generateKeyString()]) + + let value = "FOO" + let encryptedValue = try Crypt.encrypt(string: value).base64EncodedString() + let reader: FakeReader = ["foo": encryptedValue] + let encrypted = try Encrypted(key: "foo", on: reader) + XCTAssertEqual(encrypted.wrappedValue, "FOO") + + var writer: SQLRowWriter = FakeWriter() + try encrypted.store(key: "foo", on: &writer) + guard let storedValue = (writer as? FakeWriter)?.dict["foo"] else { + return XCTFail("a value wasn't stored") + } + + let decrypted = try Crypt.decrypt(base64Encoded: storedValue.string()) + XCTAssertEqual(decrypted, value) + } + + func testEncryptedNotBase64Throws() { + let reader: FakeReader = ["foo": "bar"] + XCTAssertThrowsError(try Encrypted(key: "foo", on: reader)) + } +} + +private struct FakeWriter: SQLRowWriter { + var dict: [String: SQLValue] = [:] + + subscript(column: String) -> SQLValue? { + get { dict[column] } + set { dict[column] = newValue } + } + + mutating func put(json: E, at key: String) throws { + let jsonData = try JSONEncoder().encode(json) + self[key] = .json(jsonData) + } +} + +private struct FakeReader: SQLRowReader, ExpressibleByDictionaryLiteral { + var row: SQLRow + + init(dictionaryLiteral: (String, SQLValueConvertible)...) { + self.row = SQLRow(fields: dictionaryLiteral.map { SQLField(column: $0, value: $1.sqlValue) }) + } + + func requireJSON(_ key: String) throws -> D { + return try JSONDecoder().decode(D.self, from: row.require(key).json(key)) + } + + func require(_ key: String) throws -> SQLValue { + try row.require(key) + } + + func contains(_ column: String) -> Bool { + row[column] != nil + } + + subscript(_ index: Int) -> SQLValue { + row[index] + } + + subscript(_ column: String) -> SQLValue? { + row[column] + } +} diff --git a/Tests/Alchemy/Filesystem/FileTests.swift b/Tests/Alchemy/Filesystem/FileTests.swift index 1978b038..b2ed904d 100644 --- a/Tests/Alchemy/Filesystem/FileTests.swift +++ b/Tests/Alchemy/Filesystem/FileTests.swift @@ -4,14 +4,14 @@ import AlchemyTest final class FileTests: XCTestCase { func testFile() { - let file = File(name: "foo.html", size: 10, content: .buffer("

foo

")) + let file = File(name: "foo.html", source: .raw, content: .buffer("

foo

"), size: 10) XCTAssertEqual(file.extension, "html") XCTAssertEqual(file.size, 10) XCTAssertEqual(file.contentType, .html) } func testInvalidURL() { - let file = File(name: "", size: 3, content: .buffer("foo")) + let file = File(name: "", source: .raw, content: .buffer("foo"), size: 3) XCTAssertEqual(file.extension, "") } } diff --git a/Tests/Alchemy/Filesystem/FilesystemTests.swift b/Tests/Alchemy/Filesystem/FilesystemTests.swift index a118361e..ad2f3fb7 100644 --- a/Tests/Alchemy/Filesystem/FilesystemTests.swift +++ b/Tests/Alchemy/Filesystem/FilesystemTests.swift @@ -42,7 +42,7 @@ final class FilesystemTests: TestCase { AssertTrue(try await Storage.exists(filePath)) let file = try await Storage.get(filePath) AssertEqual(file.name, filePath) - AssertEqual(try await file.content.collect(), "1;2;3") + AssertEqual(try await file.getContent().collect(), "1;2;3") } func _testDelete() async throws { @@ -56,10 +56,10 @@ final class FilesystemTests: TestCase { } func _testPut() async throws { - let file = File(name: filePath, size: 3, content: "foo") - try await Storage.put(file) + let file = File(name: filePath, source: .raw, content: "foo", size: 3) + try await Storage.put(file, as: filePath) AssertTrue(try await Storage.exists(filePath)) - try await Storage.put(file, in: "foo/bar") + try await Storage.put(file, in: "foo/bar", as: filePath) AssertTrue(try await Storage.exists("foo/bar/\(filePath)")) } @@ -69,13 +69,14 @@ final class FilesystemTests: TestCase { AssertTrue(try await Storage.exists("foo/bar/baz/\(filePath)")) let file = try await Storage.get("foo/bar/baz/\(filePath)") AssertEqual(file.name, filePath) - AssertEqual(try await file.content.collect(), "foo") + AssertEqual(try await file.getContent().collect(), "foo") try await Storage.delete("foo/bar/baz/\(filePath)") AssertFalse(try await Storage.exists("foo/bar/baz/\(filePath)")) } func _testFileStore() async throws { - try await File(name: filePath, size: 3, content: "bar").store() + try await File(name: filePath, source: .raw, content: "bar", size: 3).store(as: filePath) + print("STORED \(filePath)") AssertTrue(try await Storage.exists(filePath)) } diff --git a/Tests/Alchemy/HTTP/Content/ContentTests.swift b/Tests/Alchemy/HTTP/Content/ContentTests.swift index 18eee545..c75dce6c 100644 --- a/Tests/Alchemy/HTTP/Content/ContentTests.swift +++ b/Tests/Alchemy/HTTP/Content/ContentTests.swift @@ -85,7 +85,7 @@ final class ContentTests: XCTestCase { func _testMultipart(content: Content) throws { let file = try content["file"].fileThrowing AssertEqual(file.name, "a.txt") - AssertEqual(file.content.buffer.string, "Content of a.txt.\n") + AssertEqual(file.content?.buffer.string, "Content of a.txt.\n") } func _testFlatten(content: Content, allowsNull: Bool) throws { diff --git a/Tests/Alchemy/Hashing/HashingTests.swift b/Tests/Alchemy/Hashing/HashingTests.swift new file mode 100644 index 00000000..7f8efdb8 --- /dev/null +++ b/Tests/Alchemy/Hashing/HashingTests.swift @@ -0,0 +1,19 @@ +import AlchemyTest + +final class HashTests: TestCase { + func testBcrypt() async throws { + let hashed = try await Hash.makeAsync("foo") + let verify = try await Hash.verifyAsync("foo", hash: hashed) + XCTAssertTrue(verify) + } + + func testBcryptCostTooLow() { + XCTAssertThrowsError(try Hash(.bcrypt(rounds: 1)).make("foo")) + } + + func testSHA256() throws { + let hashed = try Hash(.sha256).make("foo") + let verify = try Hash(.sha256).verify("foo", hash: hashed) + XCTAssertTrue(verify) + } +} diff --git a/Tests/Alchemy/SQL/Database/Fixtures/Models.swift b/Tests/Alchemy/SQL/Database/Fixtures/Models.swift index f776e07a..b8b67c38 100644 --- a/Tests/Alchemy/SQL/Database/Fixtures/Models.swift +++ b/Tests/Alchemy/SQL/Database/Fixtures/Models.swift @@ -44,6 +44,6 @@ struct OtherSeedModel: Model, Seedable { let bar: Bool static func generate() -> OtherSeedModel { - OtherSeedModel(foo: .random(), bar: .random()) + OtherSeedModel(foo: faker.number.randomInt(), bar: .random()) } } diff --git a/Tests/Alchemy/Utilities/BCryptTests.swift b/Tests/Alchemy/Utilities/BCryptTests.swift deleted file mode 100644 index 662571ec..00000000 --- a/Tests/Alchemy/Utilities/BCryptTests.swift +++ /dev/null @@ -1,13 +0,0 @@ -import AlchemyTest - -final class BcryptTests: TestCase { - func testBcrypt() async throws { - let hashed = try await Bcrypt.hash("foo") - let verify = try await Bcrypt.verify(plaintext: "foo", hashed: hashed) - XCTAssertTrue(verify) - } - - func testCostTooLow() { - XCTAssertThrowsError(try Bcrypt.hashSync("foo", cost: 1)) - } -}