diff --git a/CHANGELOG.md b/CHANGELOG.md index 11974d42a..a07851f1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/TablePro/Core/Plugins/PluginCodeSignatureVerifier.swift b/TablePro/Core/Plugins/PluginCodeSignatureVerifier.swift index 76612e2ff..7b0a6df08 100644 --- a/TablePro/Core/Plugins/PluginCodeSignatureVerifier.swift +++ b/TablePro/Core/Plugins/PluginCodeSignatureVerifier.swift @@ -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? @@ -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( diff --git a/TablePro/Core/Plugins/PluginDeveloperTrustPrompting.swift b/TablePro/Core/Plugins/PluginDeveloperTrustPrompting.swift new file mode 100644 index 000000000..1ab375d68 --- /dev/null +++ b/TablePro/Core/Plugins/PluginDeveloperTrustPrompting.swift @@ -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) + } +} diff --git a/TablePro/Core/Plugins/PluginError.swift b/TablePro/Core/Plugins/PluginError.swift index d97d4f250..f7df6b290 100644 --- a/TablePro/Core/Plugins/PluginError.swift +++ b/TablePro/Core/Plugins/PluginError.swift @@ -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) @@ -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): diff --git a/TablePro/Core/Plugins/PluginInstaller.swift b/TablePro/Core/Plugins/PluginInstaller.swift index 82761f65c..899d8d23a 100644 --- a/TablePro/Core/Plugins/PluginInstaller.swift +++ b/TablePro/Core/Plugins/PluginInstaller.swift @@ -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) @@ -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, @@ -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") diff --git a/TablePro/Core/Plugins/PluginManager+Install.swift b/TablePro/Core/Plugins/PluginManager+Install.swift index 1fdffaf5b..da42e4224 100644 --- a/TablePro/Core/Plugins/PluginManager+Install.swift +++ b/TablePro/Core/Plugins/PluginManager+Install.swift @@ -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) @@ -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, diff --git a/TablePro/Core/Plugins/PluginManager+Validation.swift b/TablePro/Core/Plugins/PluginManager+Validation.swift index a69490c37..43db5da34 100644 --- a/TablePro/Core/Plugins/PluginManager+Validation.swift +++ b/TablePro/Core/Plugins/PluginManager+Validation.swift @@ -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) + } } } diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index 41da197f6..ef543524d 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -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) diff --git a/TablePro/Core/Plugins/PluginSignatureTrust.swift b/TablePro/Core/Plugins/PluginSignatureTrust.swift new file mode 100644 index 000000000..e1732e0a1 --- /dev/null +++ b/TablePro/Core/Plugins/PluginSignatureTrust.swift @@ -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 + } +} diff --git a/TablePro/Core/Storage/PluginDeveloperTrustStore.swift b/TablePro/Core/Storage/PluginDeveloperTrustStore.swift new file mode 100644 index 000000000..11e566ef3 --- /dev/null +++ b/TablePro/Core/Storage/PluginDeveloperTrustStore.swift @@ -0,0 +1,82 @@ +// +// PluginDeveloperTrustStore.swift +// TablePro +// + +import Foundation +import os + +internal struct TrustedPluginDeveloper: Codable, Hashable, Identifiable, Sendable { + internal let identity: PluginDeveloperIdentity + internal let trustedAt: Date + + internal var id: String { identity.teamID } +} + +internal protocol PluginDeveloperTrustChecking: Sendable { + func isTrusted(_ identity: PluginDeveloperIdentity) -> Bool + func trust(_ identity: PluginDeveloperIdentity) + func revoke(teamID: String) + func trustedDevelopers() -> [TrustedPluginDeveloper] +} + +/// Consent is recorded per Apple Team ID, not per plugin, so a developer the user already trusts can +/// ship an update without asking again, and revoking one developer revokes every plugin they signed. +/// +/// Not main-actor bound: the plugin load path runs off the main actor and has to read trust before +/// it loads a bundle. `UserDefaults` is thread safe, and this type holds no other state. +internal final class PluginDeveloperTrustStore: PluginDeveloperTrustChecking, @unchecked Sendable { + internal static let shared = PluginDeveloperTrustStore() + + private static let logger = Logger(subsystem: "com.TablePro", category: "PluginDeveloperTrust") + private static let storageKey = "com.TablePro.pluginDeveloperTrust.entries" + + private let defaults: UserDefaults + + internal init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + internal func isTrusted(_ identity: PluginDeveloperIdentity) -> Bool { + guard !identity.teamID.isEmpty else { return false } + return entries().contains { $0.identity.teamID == identity.teamID } + } + + internal func trust(_ identity: PluginDeveloperIdentity) { + guard !identity.teamID.isEmpty else { + Self.logger.error("Refused to trust a plugin developer with no team identifier") + return + } + var updated = entries().filter { $0.identity.teamID != identity.teamID } + updated.append(TrustedPluginDeveloper(identity: identity, trustedAt: Date())) + persist(updated) + Self.logger.info("Trusted plugin developer \(identity.teamID, privacy: .public)") + } + + internal func revoke(teamID: String) { + persist(entries().filter { $0.identity.teamID != teamID }) + Self.logger.info("Revoked plugin developer \(teamID, privacy: .public)") + } + + internal func trustedDevelopers() -> [TrustedPluginDeveloper] { + entries().sorted { $0.trustedAt > $1.trustedAt } + } + + private func entries() -> [TrustedPluginDeveloper] { + guard let data = defaults.data(forKey: Self.storageKey) else { return [] } + do { + return try JSONDecoder().decode([TrustedPluginDeveloper].self, from: data) + } catch { + Self.logger.error("Could not decode trusted plugin developers: \(error.localizedDescription)") + return [] + } + } + + private func persist(_ entries: [TrustedPluginDeveloper]) { + do { + defaults.set(try JSONEncoder().encode(entries), forKey: Self.storageKey) + } catch { + Self.logger.error("Could not persist trusted plugin developers: \(error.localizedDescription)") + } + } +} diff --git a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift index 174024330..00b60b811 100644 --- a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift +++ b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift @@ -446,15 +446,10 @@ struct InstalledPluginsView: View { .frame(maxWidth: .infinity, alignment: .leading) } } else { - VStack(spacing: 8) { - Image(systemName: "puzzlepiece.extension") - .font(.title) - .foregroundStyle(.tertiary) - Text("Select a Plugin") - .font(.headline) - .foregroundStyle(.secondary) + Form { + TrustedDevelopersView() } - .frame(maxWidth: .infinity, maxHeight: .infinity) + .formStyle(.grouped) } } diff --git a/TablePro/Views/Settings/Plugins/TrustedDevelopersView.swift b/TablePro/Views/Settings/Plugins/TrustedDevelopersView.swift new file mode 100644 index 000000000..a06c67254 --- /dev/null +++ b/TablePro/Views/Settings/Plugins/TrustedDevelopersView.swift @@ -0,0 +1,80 @@ +// +// TrustedDevelopersView.swift +// TablePro +// + +import SwiftUI + +struct TrustedDevelopersView: View { + @State private var developers: [TrustedPluginDeveloper] = [] + @State private var pendingRevoke: TrustedPluginDeveloper? + + private let store: any PluginDeveloperTrustChecking + + init(store: any PluginDeveloperTrustChecking = PluginDeveloperTrustStore.shared) { + self.store = store + } + + var body: some View { + Section { + if developers.isEmpty { + Text("No plugin developers trusted yet.") + .foregroundStyle(.secondary) + } else { + ForEach(developers) { developer in + row(for: developer) + } + } + } header: { + Text("Trusted Plugin Developers") + } footer: { + Text( + "Plugins signed by these developers install and load without asking. " + + "Removing one stops every plugin they signed from loading." + ) + .font(.callout) + .foregroundStyle(.secondary) + } + .onAppear(perform: reload) + .confirmationDialog( + revokeTitle, + isPresented: Binding(get: { pendingRevoke != nil }, set: { if !$0 { pendingRevoke = nil } }), + titleVisibility: .visible + ) { + Button("Stop Trusting", role: .destructive) { + if let developer = pendingRevoke { + store.revoke(teamID: developer.identity.teamID) + reload() + } + pendingRevoke = nil + } + Button("Cancel", role: .cancel) { pendingRevoke = nil } + } message: { + Text("Plugins signed by this developer stop loading the next time TablePro starts.") + } + } + + private func row(for developer: TrustedPluginDeveloper) -> some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(developer.identity.name) + Text(developer.identity.teamID) + .font(.callout) + .foregroundStyle(.secondary) + .monospaced() + } + Spacer() + Button("Stop Trusting") { pendingRevoke = developer } + .buttonStyle(.borderless) + } + } + + private var revokeTitle: String { + guard let developer = pendingRevoke else { return String(localized: "Stop trusting this developer?") } + return String(format: String(localized: "Stop trusting %@?"), developer.identity.name) + } + + private func reload() { + developers = store.trustedDevelopers() + } +} diff --git a/TableProTests/Core/Plugins/PluginDeveloperTrustStoreTests.swift b/TableProTests/Core/Plugins/PluginDeveloperTrustStoreTests.swift new file mode 100644 index 000000000..faa09a343 --- /dev/null +++ b/TableProTests/Core/Plugins/PluginDeveloperTrustStoreTests.swift @@ -0,0 +1,91 @@ +// +// PluginDeveloperTrustStoreTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("PluginDeveloperTrustStore") +struct PluginDeveloperTrustStoreTests { + private func makeStore() -> PluginDeveloperTrustStore { + let suiteName = "com.TablePro.tests.pluginTrust.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + fatalError("Could not create an isolated UserDefaults suite") + } + return PluginDeveloperTrustStore(defaults: defaults) + } + + private let acme = PluginDeveloperIdentity(teamID: "ABCDE12345", name: "Acme Databases") + private let other = PluginDeveloperIdentity(teamID: "ZZZZZ99999", name: "Someone Else") + + @Test("a developer is untrusted until trusted") + func untrustedByDefault() { + let store = makeStore() + #expect(store.isTrusted(acme) == false) + store.trust(acme) + #expect(store.isTrusted(acme)) + } + + @Test("trusting one developer does not trust another") + func trustIsPerDeveloper() { + let store = makeStore() + store.trust(acme) + #expect(store.isTrusted(other) == false) + } + + @Test("trust is keyed on the team id, so a display name change keeps it") + func trustSurvivesRename() { + let store = makeStore() + store.trust(acme) + let renamed = PluginDeveloperIdentity(teamID: acme.teamID, name: "Acme Data Inc") + #expect(store.isTrusted(renamed)) + } + + @Test("an empty team id can never be trusted") + func emptyTeamIDRejected() { + let store = makeStore() + let anonymous = PluginDeveloperIdentity(teamID: "", name: "No Team") + store.trust(anonymous) + #expect(store.isTrusted(anonymous) == false) + #expect(store.trustedDevelopers().isEmpty) + } + + @Test("revoking removes the developer and every plugin they signed with it") + func revoke() { + let store = makeStore() + store.trust(acme) + store.trust(other) + store.revoke(teamID: acme.teamID) + #expect(store.isTrusted(acme) == false) + #expect(store.isTrusted(other)) + } + + @Test("trusting the same developer twice keeps one entry") + func trustIsIdempotent() { + let store = makeStore() + store.trust(acme) + store.trust(acme) + #expect(store.trustedDevelopers().count == 1) + } + + @Test("trusted developers are listed most recent first") + func listingOrder() { + let store = makeStore() + store.trust(acme) + store.trust(other) + #expect(store.trustedDevelopers().first?.identity.teamID == other.teamID) + } +} + +@Suite("PluginSignatureTrust") +struct PluginSignatureTrustTests { + @Test("a first-party bundle needs no consent, a third-party one does") + func consentRequirement() { + #expect(PluginSignatureTrust.firstParty.requiresUserConsent == false) + let identity = PluginDeveloperIdentity(teamID: "ABCDE12345", name: "Acme") + #expect(PluginSignatureTrust.developerID(identity).requiresUserConsent) + } +} diff --git a/docs/features/plugins.mdx b/docs/features/plugins.mdx index 4da27a823..540b6c6a3 100644 --- a/docs/features/plugins.mdx +++ b/docs/features/plugins.mdx @@ -57,7 +57,19 @@ To browse the full catalog, open **Settings > Plugins > Browse**. Search, filter To install from a file, drag a `.tableplugin` or `.zip` onto **Settings > Plugins > Installed**, or click the **+** button there. User-installed plugins live in `~/Library/Application Support/TablePro/Plugins`. -Every plugin is verified before it loads: registry downloads must match the SHA-256 checksum in the registry manifest, and the bundle's code signature must match TablePro's signing team. +Every plugin is verified before it loads. Registry downloads must match the SHA-256 checksum in the registry manifest, and the bundle must carry a valid code signature. + +### Plugins from other developers + +A plugin TablePro signed itself installs with no extra step. + +A plugin signed by someone else installs only after you agree to trust that developer. TablePro asks once, naming the developer and their Apple Team ID, and the plugin is discarded if you decline. The bundle must be signed with a Developer ID and notarized by Apple to get that far; unsigned and ad-hoc signed bundles are refused outright and there is no way to override that. + +Say yes only if you would give that developer your database credentials, because that is what you are granting. A driver 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 later plugins and updates from the same developer install without asking again. Withdraw it in **Settings > Plugins**, which stops every plugin signed by that developer from loading. + +Themes are different: they are JSON with no executable code, so they need no signature and never ask. They are still checked against their SHA-256 checksum. ## Updates