Root cause
Validation & fail-open logic introduced
Sign on-disk policy with EC-P256 key in System Keychain / Secure Enclave
How AI contributed: Direct introductionPublished Jul 20, 2026 in
craigjbass/clearancekitAll 3 findings in this repository
Replay of legitimately-signed policy snapshots (CWE-354 / CWE-347): ClearanceKit's on-disk policy store (/Library/Application Support/clearancekit/store.db, earlier user-policy.json) is signed with an EC-P256 key (System Keychain / Secure Enclave) and verified on load via PolicySigner, but the signed payload contains only the canonical row content with NO monotonic counter, version, or timestamp (freshness) binding. An attacker who can write store.db and the matching data_signatures row (feasible during the opfilter-update window when the Endpoint Security filter is offline, or via offline-boot / decrypted-backup scenarios) can substitute a previously captured legitimately-signed older snapshot; opfilter accepts it as fully valid on next boot because the ECDSA signatures still verify. Fixed in 5.0.10.
Root cause
Sign on-disk policy with EC-P256 key in System Keychain / Secure Enclave
Fix
fix: lock down signing-key ACL and add epoch ratchet against replay
AI-assisted fix: Claude Code · Craig Bass
Case facts
@@ -0,0 +1,199 @@+//+// PolicySigner.swift+// clearancekit-daemon+//+// Signs and verifies the on-disk policy JSON using an EC-P256 key stored in+// the System Keychain.+//+// Key selection (in priority order):+//+// 1. Secure Enclave — hardware-bound, not exportable, inaccessible while the+// machine is locked. Stored via the data-protection keychain+// (kSecUseDataProtectionKeychain) which is the required storage backend for+// SE keys on macOS. kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly means+// the key is accessible after the first unlock and is bound to this device.+//+// 2. Software fallback — for Intel Macs without a T2 chip. Explicitly stored+// in the System Keychain (/Library/Keychains/System.keychain) rather than+// any per-user login keychain. Scoped via SecTrustedApplicationCreateFromPath+// so only processes sharing the daemon's code-signing identity (team+// 37KMK6XFTT) can use the key.+//+// Keychain targeting:+// - SE path: kSecUseDataProtectionKeychain = true+// - Software path: explicit SecKeychainOpen("/Library/Keychains/System.keychain")+// Both are searched on load so the correct key is found regardless of which+// path was used at creation time.+//++import Foundation+import Security++enum PolicySigner {+ private static let keyTag = Data("uk.craigbass.clearancekit.policy-signing-key".utf8)+ private static let keyLabel = "clearancekit policy signing key"+ private static let algorithm = SecKeyAlgorithm.ecdsaSignatureMessageX962SHA256++ /// Reference to the System Keychain. Used for software-backed keys so they+ /// are never accidentally stored in a per-user login keychain.+ private static let systemKeychain: SecKeychain? = {+ var kc: SecKeychain?+ let status = SecKeychainOpen("/Library/Keychains/System.keychain", &kc)+ if status != errSecSuccess {+ NSLog("PolicySigner: Could not open System Keychain (%d)", status)+ }+ return kc+ }()++ // MARK: - Public API++ static func sign(_ data: Data) throws -> Data {+ let key = try loadOrCreateKey()+ var cfError: Unmanaged<CFError>?+ guard let sig = SecKeyCreateSignature(key, algorithm, data as CFData, &cfError) else {+ throw cfError!.takeRetainedValue()+ }+ return sig as Data+ }++ /// Throws if the signature does not match the data.+ static func verify(_ data: Data, signature: Data) throws {+ let key = try loadOrCreateKey()+ guard let publicKey = SecKeyCopyPublicKey(key) else {+ throw PolicySignerError.publicKeyUnavailable+ }+ var cfError: Unmanaged<CFError>?+ guard SecKeyVerifySignature(+ publicKey, algorithm,+ data as CFData, signature as CFData,+ &cfError+ ) else {+ throw cfError?.takeRetainedValue() ?? PolicySignerError.verificationFailed+ }+ }++ // MARK: - Key lifecycle++ static func loadOrCreateKey() throws -> SecKey {+ if let key = try? loadKey() { return key }+ return try createKey()+ }++ /// Searches the data-protection keychain (SE keys) then the System Keychain+ /// (software keys) so the correct key is found regardless of creation path.+ private static func loadKey() throws -> SecKey {+ let baseQuery: [CFString: Any] = [+ kSecClass: kSecClassKey,+ kSecAttrApplicationTag: keyTag,+ kSecAttrKeyType: kSecAttrKeyTypeECSECPrimeRandom,+ kSecAttrKeyClass: kSecAttrKeyClassPrivate,@@ -7,6 +7,7 @@ import Foundation private let userPolicyDir = URL(fileURLWithPath: "/Library/Application Support/clearancekit") private let userPolicyURL = userPolicyDir.appendingPathComponent("user-policy.json")+private let signatureURL = userPolicyDir.appendingPathComponent("user-policy.json.sig") final class DaemonXPCServer: NSObject { static let shared = DaemonXPCServer()@@ -227,6 +228,25 @@ final class DaemonXPCServer: NSObject { private func loadUserRulesFromDisk() -> [FAARule] { guard let data = try? Data(contentsOf: userPolicyURL) else { return [] }++ if let sigData = try? Data(contentsOf: signatureURL) {+ do {+ try PolicySigner.verify(data, signature: sigData)+ } catch {+ NSLog("DaemonXPCServer: Policy signature verification FAILED (%@) — discarding potentially tampered data", "\(error)")+ return []+ }+ } else {+ // No signature yet: first run after enabling signing, or post-update+ // recovery where the old key was inaccessible and was regenerated.+ // Trust the data this once and sign it so future loads are verified.+ NSLog("DaemonXPCServer: No policy signature found — signing existing data (first run or post-update recovery)")+ if let sig = try? PolicySigner.sign(data) {+ try? sig.write(to: signatureURL, options: .atomic)+ try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: signatureURL.path)+ }+ }+ guard let rules = try? JSONDecoder().decode([FAARule].self, from: data) else { NSLog("DaemonXPCServer: Failed to decode user policy from disk — starting with empty policy") return []@@ -249,9 +269,15 @@ final class DaemonXPCServer: NSObject { } do { // .atomic writes to a temp file then renames, preserving the original on failure.- // After the write, lock the file down to root read/write only (0o600).+ // After the write, lock both files down to root read/write only (0o600). try data.write(to: userPolicyURL, options: .atomic) try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: userPolicyURL.path)+ if let sig = try? PolicySigner.sign(data) {+ try sig.write(to: signatureURL, options: .atomic)+ try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: signatureURL.path)+ } else {+ NSLog("DaemonXPCServer: Failed to sign policy data — signature file not updated")+ } } catch { NSLog("DaemonXPCServer: Failed to write user policy to disk: %@", error.localizedDescription) }@@ -21,13 +21,29 @@ import Foundation import Security -private let aclMigrationMarker = URL(fileURLWithPath: "/Library/Application Support/clearancekit/.key-acl-v2")+/// Legacy marker files used by earlier opfilter builds to track ACL-migration+/// state on disk. They are now ignored — the keychain version counter is the+/// sole trust anchor — but we still clean them up on first v3 boot to avoid+/// leaving stale state in /Library/Application Support/clearancekit.+private let legacyAclMarkers: [URL] = [+ URL(fileURLWithPath: "/Library/Application Support/clearancekit/.key-acl-v2"),+ URL(fileURLWithPath: "/Library/Application Support/clearancekit/.key-acl-v3"),+] enum PolicySigner { private static let keyTag = Data("uk.craigbass.clearancekit.policy-signing-key".utf8) private static let keyLabel = "clearancekit policy signing key" private static let algorithm = SecKeyAlgorithm.ecdsaSignatureMessageX962SHA256 + /// Generic-password keychain item that records which ACL-migration version+ /// has run. Trust anchor for skipping the marker-file path on subsequent+ /// boots: once written with a proper ACL it can only be modified by+ /// opfilter, so an attacker cannot force re-migration by deleting the+ /// marker file alone.+ private static let aclVersionService = "uk.craigbass.clearancekit.acl-version"+ private static let aclVersionAccount = "policy-signing-key"+ private static let currentAclMigrationVersion = 3+ /// Reference to the System Keychain, used for software-backed keys only. /// /// `SecKeychainOpen` is deprecated since macOS 10.10 but remains the only API that@@ -74,7 +90,7 @@ enum PolicySigner { // MARK: - Key lifecycle static func loadOrCreateKey() throws -> SecKey {- migratePermissiveKeyOnce()+ rekeyForExplicitPathAclOnce() if let key = try? loadKey() { return key } return try createSoftwareKey() }@@ -130,14 +146,17 @@ enum PolicySigner { return key } - /// Builds a SecAccess that lists only opfilter as a trusted application.- /// In Keychain Access this shows as "Confirm before allowing access" with- /// opfilter under "Always allow access by these applications".+ /// Builds a SecAccess that lists only opfilter as a trusted application,+ /// resolved via the running executable's absolute path. Passing `nil` to+ /// `SecTrustedApplicationCreateFromPath` was observed to produce an+ /// empty trusted-apps list when called from a system extension; the+ /// explicit path forces the API to bind to the running binary's CDHash. private static func makeDaemonOnlyAccess() throws -> SecAccess {+ let executablePath = currentExecutablePath() var trustedApp: SecTrustedApplication?- let appStatus = SecTrustedApplicationCreateFromPath(nil, &trustedApp)+ let appStatus = SecTrustedApplicationCreateFromPath(executablePath, &trustedApp) guard appStatus == errSecSuccess, let app = trustedApp else {- NSLog("PolicySigner: SecTrustedApplicationCreateFromPath failed (%d)", appStatus)+ NSLog("PolicySigner: SecTrustedApplicationCreateFromPath failed for %@ (%d)", executablePath, appStatus) throw PolicySignerError.aclCreationFailed } @@ -150,17 +169,40 @@ enum PolicySigner { return result } + private static func currentExecutablePath() -> String {+ var size: UInt32 = 0+ _ = _NSGetExecutablePath(nil, &size)+ var buffer = [CChar](repeating: 0, count: Int(size))+ let status = _NSGetExecutablePath(&buffer, &size)+ guard status == 0 else {+ fatalError("PolicySigner: _NSGetExecutablePath failed (\(status)) — cannot bind keychain ACL to opfilter")+ }+ return String(cString: buffer)+ }+ // MARK: - One-time migration - /// Deletes any existing System Keychain key that was created with a- /// permissive (allow-all) ACL. Runs once, gated by a marker file.- /// The next call to loadOrCreateKey() will create a fresh key with a- /// opfilter-only ACL.- private static func migratePermissiveKeyOnce() {- guard !FileManager.default.fileExists(atPath: aclMigrationMarker.path) else { return }- defer {- FileManager.default.createFile(atPath: aclMigrationMarker.path, contents: nil)- }+ /// Deletes any existing System Keychain key so that the next call to@@ -0,0 +1,162 @@+//+// EpochRatchet.swift+// opfilter+//+// Replay-attack mitigation for the on-disk signed policy database.+//+// Each signed table carries a monotonically-increasing epoch in its signed+// payload. After every successful save, the latest epoch is also persisted+// in the System Keychain under the same opfilter-only ACL used by+// PolicySigner. On load, the disk epoch is compared against the keychain+// epoch: a strictly-lower disk epoch indicates the table has been replaced+// by an older legitimately-signed snapshot (replay) and is treated as+// suspect.+//++import Foundation+import Security++protocol EpochRatchetStore: Sendable {+ func epoch(forTable table: String) -> UInt64?+ func setEpoch(_ epoch: UInt64, forTable table: String)+}++enum RatchetVerdict: Equatable {+ case verified+ case replay+}++enum EpochRatchet {+ /// Replaceable for tests. Production code uses the System-Keychain-backed store.+ nonisolated(unsafe) static var store: EpochRatchetStore = SystemKeychainEpochRatchetStore()++ static func epoch(forTable table: String) -> UInt64? {+ store.epoch(forTable: table)+ }++ static func setEpoch(_ epoch: UInt64, forTable table: String) {+ store.setEpoch(epoch, forTable: table)+ }++ /// Pure decision function. A missing keychain epoch is treated as a one-time+ /// forgiveness case (fresh install, keychain wipe) — we do not have a+ /// trustworthy lower bound to enforce, so the verdict is `.verified` and the+ /// next save re-seeds the keychain.+ static func verdict(diskEpoch: UInt64, keychainEpoch: UInt64?) -> RatchetVerdict {+ guard let keychainEpoch else { return .verified }+ return keychainEpoch > diskEpoch ? .replay : .verified+ }+}++struct InMemoryEpochRatchetStore: EpochRatchetStore {+ private final class Storage: @unchecked Sendable {+ var values: [String: UInt64] = [:]+ let lock = NSLock()+ }+ private let storage = Storage()++ init() {}++ func epoch(forTable table: String) -> UInt64? {+ storage.lock.lock()+ defer { storage.lock.unlock() }+ return storage.values[table]+ }++ func setEpoch(_ epoch: UInt64, forTable table: String) {+ storage.lock.lock()+ defer { storage.lock.unlock() }+ storage.values[table] = epoch+ }+}++struct SystemKeychainEpochRatchetStore: EpochRatchetStore {+ private static let service = "uk.craigbass.clearancekit.epoch-ratchet"++ private static let systemKeychain: SecKeychain? = {+ var kc: SecKeychain?+ // SecKeychainOpen is deprecated (macOS 10.10) but remains the only API+ // that accepts an explicit keychain path; matches PolicySigner.+ let status = SecKeychainOpen("/Library/Keychains/System.keychain", &kc)+ if status != errSecSuccess {+ NSLog("EpochRatchet: Could not open System Keychain (%d)", status)+ }+ return kc+ }()++ init() {}++ func epoch(forTable table: String) -> UInt64? {@@ -28,6 +28,7 @@ let allMigrations: [Migration] = [ Migration(version: 8, name: "Add bundle_updater_signatures table", up: migration008AddBundleUpdaterSignatures), Migration(version: 9, name: "Seed bundle_protection_enabled feature flag", up: migration009SeedBundleProtectionFlag), Migration(version: 10, name: "Seed advanced_mode_enabled feature flag", up: migration010SeedAdvancedModeFlag),+ Migration(version: 11, name: "Add epoch column to data_signatures for replay protection", up: migration011AddEpochColumn), ] // MARK: - Migration 001: Create tables and import existing JSON data@@ -337,3 +338,15 @@ private func migration010SeedAdvancedModeFlag(_ db: Database) { ) NSLog("Migration 010: Seeded advanced_mode_enabled=false") }++// MARK: - Migration 011: Add epoch column to data_signatures+//+// Existing signature rows were produced over `content` only, with no epoch+// binding. The new ALTER defaults epoch to 0 so those rows continue to load+// via the legacy-verify fallback in Database.checkSignature; the very next+// save bumps the epoch and re-signs over `content || epoch`.++private func migration011AddEpochColumn(_ db: Database) {+ db.execute("ALTER TABLE data_signatures ADD COLUMN epoch INTEGER NOT NULL DEFAULT 0")+ NSLog("Migration 011: Added epoch column to data_signatures (legacy signatures remain valid at epoch=0)")+}AI-assisted change c934134f44bf3239f2ba3610834143474ee9fabbb28fd6a210c7a1e9f9b9eec5 · Fix 6583c75548f688874c5fe1c01bf32028dd4ea6da907dac86d726b095539e26fd