Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Plugins signed by other developers can be installed. TablePro used to refuse any plugin bundle it had not signed itself, so the only way to publish a driver was through the TablePro repository. A bundle signed with a Developer ID and notarized by Apple now installs after you agree to trust that developer by name, and the prompt says plainly that a database plugin runs as part of TablePro and can read the credentials of every connection you open. Trust is recorded per developer rather than per plugin, so their updates install without asking again, and you can withdraw it. Unsigned and ad-hoc signed bundles are still refused.
- `Cmd+F` on a table tab opens a find bar over the results. Type a term and the matching cell is highlighted and scrolled to; `Return` and `Cmd+G` step forward, `Cmd+Shift+G` steps back, `Escape` clears the term and then closes the bar. Matching ignores case and accents and runs over the text as displayed, skipping binary and spatial columns. The counter always says what it searched, reading "3 of 12 on this page" while rows remain unfetched and "3 of 12" once everything is loaded, so a result is never mistaken for an answer about the whole table. When nothing matches on the page and more rows exist, Search All Rows turns the term into a server-side filter.

### Changed
Expand Down
70 changes: 57 additions & 13 deletions TablePro/Core/Plugins/PluginCodeSignatureVerifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,23 @@ enum PluginCodeSignatureVerifier {
}()

static func verify(bundle: Bundle) throws {
_ = try evaluate(bundle: bundle)
}

