diff --git a/DBSyn/MainView.swift b/DBSyn/MainView.swift index 4ac1831..cec16ea 100644 --- a/DBSyn/MainView.swift +++ b/DBSyn/MainView.swift @@ -4,6 +4,7 @@ import Charts import AppKit import CryptoKit import UniformTypeIdentifiers +import Security // MARK: - Classic Workbench Colours @@ -7476,6 +7477,7 @@ private struct DBSynInlineRowEditor: View { @State private var values: [String] @State private var executing = false @State private var errorText: String? + @State private var passwordColumnIndex: Int? init(model: DatabaseModel, database: String, table: String, columns: [String], original: [String]) { @@ -7554,6 +7556,15 @@ private struct DBSynInlineRowEditor: View { TextField(columns[i], text: $values[i]) .textFieldStyle(.roundedBorder) .disabled(primaryIndices.contains(i) || executing) + if isLaravelPasswordColumn(i) && !primaryIndices.contains(i) { + Button { + passwordColumnIndex = i + } label: { + Label("Laravel Password…", systemImage: "key.fill") + } + .disabled(executing) + .help("Generate a Laravel bcrypt password hash for this field") + } if primaryIndices.contains(i) { Text("PRIMARY KEY").font(.caption).foregroundStyle(.secondary) } @@ -7590,6 +7601,22 @@ private struct DBSynInlineRowEditor: View { } .padding(20) .frame(width: 780, height: 620) + .sheet(item: Binding( + get: { passwordColumnIndex.map { PasswordFieldSelection(index: $0) } }, + set: { passwordColumnIndex = $0?.index } + )) { selection in + DBSynLaravelPasswordGenerator { hash in + guard values.indices.contains(selection.index) else { return } + values[selection.index] = hash + passwordColumnIndex = nil + } + } + } + + private func isLaravelPasswordColumn(_ i: Int) -> Bool { + guard columns.indices.contains(i) else { return false } + let name = columns[i].lowercased() + return name == "password" || name == "password_hash" } private func apply() { @@ -7616,6 +7643,168 @@ private struct DBSynInlineRowEditor: View { } +// MARK: - Laravel bcrypt password generation + +private struct PasswordFieldSelection: Identifiable { + let index: Int + var id: Int { index } +} + +private enum LaravelBcryptService { + enum HashError: LocalizedError { + case noPHP + case failed(String) + var errorDescription: String? { + switch self { + case .noPHP: + return "PHP CLI was not found. Install PHP or specify its full executable path. Laravel bcrypt requires PHP's password_hash()." + case .failed(let message): return message + } + } + } + + static func randomPassword(length: Int = 24) -> String { + let alphabet = Array("ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#%+=") + var bytes = [UInt8](repeating: 0, count: length) + guard SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) == errSecSuccess else { + return "" // Do not silently fall back to a weaker generator + } + // Rejection sampling prevents modulo bias. + var result = "" + for byte in bytes where Int(byte) < (256 / alphabet.count) * alphabet.count { + result.append(alphabet[Int(byte) % alphabet.count]) + } + while result.count < length { + var byte: UInt8 = 0 + guard SecRandomCopyBytes(kSecRandomDefault, 1, &byte) == errSecSuccess else { break } + if Int(byte) < (256 / alphabet.count) * alphabet.count { + result.append(alphabet[Int(byte) % alphabet.count]) + } + } + return String(result.prefix(length)) + } + + static func hash(_ plaintext: String, phpPath: String) throws -> String { + let candidates = [phpPath, "/opt/homebrew/bin/php", "/usr/local/bin/php", "/usr/bin/php"] + guard let executable = candidates.first(where: { + !$0.isEmpty && $0.hasPrefix("/") && FileManager.default.isExecutableFile(atPath: $0) + }) else { throw HashError.noPHP } + + let process = Process() + process.executableURL = URL(fileURLWithPath: executable) + // Read the secret from stdin, never expose it in command-line arguments. + process.arguments = ["-r", "$p = stream_get_contents(STDIN); $h = password_hash($p, PASSWORD_BCRYPT, ['cost' => 12]); if (!$h) { fwrite(STDERR, 'bcrypt failed'); exit(1); } echo $h;"] + let input = Pipe() + let output = Pipe() + let errors = Pipe() + process.standardInput = input + process.standardOutput = output + process.standardError = errors + try process.run() + input.fileHandleForWriting.write(Data(plaintext.utf8)) + try? input.fileHandleForWriting.close() + let result = output.fileHandleForReading.readDataToEndOfFile() + let failure = errors.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + let hash = String(decoding: result, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) + guard process.terminationStatus == 0, + hash.range(of: #"^\$2y\$12\$[./A-Za-z0-9]{53}$"#, options: .regularExpression) != nil else { + let message = String(decoding: failure, as: UTF8.self) + throw HashError.failed("PHP bcrypt hashing failed: " + (message.isEmpty ? "Invalid hash output" : message)) + } + return hash + } +} + +private struct DBSynLaravelPasswordGenerator: View { + @Environment(\.dismiss) private var dismiss + let onUseHash: (String) -> Void + @State private var plaintext = "" + @State private var hashed = "" + @State private var phpPath = "" + @State private var showPassword = false + @State private var busy = false + @State private var errorText: String? + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Label("Laravel Password Generator", systemImage: "key.fill") + .font(.title2.bold()) + Text("Creates a Laravel-compatible bcrypt hash (cost 12) for a users.password field. Requires PHP CLI.") + .foregroundStyle(.secondary) + HStack { + if showPassword { + TextField("Plaintext password", text: $plaintext) + } else { + SecureField("Plaintext password", text: $plaintext) + } + Button("Generate") { + plaintext = LaravelBcryptService.randomPassword() + hashed = "" + errorText = nil + } + Button(showPassword ? "Hide" : "Show") { showPassword.toggle() } + Button("Copy Password") { copy(plaintext) }.disabled(plaintext.isEmpty) + } + HStack { + TextField("PHP executable path (optional)", text: $phpPath) + .textFieldStyle(.roundedBorder) + Text("Uses Homebrew PHP automatically when available") + .font(.caption).foregroundStyle(.secondary) + } + HStack { + Button(busy ? "Hashing…" : "Generate bcrypt Hash") { generateHash() } + .disabled(plaintext.isEmpty || busy) + if busy { ProgressView().controlSize(.small) } + } + Text("bcrypt hash ($2y$12$)").font(.headline) + TextEditor(text: .constant(hashed)) + .font(.system(.body, design: .monospaced)) + .frame(height: 86) + .border(Color(nsColor: .separatorColor)) + if let errorText { + Text(errorText).foregroundStyle(.red).textSelection(.enabled) + } + Text("Copy the plaintext password before closing. It cannot be recovered from its hash.") + .font(.caption).foregroundStyle(.secondary) + HStack { + Button("Copy Hash") { copy(hashed) }.disabled(hashed.isEmpty) + Spacer() + Button("Cancel") { dismiss() } + Button("Use Hash in Row") { onUseHash(hashed); dismiss() } + .buttonStyle(.borderedProminent) + .disabled(hashed.isEmpty || busy) + } + } + .padding(20) + .frame(width: 750) + .onChange(of: plaintext) { _, _ in hashed = "" } + } + + private func copy(_ value: String) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(value, forType: .string) + } + + private func generateHash() { + let secret = plaintext + let path = phpPath + busy = true + errorText = nil + Task { + do { + let value = try await Task.detached(priority: .userInitiated) { + try LaravelBcryptService.hash(secret, phpPath: path) + }.value + if plaintext == secret { hashed = value } + } catch { + errorText = error.localizedDescription + } + busy = false + } + } +} + // MARK: - Connection Manager private struct DBSynConnectionManager: View { @ObservedObject var model: DatabaseModel