/// Classifies a bundle's signature. Throws when the bundle is unsigned, ad-hoc signed, tampered
/// with, or signed by a certificate that is neither TablePro's own nor a Developer ID.
///
/// `SecStaticCodeCheckValidity` performs notarization checks by default: `kSecCSNoNetworkAccess`
/// is the flag that disables them, and a bundle whose notarization was revoked fails with
/// `errSecCSRevokedNotarization`. So a Developer ID bundle that passes here is one Apple has
/// seen and has not revoked.
static func evaluate(bundle: Bundle) throws -> PluginSignatureTrust {
#if DEBUG
if ProcessInfo.processInfo.environment["TABLEPRO_ALLOW_UNSIGNED_PLUGINS"] == "1" {
logger.warning(
"Skipping code-signature verification for \(bundle.bundleURL.lastPathComponent): TABLEPRO_ALLOW_UNSIGNED_PLUGINS=1"
)
return
return .firstParty
}
#endif
var staticCode: SecStaticCode?
Expand All @@ -39,27 +50,60 @@ enum PluginCodeSignatureVerifier {
throw PluginError.signatureInvalid(detail: describeOSStatus(createStatus))
}

let requirement = createSigningRequirement()
let flags = SecCSFlags(rawValue: kSecCSCheckAllArchitectures)

let checkStatus = SecStaticCodeCheckValidity(
code,
SecCSFlags(rawValue: kSecCSCheckAllArchitectures),
requirement
)
if SecStaticCodeCheckValidity(code, flags, requirement(Self.firstPartyRequirement)) == errSecSuccess {
return .firstParty
}

guard checkStatus == errSecSuccess else {
throw PluginError.signatureInvalid(detail: describeOSStatus(checkStatus))
let developerIDStatus = SecStaticCodeCheckValidity(code, flags, requirement(Self.developerIDRequirement))
guard developerIDStatus == errSecSuccess else {
throw PluginError.signatureInvalid(detail: describeOSStatus(developerIDStatus))
}

guard let identity = developerIdentity(of: code) else {
throw PluginError.signatureInvalid(detail: "signature carries no team identifier")
}
return .developerID(identity)
}

private static func createSigningRequirement() -> SecRequirement? {
private static var firstPartyRequirement: String {
"anchor apple generic and certificate leaf[subject.OU] = \"\(resolvedSigningTeamId)\""
}

/// `1.2.840.113635.100.6.1.13` is Apple's Developer ID Application marker OID. Verified against a
/// Developer ID signed app with `codesign -R`.
private static let developerIDRequirement =
"anchor apple generic and certificate leaf[field.1.2.840.113635.100.6.1.13] exists"

private static func requirement(_ text: String) -> SecRequirement? {
var requirement: SecRequirement?
let teamId = resolvedSigningTeamId
let requirementString = "anchor apple generic and certificate leaf[subject.OU] = \"\(teamId)\"" as CFString
SecRequirementCreateWithString(requirementString, SecCSFlags(), &requirement)
SecRequirementCreateWithString(text as CFString, SecCSFlags(), &requirement)
return requirement
}

private static func developerIdentity(of code: SecStaticCode) -> PluginDeveloperIdentity? {
var info: CFDictionary?
let status = SecCodeCopySigningInformation(code, SecCSFlags(rawValue: kSecCSSigningInformation), &info)
guard status == errSecSuccess,
let infoDict = info as? [String: Any],
let teamID = infoDict[kSecCodeInfoTeamIdentifier as String] as? String,
!teamID.isEmpty
else { return nil }

let name = signerCommonName(from: infoDict) ?? teamID
return PluginDeveloperIdentity(teamID: teamID, name: name)
}

private static func signerCommonName(from info: [String: Any]) -> String? {
guard let certificates = info[kSecCodeInfoCertificates as String] as? [SecCertificate],
let leaf = certificates.first
else { return nil }
var commonName: CFString?
guard SecCertificateCopyCommonName(leaf, &commonName) == errSecSuccess else { return nil }
return commonName as String?
}

private static func teamIdFromBundleSignature() -> String? {
var staticCode: SecStaticCode?
let createStatus = SecStaticCodeCreateWithPath(
Expand Down
61 changes: 61 additions & 0 deletions TablePro/Core/Plugins/PluginDeveloperTrustPrompting.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
//
// PluginDeveloperTrustPrompting.swift
// TablePro
//

import AppKit
import Foundation

internal enum PluginDeveloperTrustDecision: Sendable {
case trust
case cancel
}

@MainActor
internal protocol PluginDeveloperTrustPrompting {
func prompt(for identity: PluginDeveloperIdentity, pluginName: String) async -> PluginDeveloperTrustDecision
}

@MainActor
internal struct PluginDeveloperTrustAlertPrompt: PluginDeveloperTrustPrompting {
internal func prompt(
for identity: PluginDeveloperIdentity,
pluginName: String
) async -> PluginDeveloperTrustDecision {
let response = await present(Self.makeAlert(for: identity, pluginName: pluginName))
return response == .alertFirstButtonReturn ? .trust : .cancel
}

internal static func makeAlert(for identity: PluginDeveloperIdentity, pluginName: String) -> NSAlert {
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = String(
format: String(localized: "Trust plugins from %@?"),
identity.name
)
alert.informativeText = String(
format: String(localized: """
%1$@ was signed by %2$@ (Team ID %3$@) and notarized by Apple. TablePro did not \
write it.

A database plugin runs as part of TablePro and can read the credentials of every \
connection you open. Trust this developer only if you would give them that access.

Trusting applies to every plugin this developer signs, now and later. You can \
withdraw it in Settings > Plugins.
"""),
pluginName,
identity.name,
identity.teamID
)
alert.addButton(withTitle: String(localized: "Trust and Install"))
alert.addButton(withTitle: String(localized: "Cancel"))
alert.buttons.first?.hasDestructiveAction = true
return alert
}

private func present(_ alert: NSAlert) async -> NSApplication.ModalResponse {
guard let window = NSApp.keyWindow else { return alert.runModal() }
return await alert.beginSheetModal(for: window)
}
}
6 changes: 6 additions & 0 deletions TablePro/Core/Plugins/PluginError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import Foundation
enum PluginError: LocalizedError {
case invalidBundle(String)
case signatureInvalid(detail: String)
case developerNotTrusted(identity: PluginDeveloperIdentity)
case checksumMismatch
case incompatibleVersion(required: Int, current: Int)
case pluginOutdated(pluginVersion: Int, requiredVersion: Int)
Expand All @@ -30,6 +31,11 @@ enum PluginError: LocalizedError {
return String(format: String(localized: "Invalid plugin bundle: %@"), reason)
case .signatureInvalid(let detail):
return String(format: String(localized: "Plugin code signature verification failed: %@"), detail)
case .developerNotTrusted(let identity):
return String(
format: String(localized: "This plugin is signed by %@, a developer you have not trusted yet."),
identity.name
)
case .checksumMismatch:
return String(localized: "Plugin checksum does not match expected value")
case .incompatibleVersion(let required, let current):
Expand Down
24 changes: 22 additions & 2 deletions TablePro/Core/Plugins/PluginInstaller.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,11 @@ actor PluginInstaller {
guard let stagedBundle = Bundle(url: stagedURL) else {
throw PluginError.invalidBundle("Cannot create bundle from \(stagedURL.lastPathComponent)")
}
try PluginCodeSignatureVerifier.verify(bundle: stagedBundle)
let trust = try PluginCodeSignatureVerifier.evaluate(bundle: stagedBundle)
if case .developerID(let identity) = trust,
!PluginDeveloperTrustStore.shared.isTrusted(identity) {
throw PluginError.developerNotTrusted(identity: identity)
}
let bundleName = stagedURL.deletingPathExtension().lastPathComponent
let destURL = userPluginsDir.appendingPathComponent("\(bundleName).tableplugin", isDirectory: true)
let finalURL = try Self.atomicReplace(stagedBundleURL: stagedURL, destURL: destURL)
Expand Down Expand Up @@ -222,7 +226,10 @@ actor PluginInstaller {
throw PluginError.invalidBundle("Cannot create bundle from \(bundleURL.lastPathComponent)")
}

try PluginCodeSignatureVerifier.verify(bundle: stagedBundle)
let trust = try PluginCodeSignatureVerifier.evaluate(bundle: stagedBundle)
if case .developerID(let identity) = trust {
try await Self.requireTrust(in: identity, pluginName: registryPlugin.name)
}

try Self.validateStagedABI(
bundleURL: bundleURL,
Expand All @@ -242,6 +249,19 @@ actor PluginInstaller {
.appendingPathComponent("PluginStaging", isDirectory: true)
}

/// Asks once per developer, not once per plugin, and records the answer only on yes. Declining
/// aborts the install, so a plugin never lands on disk unless its signer is trusted.
private static func requireTrust(in identity: PluginDeveloperIdentity, pluginName: String) async throws {
guard !PluginDeveloperTrustStore.shared.isTrusted(identity) else { return }

let decision = await MainActor.run { PluginDeveloperTrustAlertPrompt() }
.prompt(for: identity, pluginName: pluginName)
guard decision == .trust else {
throw PluginError.developerNotTrusted(identity: identity)
}
PluginDeveloperTrustStore.shared.trust(identity)
}

nonisolated static func extractZip(at zipURL: URL, into destDir: URL) throws {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto")
Expand Down
18 changes: 16 additions & 2 deletions TablePro/Core/Plugins/PluginManager+Install.swift
Original file line number Diff line number Diff line change
Expand Up @@ -190,11 +190,25 @@ extension PluginManager {

// MARK: - Local bundle / zip install

/// A manual install is the only place a user can grant trust, so this is the one path that
/// asks. Declining aborts before anything is copied into the plugins directory.
private func requireTrust(for bundle: Bundle, pluginName: String) async throws {
let trust = try PluginCodeSignatureVerifier.evaluate(bundle: bundle)
guard case .developerID(let identity) = trust else { return }
guard !PluginDeveloperTrustStore.shared.isTrusted(identity) else { return }

let decision = await PluginDeveloperTrustAlertPrompt().prompt(for: identity, pluginName: pluginName)
guard decision == .trust else {
throw PluginError.developerNotTrusted(identity: identity)
}
PluginDeveloperTrustStore.shared.trust(identity)
}

private func installLooseBundle(from url: URL) async throws -> PluginEntry {
guard let sourceBundle = Bundle(url: url) else {
throw PluginError.invalidBundle("Cannot create bundle from \(url.lastPathComponent)")
}
try PluginCodeSignatureVerifier.verify(bundle: sourceBundle)
try await requireTrust(for: sourceBundle, pluginName: url.deletingPathExtension().lastPathComponent)
let bundleId = sourceBundle.bundleIdentifier ?? url.lastPathComponent

try FileManager.default.createDirectory(at: userPluginsDir, withIntermediateDirectories: true)
Expand Down Expand Up @@ -223,7 +237,7 @@ extension PluginManager {
guard let bundle = Bundle(url: bundleURL) else {
throw PluginError.invalidBundle("Cannot create bundle from \(bundleURL.lastPathComponent)")
}
try PluginCodeSignatureVerifier.verify(bundle: bundle)
try await requireTrust(for: bundle, pluginName: bundleURL.deletingPathExtension().lastPathComponent)
try PluginInstaller.validateStagedABI(
bundleURL: bundleURL,
currentKit: Self.currentPluginKitVersion,
Expand Down
6 changes: 5 additions & 1 deletion TablePro/Core/Plugins/PluginManager+Validation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ extension PluginManager {
}

func verifyCodeSignature(bundle: Bundle) throws {
try PluginCodeSignatureVerifier.verify(bundle: bundle)
let trust = try PluginCodeSignatureVerifier.evaluate(bundle: bundle)
guard case .developerID(let identity) = trust else { return }
guard PluginDeveloperTrustStore.shared.isTrusted(identity) else {
throw PluginError.developerNotTrusted(identity: identity)
}
}
}
6 changes: 5 additions & 1 deletion TablePro/Core/Plugins/PluginManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -550,7 +550,11 @@ final class PluginManager {
try validateBundleVersions(bundle)

if source != .builtIn {
try PluginCodeSignatureVerifier.verify(bundle: bundle)
let trust = try PluginCodeSignatureVerifier.evaluate(bundle: bundle)
if case .developerID(let identity) = trust,
!PluginDeveloperTrustStore.shared.isTrusted(identity) {
throw PluginError.developerNotTrusted(identity: identity)
}
}

try PluginBundleLoader.load(bundle)
Expand Down
41 changes: 41 additions & 0 deletions TablePro/Core/Plugins/PluginSignatureTrust.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
//
// PluginSignatureTrust.swift
// TablePro
//

import Foundation

/// What a plugin bundle's code signature entitles it to.
///
/// A driver plugin runs as code inside the app and holds database credentials, so an unsigned or
/// ad-hoc bundle is never loadable. The distinction that matters is between a bundle TablePro
/// signed itself, which loads silently, and one signed by another developer's Developer ID, which
/// loads only after the user has said yes to that developer by name.
internal enum PluginSignatureTrust: Equatable, Sendable {
case firstParty
case developerID(PluginDeveloperIdentity)

internal var requiresUserConsent: Bool {
switch self {
case .firstParty: false
case .developerID: true
}
}
}

/// The signing identity of a third-party plugin, as it is shown to the user and stored once trusted.
///
/// `teamID` is the key. It comes from `kSecCodeInfoTeamIdentifier`, is assigned by Apple, and cannot
/// be chosen by the signer, so two developers can never collide on it and a rename cannot move trust
/// from one to another. `name` is display only.
internal struct PluginDeveloperIdentity: Codable, Hashable, Identifiable, Sendable {
internal let teamID: String
internal let name: String

internal var id: String { teamID }

internal init(teamID: String, name: String) {
self.teamID = teamID
self.name = name
}
}
Loading
Loading