diff --git a/Core.swift b/Core.swift new file mode 100644 index 0000000..df678e0 --- /dev/null +++ b/Core.swift @@ -0,0 +1,96 @@ +/* + Crypt + + Copyright 2025 The Crypt Project. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import Foundation +import Security +import os.log + +class Crypt: NSObject { + + // Define a pointer to the MechanismRecord. This will be used to get and set + // all the inter-mechanism data. It is also used to allow or deny the login. + var mechanism: UnsafePointer + + // init the class with a MechanismRecord + @objc init(mechanism: UnsafePointer) { + os_log("initWithMechanismRecord", log: coreLog, type: .debug) + self.mechanism = mechanism + } + + // Allow the login. End of the mechanism + func allowLogin() { + os_log("called allowLogin", log: coreLog, type: .default) + _ = self.mechanism.pointee.fPlugin.pointee.fCallbacks.pointee.SetResult( + mechanism.pointee.fEngine, AuthorizationResult.allow) + } + + private func getContextData(key: AuthorizationString) -> NSData? { + os_log("getContextData called", log: coreLog, type: .debug) + var value: UnsafePointer? + let data = withUnsafeMutablePointer(to: &value) { (ptr: UnsafeMutablePointer) -> NSData? in + var flags = AuthorizationContextFlags() + if self.mechanism.pointee.fPlugin.pointee.fCallbacks.pointee.GetContextValue( + self.mechanism.pointee.fEngine, key, &flags, ptr) != errAuthorizationSuccess { + os_log("GetContextValue failed", log: coreLog, type: .error) + return nil + } + guard let length = ptr.pointee?.pointee.length else { + os_log("length failed to unwrap", log: coreLog, type: .error) + return nil + } + guard let buffer = ptr.pointee?.pointee.data else { + os_log("data failed to unwrap", log: coreLog, type: .error) + return nil + } + if length == 0 { + os_log("length is 0", log: coreLog, type: .error) + return nil + } + return NSData.init(bytes: buffer, length: length) + } + return data + } + + var username: NSString? { + get { + os_log("Requesting username...", log: coreLog, type: .debug) + guard let data = getContextData(key: kAuthorizationEnvironmentUsername) else { + return nil + } + guard let s = NSString.init(bytes: data.bytes, + length: data.length, + encoding: String.Encoding.utf8.rawValue) + else { return nil } + return s.replacingOccurrences(of: "\0", with: "") as NSString + } + } + + var password: NSString? { + get { + os_log("Requesting password...", log: coreLog, type: .debug) + guard let data = getContextData(key: kAuthorizationEnvironmentPassword) else { + return nil + } + guard let s = NSString.init(bytes: data.bytes, + length: data.length, + encoding: String.Encoding.utf8.rawValue) + else { return nil } + return s.replacingOccurrences(of: "\0", with: "") as NSString + } + } +} diff --git a/Crypt.xcodeproj/project.pbxproj b/Crypt.xcodeproj/project.pbxproj index d338167..d885ac5 100644 --- a/Crypt.xcodeproj/project.pbxproj +++ b/Crypt.xcodeproj/project.pbxproj @@ -3,53 +3,79 @@ archiveVersion = 1; classes = { }; - objectVersion = 50; + objectVersion = 56; objects = { /* Begin PBXBuildFile section */ - 1A5C742E1F15554700D9792A /* PromptWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A5C742D1F15554700D9792A /* PromptWindowController.swift */; }; 1A8643121F15292200108C19 /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A8643111F15292200108C19 /* Preferences.swift */; }; - C7AD39001D22B3E3000FB736 /* CryptMechanism.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7AD38FF1D22B3E3000FB736 /* CryptMechanism.swift */; }; D2D31D5D1C2303F500839D93 /* CryptAuthPlugin.m in Sources */ = {isa = PBXBuildFile; fileRef = D2D31D511C2303F500839D93 /* CryptAuthPlugin.m */; }; D2D31D5F1C2303F500839D93 /* Check.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2D31D541C2303F500839D93 /* Check.swift */; }; - D2D31D611C2303F500839D93 /* Enablement.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2D31D571C2303F500839D93 /* Enablement.swift */; }; - D2D31D621C2303F500839D93 /* fv2.png in Resources */ = {isa = PBXBuildFile; fileRef = D2D31D591C2303F500839D93 /* fv2.png */; }; - D2D31D631C2303F500839D93 /* PromptWindowController.m in Sources */ = {isa = PBXBuildFile; fileRef = D2D31D5B1C2303F500839D93 /* PromptWindowController.m */; }; - D2D31D641C2303F500839D93 /* PromptWindowController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D2D31D5C1C2303F500839D93 /* PromptWindowController.xib */; }; - D2D31D671C2307D500839D93 /* CryptGUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2D31D661C2307D500839D93 /* CryptGUI.swift */; }; + D50ACC7C2A6C797E00F8E62D /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = D50ACC7B2A6C797E00F8E62D /* main.swift */; }; + D50ACC832A6C79D200F8E62D /* authmod in CopyFiles */ = {isa = PBXBuildFile; fileRef = D50ACC792A6C797E00F8E62D /* authmod */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + D50F65B12B8CE9C4008887DB /* Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = D50F65B02B8CE9C4008887DB /* Keychain.swift */; }; + D50F65B32B8CEF27008887DB /* Logging.swift in Sources */ = {isa = PBXBuildFile; fileRef = D50F65B22B8CEF27008887DB /* Logging.swift */; }; + D54BDA342C31F4190028425F /* Filevault.swift in Sources */ = {isa = PBXBuildFile; fileRef = D54BDA332C31F4190028425F /* Filevault.swift */; }; + D579C9312D309A1F00FB6802 /* ArgumentParser in Frameworks */ = {isa = PBXBuildFile; productRef = D579C9302D309A1F00FB6802 /* ArgumentParser */; }; + D5C75AA72D4C06E900C77CA0 /* Core.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C75AA62D4C06E900C77CA0 /* Core.swift */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + D50ACC852A6C7E1C00F8E62D /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = FCD4FD8C1BEE763C00CF7F48 /* Project object */; + proxyType = 1; + remoteGlobalIDString = D50ACC782A6C797E00F8E62D; + remoteInfo = authmod; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXCopyFilesBuildPhase section */ C7AD39031D22C32F000FB736 /* CopyFiles */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; - dstPath = "$(CONTENTS_FOLDER_PATH)/XPCServices"; - dstSubfolderSpec = 16; + dstPath = ""; + dstSubfolderSpec = 6; files = ( + D50ACC832A6C79D200F8E62D /* authmod in CopyFiles */, ); runOnlyForDeploymentPostprocessing = 0; }; + D50ACC772A6C797E00F8E62D /* CopyFiles */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = /usr/share/man/man1/; + dstSubfolderSpec = 0; + files = ( + ); + runOnlyForDeploymentPostprocessing = 1; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 1A5C742D1F15554700D9792A /* PromptWindowController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PromptWindowController.swift; sourceTree = ""; }; 1A8643111F15292200108C19 /* Preferences.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = ""; }; - C7AD38FF1D22B3E3000FB736 /* CryptMechanism.swift */ = {isa = PBXFileReference; fileEncoding = 4; indentWidth = 2; lastKnownFileType = sourcecode.swift; path = CryptMechanism.swift; sourceTree = ""; tabWidth = 2; }; D2D31D4F1C2303F500839D93 /* Crypt-Bridging-Header.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = "Crypt-Bridging-Header.h"; path = "Crypt/Crypt-Bridging-Header.h"; sourceTree = ""; }; D2D31D501C2303F500839D93 /* CryptAuthPlugin.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = CryptAuthPlugin.h; path = Crypt/CryptAuthPlugin.h; sourceTree = ""; }; D2D31D511C2303F500839D93 /* CryptAuthPlugin.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = CryptAuthPlugin.m; path = Crypt/CryptAuthPlugin.m; sourceTree = ""; }; D2D31D521C2303F500839D93 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = Crypt/Info.plist; sourceTree = ""; }; D2D31D541C2303F500839D93 /* Check.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Check.swift; sourceTree = ""; }; - D2D31D571C2303F500839D93 /* Enablement.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Enablement.swift; sourceTree = ""; }; - D2D31D591C2303F500839D93 /* fv2.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = fv2.png; sourceTree = ""; }; - D2D31D5A1C2303F500839D93 /* PromptWindowController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = PromptWindowController.h; sourceTree = ""; }; - D2D31D5B1C2303F500839D93 /* PromptWindowController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = PromptWindowController.m; sourceTree = ""; }; - D2D31D5C1C2303F500839D93 /* PromptWindowController.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = PromptWindowController.xib; sourceTree = ""; }; D2D31D651C23042A00839D93 /* Crypt.bundle */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = Crypt.bundle; sourceTree = BUILT_PRODUCTS_DIR; }; - D2D31D661C2307D500839D93 /* CryptGUI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CryptGUI.swift; sourceTree = ""; }; + D50ACC792A6C797E00F8E62D /* authmod */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = authmod; sourceTree = BUILT_PRODUCTS_DIR; }; + D50ACC7B2A6C797E00F8E62D /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; + D50F65B02B8CE9C4008887DB /* Keychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keychain.swift; sourceTree = ""; }; + D50F65B22B8CEF27008887DB /* Logging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logging.swift; sourceTree = ""; }; + D54BDA332C31F4190028425F /* Filevault.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Filevault.swift; sourceTree = ""; }; + D5C75AA62D4C06E900C77CA0 /* Core.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Core.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + D50ACC762A6C797E00F8E62D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + D579C9312D309A1F00FB6802 /* ArgumentParser in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; FCD4FD911BEE763C00CF7F48 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -63,7 +89,11 @@ 1A8643131F15294900108C19 /* Utility */ = { isa = PBXGroup; children = ( + D5C75AA62D4C06E900C77CA0 /* Core.swift */, 1A8643111F15292200108C19 /* Preferences.swift */, + D50F65B02B8CE9C4008887DB /* Keychain.swift */, + D50F65B22B8CEF27008887DB /* Logging.swift */, + D54BDA332C31F4190028425F /* Filevault.swift */, ); name = Utility; sourceTree = ""; @@ -71,26 +101,25 @@ D2D31D531C2303F500839D93 /* Mechanisms */ = { isa = PBXGroup; children = ( - C7AD38FF1D22B3E3000FB736 /* CryptMechanism.swift */, D2D31D541C2303F500839D93 /* Check.swift */, - D2D31D661C2307D500839D93 /* CryptGUI.swift */, - D2D31D571C2303F500839D93 /* Enablement.swift */, ); name = Mechanisms; path = Crypt/Mechanisms; sourceTree = ""; }; - D2D31D581C2303F500839D93 /* Views */ = { + D50ACC7A2A6C797E00F8E62D /* authmod */ = { isa = PBXGroup; children = ( - D2D31D591C2303F500839D93 /* fv2.png */, - D2D31D5A1C2303F500839D93 /* PromptWindowController.h */, - 1A5C742D1F15554700D9792A /* PromptWindowController.swift */, - D2D31D5B1C2303F500839D93 /* PromptWindowController.m */, - D2D31D5C1C2303F500839D93 /* PromptWindowController.xib */, + D50ACC7B2A6C797E00F8E62D /* main.swift */, ); - name = Views; - path = Crypt/Views; + path = authmod; + sourceTree = ""; + }; + D50ACC802A6C799A00F8E62D /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; sourceTree = ""; }; FCD4FD8B1BEE763C00CF7F48 = { @@ -102,8 +131,10 @@ D2D31D511C2303F500839D93 /* CryptAuthPlugin.m */, D2D31D521C2303F500839D93 /* Info.plist */, D2D31D531C2303F500839D93 /* Mechanisms */, - D2D31D581C2303F500839D93 /* Views */, D2D31D651C23042A00839D93 /* Crypt.bundle */, + D50ACC792A6C797E00F8E62D /* authmod */, + D50ACC7A2A6C797E00F8E62D /* authmod */, + D50ACC802A6C799A00F8E62D /* Frameworks */, ); indentWidth = 2; sourceTree = ""; @@ -112,6 +143,26 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + D50ACC782A6C797E00F8E62D /* authmod */ = { + isa = PBXNativeTarget; + buildConfigurationList = D50ACC7D2A6C797E00F8E62D /* Build configuration list for PBXNativeTarget "authmod" */; + buildPhases = ( + D50ACC752A6C797E00F8E62D /* Sources */, + D50ACC762A6C797E00F8E62D /* Frameworks */, + D50ACC772A6C797E00F8E62D /* CopyFiles */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = authmod; + packageProductDependencies = ( + D579C9302D309A1F00FB6802 /* ArgumentParser */, + ); + productName = authmod; + productReference = D50ACC792A6C797E00F8E62D /* authmod */; + productType = "com.apple.product-type.tool"; + }; FCD4FD931BEE763C00CF7F48 /* Crypt */ = { isa = PBXNativeTarget; buildConfigurationList = FCD4FD9A1BEE763C00CF7F48 /* Build configuration list for PBXNativeTarget "Crypt" */; @@ -125,8 +176,11 @@ buildRules = ( ); dependencies = ( + D50ACC862A6C7E1C00F8E62D /* PBXTargetDependency */, ); name = Crypt; + packageProductDependencies = ( + ); productName = Crypt; productReference = D2D31D651C23042A00839D93 /* Crypt.bundle */; productType = "com.apple.product-type.bundle"; @@ -137,9 +191,14 @@ FCD4FD8C1BEE763C00CF7F48 /* Project object */ = { isa = PBXProject; attributes = { - LastUpgradeCheck = 1250; + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 1430; + LastUpgradeCheck = 1620; ORGANIZATIONNAME = "Graham Gilbert"; TargetAttributes = { + D50ACC782A6C797E00F8E62D = { + CreatedOnToolsVersion = 14.3.1; + }; FCD4FD931BEE763C00CF7F48 = { CreatedOnToolsVersion = 7.1; LastSwiftMigration = 1150; @@ -147,7 +206,7 @@ }; }; buildConfigurationList = FCD4FD8F1BEE763C00CF7F48 /* Build configuration list for PBXProject "Crypt" */; - compatibilityVersion = "Xcode 9.3"; + compatibilityVersion = "Xcode 14.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( @@ -155,11 +214,15 @@ Base, ); mainGroup = FCD4FD8B1BEE763C00CF7F48; + packageReferences = ( + D579C92F2D309A1F00FB6802 /* XCRemoteSwiftPackageReference "swift-argument-parser" */, + ); productRefGroup = FCD4FD8B1BEE763C00CF7F48; projectDirPath = ""; projectRoot = ""; targets = ( FCD4FD931BEE763C00CF7F48 /* Crypt */, + D50ACC782A6C797E00F8E62D /* authmod */, ); }; /* End PBXProject section */ @@ -169,8 +232,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - D2D31D621C2303F500839D93 /* fv2.png in Resources */, - D2D31D641C2303F500839D93 /* PromptWindowController.xib in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -193,29 +254,98 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + D50ACC752A6C797E00F8E62D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D50ACC7C2A6C797E00F8E62D /* main.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; FCD4FD901BEE763C00CF7F48 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( D2D31D5F1C2303F500839D93 /* Check.swift in Sources */, + D50F65B32B8CEF27008887DB /* Logging.swift in Sources */, 1A8643121F15292200108C19 /* Preferences.swift in Sources */, - D2D31D611C2303F500839D93 /* Enablement.swift in Sources */, + D54BDA342C31F4190028425F /* Filevault.swift in Sources */, D2D31D5D1C2303F500839D93 /* CryptAuthPlugin.m in Sources */, - D2D31D671C2307D500839D93 /* CryptGUI.swift in Sources */, - 1A5C742E1F15554700D9792A /* PromptWindowController.swift in Sources */, - D2D31D631C2303F500839D93 /* PromptWindowController.m in Sources */, - C7AD39001D22B3E3000FB736 /* CryptMechanism.swift in Sources */, + D50F65B12B8CE9C4008887DB /* Keychain.swift in Sources */, + D5C75AA72D4C06E900C77CA0 /* Core.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + D50ACC862A6C7E1C00F8E62D /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D50ACC782A6C797E00F8E62D /* authmod */; + targetProxy = D50ACC852A6C7E1C00F8E62D /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ + D50ACC7E2A6C797E00F8E62D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + CODE_SIGN_STYLE = Manual; + DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=macosx*]" = Y28TH9SHX7; + ENABLE_HARDENED_RUNTIME = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + MACOSX_DEPLOYMENT_TARGET = 13.5; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + D50ACC7F2A6C797E00F8E62D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + CODE_SIGN_STYLE = Manual; + DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=macosx*]" = Y28TH9SHX7; + ENABLE_HARDENED_RUNTIME = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + MACOSX_DEPLOYMENT_TARGET = 13.5; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.grahamgilbert.Crypt.authmod; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; FCD4FD981BEE763C00CF7F48 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; @@ -241,11 +371,13 @@ CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - CODE_SIGN_IDENTITY = "Developer ID Application: Graham Gilbert (9D8XP85393)"; + CODE_SIGN_IDENTITY = "Apple Development"; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -260,7 +392,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 11.0; + MACOSX_DEPLOYMENT_TARGET = 13.5; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -270,8 +402,8 @@ FCD4FD991BEE763C00CF7F48 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; @@ -297,11 +429,13 @@ CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - CODE_SIGN_IDENTITY = "Developer ID Application: Graham Gilbert (9D8XP85393)"; + CODE_SIGN_IDENTITY = "Apple Development"; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -310,7 +444,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 11.0; + MACOSX_DEPLOYMENT_TARGET = 13.5; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -321,13 +455,15 @@ FCD4FD9B1BEE763C00CF7F48 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Mac Developer"; - CODE_SIGN_STYLE = Automatic; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 4.0; - DEVELOPMENT_TEAM = 9D8XP85393; + CURRENT_PROJECT_VERSION = 6.0; + DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=macosx*]" = Y28TH9SHX7; INFOPLIST_FILE = Crypt/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Bundles"; LD_RUNPATH_SEARCH_PATHS = ( @@ -335,8 +471,8 @@ "@executable_path/../Frameworks", "@loader_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.14; - MARKETING_VERSION = 4.0; + MACOSX_DEPLOYMENT_TARGET = 13.5; + MARKETING_VERSION = 6.0; PRODUCT_BUNDLE_IDENTIFIER = com.grahamgilbert.Crypt; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE = ""; @@ -354,13 +490,15 @@ FCD4FD9C1BEE763C00CF7F48 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Mac Developer"; - CODE_SIGN_STYLE = Automatic; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 4.0; - DEVELOPMENT_TEAM = 9D8XP85393; + CURRENT_PROJECT_VERSION = 6.0; + DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=macosx*]" = Y28TH9SHX7; INFOPLIST_FILE = Crypt/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Bundles"; LD_RUNPATH_SEARCH_PATHS = ( @@ -368,8 +506,8 @@ "@executable_path/../Frameworks", "@loader_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.14; - MARKETING_VERSION = 4.0; + MACOSX_DEPLOYMENT_TARGET = 13.5; + MARKETING_VERSION = 6.0; PRODUCT_BUNDLE_IDENTIFIER = com.grahamgilbert.Crypt; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE = ""; @@ -385,6 +523,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + D50ACC7D2A6C797E00F8E62D /* Build configuration list for PBXNativeTarget "authmod" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D50ACC7E2A6C797E00F8E62D /* Debug */, + D50ACC7F2A6C797E00F8E62D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; FCD4FD8F1BEE763C00CF7F48 /* Build configuration list for PBXProject "Crypt" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -404,6 +551,25 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + D579C92F2D309A1F00FB6802 /* XCRemoteSwiftPackageReference "swift-argument-parser" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-argument-parser.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.5.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + D579C9302D309A1F00FB6802 /* ArgumentParser */ = { + isa = XCSwiftPackageProductDependency; + package = D579C92F2D309A1F00FB6802 /* XCRemoteSwiftPackageReference "swift-argument-parser" */; + productName = ArgumentParser; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = FCD4FD8C1BEE763C00CF7F48 /* Project object */; } diff --git a/Crypt/Crypt-Bridging-Header.h b/Crypt/Crypt-Bridging-Header.h index e3ff5f3..c4dcc48 100644 --- a/Crypt/Crypt-Bridging-Header.h +++ b/Crypt/Crypt-Bridging-Header.h @@ -3,4 +3,3 @@ // #import "CryptAuthPlugin.h" -#import "PromptWindowController.h" diff --git a/Crypt/CryptAuthPlugin.h b/Crypt/CryptAuthPlugin.h index 8b89aa0..ffb48de 100644 --- a/Crypt/CryptAuthPlugin.h +++ b/Crypt/CryptAuthPlugin.h @@ -49,8 +49,6 @@ struct MechanismRecord { AuthorizationEngineRef fEngine; const PluginRecord * fPlugin; Boolean fCheck; - Boolean fCryptGUI; - Boolean fEnablement; }; typedef struct MechanismRecord MechanismRecord; diff --git a/Crypt/CryptAuthPlugin.m b/Crypt/CryptAuthPlugin.m index fff252e..9ddf2e9 100644 --- a/Crypt/CryptAuthPlugin.m +++ b/Crypt/CryptAuthPlugin.m @@ -18,7 +18,6 @@ #import "CryptAuthPlugin.h" #import "Crypt-Swift.h" // Auto-generated header - Makes the Swift classes available to ObjC -#import "PromptWindowController.h" #pragma mark #pragma mark Entry Point Wrappers @@ -98,8 +97,6 @@ - (OSStatus)MechanismCreate:(AuthorizationPluginRef)inPlugin mechanism->fEngine = inEngine; mechanism->fPlugin = (PluginRecord *)inPlugin;; mechanism->fCheck = (strcmp(mechanismId, "Check") == 0); - mechanism->fCryptGUI = (strcmp(mechanismId, "CryptGUI") == 0); - mechanism->fEnablement = (strcmp(mechanismId, "Enablement") == 0); *outMechanism = mechanism; return errSecSuccess; } @@ -111,12 +108,6 @@ - (OSStatus)MechanismInvoke:(AuthorizationMechanismRef)inMechanism { if (mechanism->fCheck) { Check *check = [[Check alloc] initWithMechanism:mechanism]; [check run]; - } else if (mechanism->fCryptGUI) { - CryptGUI *cryptgui = [[CryptGUI alloc] initWithMechanism:mechanism]; - [cryptgui run]; - } else if (mechanism->fEnablement) { - Enablement *enablement = [[Enablement alloc] initWithMechanism:mechanism]; - [enablement run]; } // Default "Allow Login". Used if none of the mechanisms above are called or don't make diff --git a/Crypt/Info.plist b/Crypt/Info.plist index ea16117..94e51ea 100644 --- a/Crypt/Info.plist +++ b/Crypt/Info.plist @@ -15,13 +15,13 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 5.0.3 + 6.0.0 CFBundleSignature ???? CFBundleVersion - 282 + 288 NSHumanReadableCopyright - Copyright © 2024 The Crypt Project. All rights reserved. + Copyright © 2025 The Crypt Project. All rights reserved. NSPrincipalClass diff --git a/Crypt/Mechanisms/Check.swift b/Crypt/Mechanisms/Check.swift index 087f0ad..4d644c5 100644 --- a/Crypt/Mechanisms/Check.swift +++ b/Crypt/Mechanisms/Check.swift @@ -1,7 +1,7 @@ /* Crypt - Copyright 2024 The Crypt Project. + Copyright 2025 The Crypt Project. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -21,300 +21,101 @@ import Security import CoreFoundation import os.log -class Check: CryptMechanism { - // Log for the Check functions - private static let log = OSLog(subsystem: "com.grahamgilbert.crypt", category: "Check") - - // Preference bundle id - fileprivate let bundleid = "com.grahamgilbert.crypt" +class Check: Crypt { @objc func run() { - os_log("Starting run of Crypt.Check...", log: Check.log, type: .default) + os_log("Starting run of Crypt.Check...", log: checkLog, type: .default) // check for ServerUrl - let serverURL : NSString? = getServerURL() - - // check for SkipUsers Preference - let skipUsers : Bool = getSkipUsers() + let serverURL = (getPref(key: .ServerURL) as? NSString) ?? nil guard let username = self.username else { allowLogin(); return } guard let password = self.password else { allowLogin(); return } - let the_settings = NSDictionary.init(dictionary: ["Username" : username, "Password" : password]) - - //Get status on encryption. + let the_settings = NSDictionary.init(dictionary: ["Username": username, "Password": password]) + // check for SkipUsers Preference + let skipUsers: Bool = getSkipUsers(username: username as String) + // Get status on encryption. let fdestatus = getFVEnabled() - let fvEnabled : Bool = fdestatus.encrypted - let decrypting : Bool = fdestatus.decrypting - let filepath = CFPreferencesCopyAppValue(Preferences.outputPath as CFString, bundleid as CFString) as? String ?? "/private/var/root/crypt_output.plist" - os_log("OutPutPlist Preferences is set to %{public}@", log: Check.log, type: .default, String(describing: filepath)) + let fvEnabled: Bool = fdestatus.encrypted + let decrypting: Bool = fdestatus.decrypting + let filepath = getPref(key: .OutputPath) as! String + os_log("OutPutPlist Preferences is set to %{public}@", log: checkLog, type: .default, String(describing: filepath)) if decrypting { // If we are decrypting we can't do anything so we can just log in - os_log("We are Decrypting! Not much we can do, exiting for safety...", log: Check.log, type: .error) - self.needsEncryption = false + os_log("We are Decrypting! Not much we can do, exiting for safety...", log: checkLog, type: .error) allowLogin() - return; + return } if fvEnabled { - //FileVault is enabled, checks for things to do if FileVault is enabled should be done here. + // FileVault is enabled, checks for things to do if FileVault is enabled should be done here. // Check for RotateUsedKey Preference - let rotateKey: Bool = getRotateUsedKeyPreference() - os_log("RotateUsedKey Preferences is set to %{public}@", log: Check.log, type: .default, String(describing: rotateKey)) + let rotateKey = getPref(key: .RotateUsedKey) as! Bool + os_log("RotateUsedKey Preferences is set to %{public}@", log: checkLog, type: .default, String(describing: rotateKey)) // Check for RemovePlist Preferences - let removePlist: Bool = getRemovePlistKeyPreference() - os_log("RemovePlist Preferences is set to %{public}@", log: Check.log, type: .default, String(describing: removePlist)) + let removePlist = getPref(key: .RemovePlist) as! Bool + os_log("RemovePlist Preferences is set to %{public}@", log: checkLog, type: .default, String(describing: removePlist)) + + let useKeychain = getPref(key: .StoreRecoveryKeyInKeychain) as! Bool - // Check to see if our recovery key exists at the OutputPath Preference. - let recoveryKeyExists: Bool = checkFileExists(path: filepath) - let genKey = getGenerateNewKey() - let generateKey : Bool = genKey.generateKey - let forcedKey : Bool = genKey.forcedKey + // Check to see if we have a recovery key somewhere + let recoveryKeyExists: Bool = hasRecoveryKey(path: filepath, useKeychain: useKeychain) + let generateKey = getPref(key: .GenerateNewKey) as! Bool + let alreadyGeneratedKey = getPref(key: .RotatedKey) as! Bool if (!recoveryKeyExists && !removePlist && rotateKey) || generateKey { - if forcedKey { - os_log("WARNING!!!!!! GenerateNewKey is set to True, but it's a Managed Preference, you probably don't want to do this. Please change to a non Managed value.", log: Check.log, type: .error) - self.needsEncryption = false + if alreadyGeneratedKey { + os_log("We've already generated a new key. If you wish to generate another key please remove RotatedKey from preferences...", log: checkLog, type: .default) allowLogin() - return; + return } // If key is missing from disk and we aren't supposed to remove it we should generate a new key... - os_log("Conditions for making a new key have been met. Attempting to generate a new key...", log: Check.log, type: .default) + os_log("Conditions for making a new key have been met. Attempting to generate a new key...", log: checkLog, type: .default) do { try _ = rotateRecoveryKey(the_settings, filepath: filepath) } catch let error as NSError { - os_log("Caught error trying to rotate recovery key: %{public}@", log: Check.log, type: .error, error.localizedDescription) + os_log("Caught error trying to rotate recovery key: %{public}@", log: checkLog, type: .error, error.localizedDescription) + allowLogin() + return } if generateKey { - os_log("We've rotated the key and GenerateNewKey was True, setting to False to avoid multiple generations", log: Check.log, type: .default) - // set to false for house keeping - CFPreferencesSetValue("GenerateNewKey" as CFString, false as CFPropertyList, bundleid as CFString, kCFPreferencesAnyUser, kCFPreferencesAnyHost) - // delete from root if set there. - CFPreferencesSetAppValue("GenerateNewKey" as CFString, nil, bundleid as CFString) + os_log("We've rotated the key and GenerateNewKey was True, setting to RotatedKey to avoid multiple generations", log: checkLog, type: .default) + // set to false for house keeping, setPref will also sync to disk + _ = setPref(key: .RotatedKey, value: true) } - self.needsEncryption = false allowLogin() - return; + return } - //let usedKey: Bool = getUsedKey() - //let onPatchedVersion: Bool = ProcessInfo().isOperatingSystemAtLeast(OperatingSystemVersion.init(majorVersion: 10, minorVersion: 12, patchVersion: 4)) - - -// // Feature was supposed to be fixed here "support.apple.com/en-us/HT207536" but it wasn't -// // Leaving code incase it gets fixed eventually -// // Check to see if we used the key to unlock the disk, rotate if configured to. -// if rotateKey && usedKey && onPatchedVersion { -// os_log("Used key to unlock, need to rotate", log: Check.log, type: .default) -// do { -// try _ = rotateRecoveryKey(the_settings, filepath: filepath) -// } catch let error as NSError { -// os_log("Caught error trying to rotate recovery key: %@", log: Check.log, type: .default, error) -// _ = allowLogin() -// } -// } - - - -// if let keyRotateDays = CFPreferencesCopyAppValue(Preferences.keyRotateDays as CFString, bundleid as CFString) { -// -// let prefs = try! Data.init(contentsOf: URL(fileURLWithPath: filepath)) -// -// let prefsDict = prefs as! Dictionary -// -// let lastDate = prefsDict["EnabledDate"] as! Date -// -// if (Double(keyRotateDays as! NSNumber) * 24 * 60 * 60 ) < lastDate.timeIntervalSince(lastDate) { -// do { -// _ = try rotateRecoveryKey(the_settings, filepath: filepath) -// } -// catch let error as NSError { -// NSLog("%@", error) -// _ = allowLogin() -// } -// } -// } - - os_log("All checks for an encypted machine have passed, Allowing Login...", log: Check.log, type: .default) - self.needsEncryption = false + os_log("All checks for an encrypted machine have passed, Allowing Login...", log: checkLog, type: .default) allowLogin() - return; + return // end of fvEnabled - } - else if skipUsers { - os_log("Logged in User is in the Skip List... Not enforcing FileVault...", log: Check.log, type: .error) - self.needsEncryption = false - allowLogin() - return; - } - else if (serverURL == nil) { - //Should we acutally do this? - os_log("Couldn't find ServerURL Pref choosing not to enable FileVault...", log: Check.log, type: .error) - self.needsEncryption = false + } else if skipUsers { + os_log("Logged in User is in the Skip List... Not enforcing FileVault...", log: checkLog, type: .error) + allowLogin() - return; - } - else if onHighSierraOrNewer() && onAPFS() { - // we're on high sierra we can just enable - os_log("On High Sierra and not enabled. Starting Enablement...", log: Check.log, type: .default) - do { - try _ = enableFileVault(the_settings, filepath: filepath) - } catch let error as NSError { - os_log("Caught error trying to Enable FileVault on High Sierra: %{public}@", log: Check.log, type: .error, String(describing: error.localizedDescription)) - } - if needToRestart() { - self.needsEncryption = true - return - } - self.needsEncryption = false + return + } else if serverURL == nil { + // Should we acutally do this? + os_log("Couldn't find ServerURL Pref choosing not to enable FileVault...", log: checkLog, type: .error) allowLogin() - return; + return } - else { - os_log("FileVault is not enabled, Setting to enable...", log: Check.log, type: .error) - self.needsEncryption = true + os_log("FileVault is not enabled, attempting to enable...", log: checkLog, type: .default) + do { + try _ = enableFileVault(the_settings, filepath: filepath) + } catch let error as NSError { + os_log("Caught error trying to Enable FileVault: %{public}@", log: checkLog, type: .error, String(describing: error.localizedDescription)) } - } - // fdesetup Errors - enum FileVaultError: Error { - case fdeSetupFailed(retCode: Int32) - case outputPlistNull - case outputPlistMalformed - } - - fileprivate func getUsedKey() -> Bool { - let task = Process(); - task.launchPath = "/usr/bin/fdesetup" - task.arguments = ["usingrecoverykey"] - let pipe = Pipe() - task.standardOutput = pipe - task.launch() - let data = pipe.fileHandleForReading.readDataToEndOfFile() - guard let output: String = String(data: data, encoding: String.Encoding.utf8) - else { return false } - return (output.range(of: "true") != nil) - } - - func trim_string(_ the_string:String) -> String { - let output = the_string.trimmingCharacters( - in: CharacterSet.whitespacesAndNewlines) - os_log("Trimming %{public}@ to %{public}@", log: Check.log, type: .default, String(describing: the_string), String(describing: output)) - return output - } - - fileprivate func getSkipUsers() -> Bool { - os_log("Checking for any SkipUsers...", log: Check.log, type: .default) - guard let username = self.username - else { - os_log("Cannot get username", log: Check.log, type: .error) - return false - } - os_log("Username is %{public}@...", log: Check.log, type: .error, String(describing: username)) - - if username as String == "_mbsetupuser" { - os_log("User is _mbsetupuser... Need to Skip...", log: Check.log, type: .error) - return true - } - - if username as String == "root" { - os_log("User is root... Need to Skip...", log: Check.log, type: .error) - return true - } - guard let prefValue = CFPreferencesCopyAppValue("SkipUsers" as CFString, bundleid as CFString) as? [String] - else { return false } - for s in prefValue { - if trim_string(s) == username as String { - os_log("Found %{public}@ in SkipUsers list...", log: Check.log, type: .error, String(describing: username)) - return true - } - } - return false - } - - fileprivate func getServerURL() -> NSString? { - let preference = CFPreferencesCopyAppValue("ServerURL" as CFString, bundleid as CFString) as? NSString - return (preference != nil) ? preference : nil - } - - fileprivate func getRotateUsedKeyPreference() -> Bool { - guard let rotatekey : Bool = CFPreferencesCopyAppValue("RotateUsedKey" as CFString, bundleid as CFString) as? Bool - else { return true } - return rotatekey - } - - fileprivate func getRemovePlistKeyPreference() -> Bool { - guard let removeplist : Bool = CFPreferencesCopyAppValue(Preferences.removePlist as CFString, bundleid as CFString) as? Bool - else { return true } - return removeplist - } - - fileprivate func getGenerateNewKey() -> (generateKey: Bool, forcedKey: Bool) { - let forcedKey : Bool = CFPreferencesAppValueIsForced("GenerateNewKey" as CFString, bundleid as CFString) - guard let genkey : Bool = CFPreferencesCopyAppValue("GenerateNewKey" as CFString, bundleid as CFString) as? Bool - else {return (false, forcedKey)} - return (genkey, forcedKey) - } - - func rotateRecoveryKey(_ theSettings : NSDictionary, filepath : String) throws -> Bool { - os_log("Attempting to Rotate Recovery Key...", log: Check.log, type: .default) - let inputPlist = try PropertyListSerialization.data(fromPropertyList: theSettings, - format: PropertyListSerialization.PropertyListFormat.xml, options: 0) - - let inPipe = Pipe.init() - let outPipe = Pipe.init() - let errorPipe = Pipe.init() - - let task = Process.init() - task.launchPath = "/usr/bin/fdesetup" - task.arguments = ["changerecovery", "-personal", "-outputplist", "-inputplist"] - task.standardInput = inPipe - task.standardOutput = outPipe - task.standardError = errorPipe - task.launch() - inPipe.fileHandleForWriting.write(inputPlist) - inPipe.fileHandleForWriting.closeFile() - task.waitUntilExit() - - let errorOut = errorPipe.fileHandleForReading.readDataToEndOfFile() - let errorMessage = String(data: errorOut, encoding: .utf8) - errorPipe.fileHandleForReading.closeFile() - - if task.terminationStatus != 0 { - let termstatus = String(describing: task.terminationStatus) - os_log("Error: fdesetup terminated with a NON-Zero exit status: %{public}@", log: Check.log, type: .error, termstatus) - os_log("fdesetup Standard Error: %{public}@", log: Check.log, type: .error, String(describing: errorMessage)) - throw FileVaultError.fdeSetupFailed(retCode: task.terminationStatus) - } - - os_log("Trying to get output data", log: Check.log, type: .default) - let outputData = outPipe.fileHandleForReading.readDataToEndOfFile() - outPipe.fileHandleForReading.closeFile() - - if outputData.count == 0 { - os_log("Error: Found nothing in output data", log: Check.log, type: .error) - throw FileVaultError.outputPlistNull - } - - var format : PropertyListSerialization.PropertyListFormat = PropertyListSerialization.PropertyListFormat.xml - let outputPlist = try PropertyListSerialization.propertyList(from: outputData, - options: PropertyListSerialization.MutabilityOptions(), format: &format) - - if (format == PropertyListSerialization.PropertyListFormat.xml) { - if outputPlist is NSDictionary { - os_log("Attempting to write key to: %{public}@", log: Check.log, type: .default, String(describing: filepath)) - _ = (outputPlist as! NSDictionary).write(toFile: filepath, atomically: true) - } - os_log("Successfully wrote key to: %{public}@", log: Check.log, type: .default, String(describing: filepath)) - return true - } else { - os_log("rotateRecoveryKey() Error. Format does not equal 'PropertyListSerialization.PropertyListFormat.xml'", log: Check.log, type: .error) - throw FileVaultError.outputPlistMalformed - } + allowLogin() + return } } diff --git a/Crypt/Mechanisms/CryptGUI.swift b/Crypt/Mechanisms/CryptGUI.swift index 656e1ad..80303de 100644 --- a/Crypt/Mechanisms/CryptGUI.swift +++ b/Crypt/Mechanisms/CryptGUI.swift @@ -20,7 +20,7 @@ import Foundation class CryptGUI: CryptMechanism { @objc func run() { - if (self.needsEncryption) { + if self.needsEncryption { let promptWindowController = PromptWindowController.init() promptWindowController.mechanism = self.mechanism guard let promptWindow = promptWindowController.window diff --git a/Crypt/Mechanisms/CryptMechanism.swift b/Crypt/Mechanisms/CryptMechanism.swift deleted file mode 100644 index 8a5f1d3..0000000 --- a/Crypt/Mechanisms/CryptMechanism.swift +++ /dev/null @@ -1,350 +0,0 @@ -/* - Crypt - - Copyright 2024 The Crypt Project. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -import Foundation -import Security -import os.log - -class CryptMechanism: NSObject { - // This NSString will be used as the domain for the inter-mechanism context data - let contextCryptDomain : NSString = "com.grahamgilbert.crypt" - - // Key for hint data - private let needsEncryptionHintKey = "com.grahamgilbert.crypt.needsEncryption" - - // Log Crypt Mechanism - private static let log = OSLog(subsystem: "com.grahamgilbert.crypt", category: "CryptMechanism") - // Define a pointer to the MechanismRecord. This will be used to get and set - // all the inter-mechanism data. It is also used to allow or deny the login. - var mechanism:UnsafePointer - - // init the class with a MechanismRecord - @objc init(mechanism:UnsafePointer) { - os_log("initWithMechanismRecord", log: CryptMechanism.log, type: .default) - self.mechanism = mechanism - } - - // Allow the login. End of the mechanism - func allowLogin() { - os_log("called allowLogin", log: CryptMechanism.log, type: .default) - _ = self.mechanism.pointee.fPlugin.pointee.fCallbacks.pointee.SetResult( - mechanism.pointee.fEngine, AuthorizationResult.allow) - } - - private func getContextData(key: AuthorizationString) -> NSData? { - os_log("getContextData called", log: CryptMechanism.log, type: .default) - var value: UnsafePointer? - let data = withUnsafeMutablePointer(to: &value) { (ptr: UnsafeMutablePointer) -> NSData? in - var flags = AuthorizationContextFlags() - if (self.mechanism.pointee.fPlugin.pointee.fCallbacks.pointee.GetContextValue( - self.mechanism.pointee.fEngine, key, &flags, ptr) != errAuthorizationSuccess) { - os_log("GetContextValue failed", log: CryptMechanism.log, type: .error) - return nil; - } - guard let length = ptr.pointee?.pointee.length else { - os_log("length failed to unwrap", log: CryptMechanism.log, type: .error) - return nil - } - guard let buffer = ptr.pointee?.pointee.data else { - os_log("data failed to unwrap", log: CryptMechanism.log, type: .error) - return nil - } - if (length == 0) { - os_log("length is 0", log: CryptMechanism.log, type: .error) - return nil - } - return NSData.init(bytes: buffer, length: length) - } - os_log("getContextData success", log: CryptMechanism.log, type: .default) - return data - } - - private func getHintData(key: AuthorizationString) -> NSData? { - os_log("getHintData called", log: CryptMechanism.log, type: .default) - var value: UnsafePointer? - let data = withUnsafeMutablePointer(to: &value) { (ptr: UnsafeMutablePointer) -> NSData? in - if (self.mechanism.pointee.fPlugin.pointee.fCallbacks.pointee.GetHintValue( - self.mechanism.pointee.fEngine, key, ptr) != errAuthorizationSuccess) { - os_log("GetHintValue failed", log: CryptMechanism.log, type: .error) - return nil; - } - guard let length = ptr.pointee?.pointee.length else { - os_log("length failed to unwrap", log: CryptMechanism.log, type: .error) - return nil - } - guard let buffer = ptr.pointee?.pointee.data else { - os_log("data failed to unwrap", log: CryptMechanism.log, type: .error) - return nil - } - if (length == 0) { - os_log("length is 0", log: CryptMechanism.log, type: .error) - return nil - } - return NSData.init(bytes: buffer, length: length) - } - os_log("getHintData success", log: CryptMechanism.log, type: .default) - return data - } - - private func setHintData(key: AuthorizationString, data: NSData) -> Bool { - os_log("setHintData called", log: CryptMechanism.log, type: .default) - var value = AuthorizationValue(length: data.length , - data: UnsafeMutableRawPointer(mutating: data.bytes)) - return (self.mechanism.pointee.fPlugin.pointee.fCallbacks.pointee.SetHintValue( - self.mechanism.pointee.fEngine, key, &value) != errAuthorizationSuccess) - } - - var username: NSString? { - get { - os_log("Requesting username...", log: CryptMechanism.log, type: .default) - guard let data = getContextData(key: kAuthorizationEnvironmentUsername) else { - return nil - } - guard let s = NSString.init(bytes: data.bytes, - length: data.length, - encoding: String.Encoding.utf8.rawValue) - else { return nil } - return s.replacingOccurrences(of: "\0", with: "") as NSString - } - } - - var password: NSString? { - get { - os_log("Requesting password...", log: CryptMechanism.log, type: .default) - guard let data = getContextData(key: kAuthorizationEnvironmentPassword) else { - return nil - } - guard let s = NSString.init(bytes: data.bytes, - length: data.length, - encoding: String.Encoding.utf8.rawValue) - else { return nil } - return s.replacingOccurrences(of: "\0", with: "") as NSString - } - } - - var uid: uid_t { - get { - os_log("Requesting uid...", log: CryptMechanism.log, type: .default) - var uid: UInt32 = UInt32.max - 1 // nobody - guard let data = getContextData(key: kAuthorizationEnvironmentUID) else { - return uid - } - data.getBytes(&uid, length: MemoryLayout.size) - return uid - } - } - - var needsEncryption: Bool { - set { - os_log("needsEncryption set to %@", log: CryptMechanism.log, type: .default, newValue as CVarArg) - guard let data = try? NSKeyedArchiver.archivedData(withRootObject: NSNumber.init(value: newValue), requiringSecureCoding: false) else { return } - _ = setHintData(key: needsEncryptionHintKey, data: data as NSData) - } - - get { - os_log("Requesting needsEncryption...", log: CryptMechanism.log, type: .default) - guard let data = getHintData(key: needsEncryptionHintKey) else { - return false - } - guard let value = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSNumber.self, from: data as Data) else { - return false - } - return (value).boolValue - } - } - - func needToRestart() -> Bool { - os_log("Checking to see if we need to restart now because we may not be on APFS", log: CryptMechanism.log, type: .default) - let task = Process(); - task.launchPath = "/usr/bin/fdesetup" - task.arguments = ["status"] - let pipe = Pipe() - task.standardOutput = pipe - task.launch() - let data = pipe.fileHandleForReading.readDataToEndOfFile() - guard let output: String = String(data: data, encoding: String.Encoding.utf8) - else { return true } - if ((output.range(of: "restart")) != nil) { - os_log("Looks like we need to restart...", log: CryptMechanism.log, type: .default) - return true - } else { - os_log("No restart needed.", log: CryptMechanism.log, type: .default) - return false - } - } - - // check if on 10.13+ - func onHighSierraOrNewer() -> Bool { - os_log("Checking to see if on 10.13+", log: CryptMechanism.log, type: .default) - return ProcessInfo().isOperatingSystemAtLeast(OperatingSystemVersion.init(majorVersion: 10, minorVersion: 13, patchVersion: 0)) - } - - // check authrestart capability - func checkAuthRestart() -> Bool { - let outPipe = Pipe.init() - let authRestartCheck = Process.init() - authRestartCheck.launchPath = "/usr/bin/fdesetup" - authRestartCheck.arguments = ["supportsauthrestart"] - authRestartCheck.standardOutput = outPipe - authRestartCheck.launch() - let outputData = outPipe.fileHandleForReading.availableData - let outputString = String(data: outputData, encoding: String.Encoding.utf8) ?? "" - if (outputString.range(of: "true") != nil) { - os_log("Authrestart capability is 'true', will authrestart as appropriate", log: CryptMechanism.log, type: .default) - return true - } - else { - os_log("Authrestart capability is 'false', reverting to standard reboot", log: CryptMechanism.log, type: .default) - return false - } - } - - // fdesetup Errors - private enum FileVaultError: Error { - case fdeSetupFailed(retCode: Int32) - case outputPlistNull - case outputPlistMalformed - } - - // Check if some information on filevault whether it's encrypted and if decrypting. - func getFVEnabled() -> (encrypted: Bool, decrypting: Bool) { - os_log("Checking the current status of FileVault..", log: CryptMechanism.log, type: .default) - let task = Process(); - task.launchPath = "/usr/bin/fdesetup" - task.arguments = ["status"] - let pipe = Pipe() - task.standardOutput = pipe - task.launch() - let data = pipe.fileHandleForReading.readDataToEndOfFile() - guard let output: String = String(data: data, encoding: String.Encoding.utf8) - else { return (false, false) } - if ((output.range(of: "FileVault is On.")) != nil) { - os_log("Filevault is On...", log: CryptMechanism.log, type: .default) - return (true, false) - } else if (output.range(of: "Decryption in progress:") != nil) { - os_log("FileVault Decryption in progress...", log: CryptMechanism.log, type: .error) - return (true, true) - } else { - os_log("FileVault is not enabled...", log: CryptMechanism.log, type: .error) - return (false, false) - } - } - - func enableFileVault(_ theSettings : NSDictionary, filepath : String) throws -> Bool { - os_log("Attempting to enable FileVault", log: CryptMechanism.log, type: .default) - let inputPlist = try PropertyListSerialization.data(fromPropertyList: theSettings, - format: PropertyListSerialization.PropertyListFormat.xml, options: 0) - - let inPipe = Pipe.init() - let outPipe = Pipe.init() - let errorPipe = Pipe.init() - - let task = Process.init() - task.launchPath = "/usr/bin/fdesetup" - task.arguments = ["enable", "-outputplist", "-inputplist"] - - // check if we should do an authrestart on enablement - if checkAuthRestart() && !onAPFS(){ - os_log("adding -authrestart flag at index 1 of our task arguments...", log: CryptMechanism.log, type: .default) - task.arguments?.insert("-authrestart", at: 1) - } - - // if there's an IRK, need to add the -keychain argument to keep us from failing. - let instKeyPath = "/Library/Keychains/FileVaultMaster.keychain" - if checkFileExists(path: instKeyPath) { - os_log("Appending -keychain to the end of our task arguments...", log: CryptMechanism.log, type: .default) - task.arguments?.append("-keychain") - } - - os_log("Running /usr/bin/fdesetup %{public}@", log: CryptMechanism.log, type: .default, String(describing: task.arguments)) - - task.standardInput = inPipe - task.standardOutput = outPipe - task.standardError = errorPipe - task.launch() - inPipe.fileHandleForWriting.write(inputPlist) - inPipe.fileHandleForWriting.closeFile() - task.waitUntilExit() - - os_log("Trying to get output data", log: CryptMechanism.log, type: .default) - let outputData = outPipe.fileHandleForReading.readDataToEndOfFile() - outPipe.fileHandleForReading.closeFile() - - let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() - let errorMessage = String(data: errorData, encoding: .utf8) - errorPipe.fileHandleForReading.closeFile() - - if task.terminationStatus != 0 { - let termstatus = String(describing: task.terminationStatus) - os_log("fdesetup terminated with a NON-Zero exit status: %{public}@", log: CryptMechanism.log, type: .error, termstatus) - os_log("fdesetup Standard Error: %{public}@", log: CryptMechanism.log, type: .error, String(describing: errorMessage)) - throw FileVaultError.fdeSetupFailed(retCode: task.terminationStatus) - } - - if outputData.count == 0 { - os_log("Found nothing in output data", log: CryptMechanism.log, type: .error) - throw FileVaultError.outputPlistNull - } - - var format : PropertyListSerialization.PropertyListFormat = PropertyListSerialization.PropertyListFormat.xml - let outputPlist = try PropertyListSerialization.propertyList(from: outputData, - options: PropertyListSerialization.MutabilityOptions(), format: &format) - - if (format == PropertyListSerialization.PropertyListFormat.xml) { - if outputPlist is NSDictionary { - os_log("Attempting to write key to: %{public}@", log: CryptMechanism.log, type: .default, String(describing: filepath)) - _ = (outputPlist as! NSDictionary).write(toFile: filepath, atomically: true) - } - os_log("Successfully wrote key to: %{public}@", log: CryptMechanism.log, type: .default, String(describing: filepath)) - return true - } else { - os_log("rotateRecoveryKey() Error. Format does not equal 'PropertyListSerialization.PropertyListFormat.xml'", log: CryptMechanism.log, type: .error) - throw FileVaultError.outputPlistMalformed - } - } - - func onAPFS() -> Bool { - // checks to see if our boot drive is APFS - let ws = NSWorkspace.shared - - var myDes: NSString? = nil - var myType: NSString? = nil - - ws.getFileSystemInfo(forPath: "/", isRemovable: nil, isWritable: nil, isUnmountable: nil, description: &myDes, type: &myType) - - if myType == "apfs" { - os_log("Machine appears to be APFS", log: CryptMechanism.log, type: .default) - return true - } else { - os_log("Machine is not APFS we appear to be: %{public}@", log: CryptMechanism.log, type: .default, String(describing: myType)) - return false - } - } - - func checkFileExists(path: String) -> Bool { - os_log("Checking to see if %{public}@ exists...", log: Check.log, type: .default, String(describing: path)) - let fm = FileManager.default - if fm.fileExists(atPath: path) { - os_log("%{public}@ exists...", log: Check.log, type: .default, String(describing: path)) - return true - } else { - os_log("%{public}@ does NOT exist...", log: Check.log, type: .default, String(describing: path)) - return false - } - } -} diff --git a/Crypt/Mechanisms/Enablement.swift b/Crypt/Mechanisms/Enablement.swift deleted file mode 100644 index c784faa..0000000 --- a/Crypt/Mechanisms/Enablement.swift +++ /dev/null @@ -1,77 +0,0 @@ -/* - Crypt - - Copyright 2024 The Crypt Project. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - - -import Foundation -import Security -import CoreFoundation -import os.log - -class Enablement: CryptMechanism { - - fileprivate let bundleid = "com.grahamgilbert.crypt" - - private static let log = OSLog(subsystem: "com.grahamgilbert.crypt", category: "Enablement") - // This is the only public function. It will be called from the - // ObjC AuthorizationPlugin class - @objc func run() { - - if self.needsEncryption { - - os_log("Attempting to enable FileVault", log: Enablement.log, type: .default) - - guard let username = self.username - else { allowLogin(); return } - guard let password = self.password - else { allowLogin(); return } - - if getFVEnabled().encrypted && needToRestart() { - // This is a catch for if we are on 10.13+ but on HFS. - os_log("FileVault is enabled already and `fdesetup status` requests a restart", log: Enablement.log, type: .default) - _ = restartMac() - } - let the_settings = NSDictionary.init(dictionary: ["Username" : username, "Password" : password]) - let filepath = CFPreferencesCopyAppValue(Preferences.outputPath as CFString, bundleid as CFString) as? String ?? "/private/var/root/crypt_output.plist" - do { - _ = try enableFileVault(the_settings, filepath: filepath) - _ = restartMac() - } - catch let error as NSError { - os_log("Failed to Enable FileVault %{public}@", log: Enablement.log, type: .error, error.localizedDescription) - allowLogin() - } - } else { - // Allow to login. End of mechanism - os_log("Hint Value not set Allowing Login...", log: Enablement.log, type: .default) - allowLogin() - } - } - - // Restart - fileprivate func restartMac() -> Bool { - // Wait a couple of seconds for everything to finish - os_log("called restartMac()...", log: Enablement.log, type: .default) - sleep(3) - let task = Process(); - os_log("Restarting Mac after enabling FileVault...", log: Enablement.log, type: .default) - task.launchPath = "/sbin/shutdown" - task.arguments = ["-r", "now"] - task.launch() - return true - } -} diff --git a/Crypt/Views/PromptWindowController.h b/Crypt/Views/PromptWindowController.h deleted file mode 100644 index 2202ee9..0000000 --- a/Crypt/Views/PromptWindowController.h +++ /dev/null @@ -1,27 +0,0 @@ -/* - Crypt - - Copyright 2024 The Crypt Project. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -#import -#include -#import "CryptAuthPlugin.h" - -@interface PromptWindowController : NSWindowController - -@property const MechanismRecord *mechanism; - -@end diff --git a/Crypt/Views/PromptWindowController.m b/Crypt/Views/PromptWindowController.m deleted file mode 100644 index fadd2ff..0000000 --- a/Crypt/Views/PromptWindowController.m +++ /dev/null @@ -1,61 +0,0 @@ -/* - Crypt - - Copyright 2024 The Crypt Project. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. -*/ - -#import "PromptWindowController.h" - -@interface PromptWindowController() - -@property NSRect screenRect; -@property (weak) IBOutlet NSView *mainView; -@property (weak) IBOutlet NSView *promptView; -- (IBAction)continueClicked:(id)sender; -@property (weak) IBOutlet NSTextField *windowText; - -@end - -@implementation PromptWindowController - -- (id)init { - self = [super init]; - if (self) { - NSLog(@"Crypt:MechanismInvoke:PromptWindowController:init [+] initWithWindowNibName"); - self = [super initWithWindowNibName:@"PromptWindowController"]; - } - return self; -} - -- (void)awakeFromNib { - NSLog(@"Crypt:MechanismInvoke:PromptWindowController [+] awakeFromNib."); - // Make the window visible at the LoginWindow - // Set the order so the Main Window will - // be on top of the BackdropWindow - [[self window] setCanBecomeVisibleWithoutLogin:TRUE]; - [[self window] setLevel:NSScreenSaverWindowLevel + 1]; - [[self window] orderFrontRegardless]; - [self.mainView addSubview:_promptView]; -}; - -- (void)windowWillClose:(NSNotification *)notification { - [NSApp abortModal]; -} - -- (IBAction)continueClicked:(id)sender { - self.mechanism->fPlugin->fCallbacks->SetResult(_mechanism->fEngine, kAuthorizationResultAllow); - [self close]; -} -@end diff --git a/Crypt/Views/PromptWindowController.swift b/Crypt/Views/PromptWindowController.swift deleted file mode 100644 index b8f19ca..0000000 --- a/Crypt/Views/PromptWindowController.swift +++ /dev/null @@ -1,9 +0,0 @@ -// -// PromptWindowController.swift -// Crypt -// -// Created by Joel Rennich on 7/11/17. -// Copyright © 2021 Graham Gilbert. All rights reserved. -// - -import Foundation diff --git a/Crypt/Views/PromptWindowController.xib b/Crypt/Views/PromptWindowController.xib deleted file mode 100644 index 83f00f4..0000000 --- a/Crypt/Views/PromptWindowController.xib +++ /dev/null @@ -1,87 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Crypt/Views/fv2.png b/Crypt/Views/fv2.png deleted file mode 100644 index c40c5d1..0000000 Binary files a/Crypt/Views/fv2.png and /dev/null differ diff --git a/Filevault.swift b/Filevault.swift new file mode 100644 index 0000000..a842848 --- /dev/null +++ b/Filevault.swift @@ -0,0 +1,331 @@ +/* + Crypt + + Copyright 2025 The Crypt Project. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import Foundation +import Security +import CoreFoundation +import os.log + +// fdesetup Errors +enum FileVaultError: Error { + case fdeSetupFailed(retCode: Int32) + case outputPlistNull + case outputPlistMalformed +} + +// check authrestart capability +func checkAuthRestart() -> Bool { + let outPipe = Pipe.init() + let authRestartCheck = Process.init() + authRestartCheck.launchPath = "/usr/bin/fdesetup" + authRestartCheck.arguments = ["supportsauthrestart"] + authRestartCheck.standardOutput = outPipe + authRestartCheck.launch() + let outputData = outPipe.fileHandleForReading.availableData + let outputString = String(data: outputData, encoding: String.Encoding.utf8) ?? "" + if outputString.range(of: "true") != nil { + os_log("Authrestart capability is 'true', will authrestart as appropriate", log: filevaultLog, type: .default) + return true + } else { + os_log("Authrestart capability is 'false', reverting to standard reboot", log: filevaultLog, type: .default) + return false + } +} + +// Check if some information on filevault whether it's encrypted and if decrypting. +func getFVEnabled() -> (encrypted: Bool, decrypting: Bool) { + os_log("Checking the current status of FileVault..", log: filevaultLog, type: .default) + let task = Process() + task.launchPath = "/usr/bin/fdesetup" + task.arguments = ["status"] + let pipe = Pipe() + task.standardOutput = pipe + task.launch() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + guard let output: String = String(data: data, encoding: String.Encoding.utf8) + else { return (false, false) } + if (output.range(of: "FileVault is On.")) != nil { + os_log("Filevault is On...", log: filevaultLog, type: .default) + return (true, false) + } else if output.range(of: "Decryption in progress:") != nil { + os_log("FileVault Decryption in progress...", log: filevaultLog, type: .error) + return (true, true) + } else { + os_log("FileVault is not enabled...", log: filevaultLog, type: .error) + return (false, false) + } +} + +func enableFileVault(_ theSettings: NSDictionary, filepath: String) throws -> Bool { + os_log("Attempting to enable FileVault", log: filevaultLog, type: .default) + let inputPlist = try PropertyListSerialization.data(fromPropertyList: theSettings, + format: PropertyListSerialization.PropertyListFormat.xml, options: 0) + + let inPipe = Pipe.init() + let outPipe = Pipe.init() + let errorPipe = Pipe.init() + + let task = Process.init() + task.launchPath = "/usr/bin/fdesetup" + task.arguments = ["enable", "-outputplist", "-inputplist"] + +// // check if we should do an authrestart on enablement +// if checkAuthRestart() { +// os_log("adding -authrestart flag at index 1 of our task arguments...", log: filevaultLog, type: .default) +// task.arguments?.insert("-authrestart", at: 1) +// } + + // if there's an IRK, need to add the -keychain argument to keep us from failing. + let instKeyPath = "/Library/Keychains/FileVaultMaster.keychain" + if checkFileExists(path: instKeyPath) { + os_log("Appending -keychain to the end of our task arguments...", log: filevaultLog, type: .default) + task.arguments?.append("-keychain") + } + + os_log("Running /usr/bin/fdesetup %{public}@", log: filevaultLog, type: .default, String(describing: task.arguments)) + + task.standardInput = inPipe + task.standardOutput = outPipe + task.standardError = errorPipe + task.launch() + inPipe.fileHandleForWriting.write(inputPlist) + inPipe.fileHandleForWriting.closeFile() + task.waitUntilExit() + + os_log("Trying to get output data", log: filevaultLog, type: .default) + let outputData = outPipe.fileHandleForReading.readDataToEndOfFile() + outPipe.fileHandleForReading.closeFile() + + let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() + let errorMessage = String(data: errorData, encoding: .utf8) + errorPipe.fileHandleForReading.closeFile() + + if task.terminationStatus != 0 { + let termstatus = String(describing: task.terminationStatus) + os_log("fdesetup terminated with a NON-Zero exit status: %{public}@", log: filevaultLog, type: .error, termstatus) + os_log("fdesetup Standard Error: %{public}@", log: filevaultLog, type: .error, String(describing: errorMessage)) + throw FileVaultError.fdeSetupFailed(retCode: task.terminationStatus) + } + + if outputData.count == 0 { + os_log("Found nothing in output data", log: filevaultLog, type: .error) + throw FileVaultError.outputPlistNull + } + + // try to write the output to disk or the in the keychain depending on preferences set on the machine. + return try handleFileVaultOutput(outputData: outputData, filepath: filepath) +} + +/** + Rotates the FileVault recovery key. + + This function attempts to rotate the FileVault recovery key using the provided settings. + It serializes the settings to a property list, executes the `fdesetup` command to change the recovery key, + and writes the new key to the specified file path if successful. + + - Parameters: + - theSettings: A dictionary containing the settings for the `fdesetup` command. + - filepath: The file path where the new recovery key should be written. + - useKeychain: A boolean indicating whether to use the keychain. + - Returns: A boolean indicating whether the key rotation was successful. + - Throws: An error of type `FileVaultError` if the process fails. + */ +func rotateRecoveryKey(_ theSettings: NSDictionary, filepath: String) throws -> Bool { + os_log("Attempting to Rotate Recovery Key...", log: filevaultLog, type: .default) + + let inputPlist = try PropertyListSerialization.data(fromPropertyList: theSettings, + format: .xml, options: 0) + + let inPipe = Pipe() + let outPipe = Pipe() + let errorPipe = Pipe() + + let task = Process() + task.launchPath = "/usr/bin/fdesetup" + task.arguments = ["changerecovery", "-personal", "-outputplist", "-inputplist"] + task.standardInput = inPipe + task.standardOutput = outPipe + task.standardError = errorPipe + task.launch() + + inPipe.fileHandleForWriting.write(inputPlist) + inPipe.fileHandleForWriting.closeFile() + task.waitUntilExit() + + let errorOut = errorPipe.fileHandleForReading.readDataToEndOfFile() + let errorMessage = String(data: errorOut, encoding: .utf8) + errorPipe.fileHandleForReading.closeFile() + + if task.terminationStatus != 0 { + let termstatus = String(describing: task.terminationStatus) + os_log("Error: fdesetup terminated with a NON-Zero exit status: %{public}@", log: filevaultLog, type: .error, termstatus) + os_log("fdesetup Standard Error: %{public}@", log: filevaultLog, type: .error, String(describing: errorMessage)) + throw FileVaultError.fdeSetupFailed(retCode: task.terminationStatus) + } + + os_log("Trying to get output data", log: filevaultLog, type: .default) + let outputData = outPipe.fileHandleForReading.readDataToEndOfFile() + outPipe.fileHandleForReading.closeFile() + + if outputData.count == 0 { + os_log("Error: Found nothing in output data", log: filevaultLog, type: .error) + throw FileVaultError.outputPlistNull + } + + return try handleFileVaultOutput(outputData: outputData, filepath: filepath) + +} + +func checkFileExists(path: String) -> Bool { + os_log("Checking to see if %{public}@ exists...", log: filevaultLog, type: .default, String(describing: path)) + let fm = FileManager.default + if fm.fileExists(atPath: path) { + os_log("%{public}@ exists...", log: filevaultLog, type: .default, String(describing: path)) + return true + } else { + os_log("%{public}@ does NOT exist...", log: filevaultLog, type: .default, String(describing: path)) + return false + } +} + +func hasRecoveryKey(path: String, useKeychain: Bool) -> Bool { + if !useKeychain { + return checkFileExists(path: path) + } + + let label = "com.grahamgilbert.crypt.recovery" + guard let recoveryKey = getPasswordFromKeychain(label: label) else { + os_log("Recovery Key NOT found in keychain...", log: filevaultLog, type: .default) + return false + } + + os_log("Recovery Key found in keychain...", log: filevaultLog, type: .default) + return true +} + +/// Processes the output from a FileVault operation and handles the storage of recovery information +/// +/// This function takes the output data from a FileVault operation and either stores the recovery key +/// in the system keychain or writes the entire output plist to disk, depending on preferences. +/// +/// - Parameters: +/// - outputData: The data returned from the FileVault operation +/// - filepath: The path where the plist should be written if not using keychain storage +/// +/// - Returns: A boolean indicating whether the operation was successful +/// +/// - Throws: Errors that may occur during plist serialization or file writing operations +private func handleFileVaultOutput(outputData: Data, filepath: String) throws -> Bool { + + var format: PropertyListSerialization.PropertyListFormat = .xml + + // Deserialize the output data to a property list + guard let outputPlist = try? PropertyListSerialization.propertyList( + from: outputData, + options: .mutableContainersAndLeaves, + format: &format + ) else { + os_log("Error: Failed to deserialize output data", log: filevaultLog, type: .error) + return false + } + + // Cast the outputPlist to a dictionary + guard let dictionary = outputPlist as? [String: Any] else { + os_log("Error: Failed to cast the FileVault enablement output to a dictionary", log: filevaultLog, type: .error) + return false + } + + // Access the "RecoveryKey" from the dictionary we can use this to write to the keychain + guard let recoveryKey = dictionary["RecoveryKey"] as? String else { + os_log("Error: Could not find 'RecoveryKey' in the output", log: filevaultLog, type: .error) + return false + } + + // check if we are using the keychain if not we'll write it the the output file + if getPref(key: .StoreRecoveryKeyInKeychain) as! Bool { + let systemKeychainPath = "/Library/Keychains/System.keychain" + var read_apps = getPref(key: .AppsAllowedToReadKey) as! [String] + var change_apps = getPref(key: .AppsAllowedToChangeKey) as! [String] + // we need to append empty strings into the arrays which will add the Authorization Framwork paths into lists so we can read and write the key later on. + read_apps.append("") + change_apps.append("") + let invisible = getPref(key: .InvisibleInKeychain) as! Bool + let label: String = "com.grahamgilbert.crypt.recovery" + guard syncRecoveryKeyToKeychain(label: label, recoveryKey: recoveryKey, keychain: systemKeychainPath, apps: read_apps, owners: change_apps, makeInvisible: invisible) else { + os_log("Error: Failed to sync recovery key to keychain.", log: filevaultLog, type: .error) + return false + } + + // We should clear the LastEscrow pref value so we queue up a sync at first run of checkin. + _ = setPref(key: .LastEscrow, value: Date(timeIntervalSince1970: 0)) + return true + } + + // if we aren't using the keychain write the data to disk. + do { + try (outputPlist as! NSDictionary).write(to: URL(filePath: filepath, directoryHint: .notDirectory)) + os_log("Successfully wrote output plist to %{public}@", log: filevaultLog, type: .default, filepath) + } catch { + os_log("Error writing output plist to disk: %{public}@", log: filevaultLog, type: .error, error.localizedDescription) + return false + } + + return true +} + +private func getUsedKey() -> Bool { + let task = Process() + task.launchPath = "/usr/bin/fdesetup" + task.arguments = ["usingrecoverykey"] + let pipe = Pipe() + task.standardOutput = pipe + task.launch() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + guard let output: String = String(data: data, encoding: String.Encoding.utf8) + else { return false } + return (output.range(of: "true") != nil) +} + +func trim_string(_ the_string: String) -> String { + let output = the_string.trimmingCharacters( + in: CharacterSet.whitespacesAndNewlines) + os_log("Trimming %{public}@ to %{public}@", log: checkLog, type: .default, String(describing: the_string), String(describing: output)) + return output +} + +func getSkipUsers(username: String) -> Bool { + os_log("Checking for any SkipUsers...", log: checkLog, type: .default) + + if username as String == "_mbsetupuser" { + os_log("User is _mbsetupuser... Need to Skip...", log: checkLog, type: .error) + return true + } + + if username as String == "root" { + os_log("User is root... Need to Skip...", log: checkLog, type: .error) + return true + } + if let skipUsers = getPref(key: .SkipUsers) as? [String] { + for user in skipUsers { + if trim_string(user) == username as String { + return true + } + } + } + return false + } diff --git a/Keychain.swift b/Keychain.swift new file mode 100644 index 0000000..0efc81f --- /dev/null +++ b/Keychain.swift @@ -0,0 +1,828 @@ +/* + Crypt + + Copyright 2025 The Crypt Project. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +import Foundation +import Security +import os.log + +extension Data { + + init?(fromHexEncodedString string: String) { + + // Convert 0 ... 9, a ... f, A ...F to their decimal value, + // return nil for all other input characters + func decodeNibble(u: UInt16) -> UInt8? { + switch u { + case 0x30 ... 0x39: + return UInt8(u - 0x30) + case 0x41 ... 0x46: + return UInt8(u - 0x41 + 10) + case 0x61 ... 0x66: + return UInt8(u - 0x61 + 10) + default: + return nil + } + } + + self.init(capacity: string.utf16.count/2) + var even = true + var byte: UInt8 = 0 + for c in string.utf16 { + guard let val = decodeNibble(u: c) else { return nil } + if even { + byte = val << 4 + } else { + byte += val + self.append(byte) + } + even = !even + } + guard even else { return nil } + } + + func hexEncodedString() -> String { + return map { String(format: "%02hhx", $0) }.joined() + } +} + +/// Translates a keychain `OSStatus` error code into a human-readable string description. +/// +/// This function attempts to convert an `OSStatus` error code, typically returned by +/// keychain operations, into a string that describes the error in an understandable format. +/// If the translation fails, it provides a default message with the error code. +/// +/// - Parameter status: The `OSStatus` error code to be translated into a readable format. +/// - Returns: A `String` containing the human-readable description of the error code. +/// If translation is unsuccessful, a default message indicating "Unknown status" is returned. +/// +/// - Note: +/// This function utilizes `SecCopyErrorMessageString` to perform the translation. +/// Logging is implemented to track the error code being translated for debugging purposes. +/// +/// - Throws: +/// No Swift-level errors are thrown. Instead, the function logs the translation attempt +/// and provides a fallback message for unrecognized or non-translatable error codes. +public func translateErrCode(_ status: OSStatus) -> String { + // converts a keychain OSStatus error code into human readable string. + os_log("Attempting to translate Error Code: %{public}@", + log: keychainLog, type: .default, String(describing: status)) + let message = SecCopyErrorMessageString(status, nil) + return (message as? String) ?? "Unknown status: \(status)" +} + +/// Adds a string to the specified keychain with an optional access control and visibility setting. +/// +/// This function attempts to add a string as a generic password to a keychain item identified by +/// a label. It allows for optional invisibility and access control attributes to be specified. +/// +/// - Parameters: +/// - item: The `String` data to be added to the keychain. +/// - label: A `String` that sets the label for the keychain item, used for identification. +/// - keychain: A `String` specifying the path of the keychain where the data should be stored. +/// - isInvisible: A `Bool` determining if the keychain item should be invisible in the interface. Defaults to `false`. +/// - withAccess: An optional `SecAccess` object for defining access control settings. Defaults to `nil` if no specific settings are required. +/// - Returns: A `Bool` indicating whether the operation was successful (`true`) or encountered an error (`false`). +/// +/// - Note: +/// The function invokes `SecItemAdd` to add the string, and includes specific attributes like setting it +/// as invisible and adding custom access controls if provided. It logs operations and any errors, utilizing +/// utilities such as `getSecKeychain` to manage the keychain interactions. +/// +/// - Throws: +/// This function does not throw Swift-level errors but logs messages for any issues encountered during +/// the process, including when it cannot open the keychain or fails to add the item due to error conditions. +func addStringToKeychain(stringToAdd item: String, withLabel label: String, keychain: String, isInvisible: Bool = false, withAccess: SecAccess? = nil) -> Bool { + guard let secKeychain = getSecKeychain(path: keychain) else { + return false + } + + os_log("Attempting to add String to KeyChain with label: %{public}s", log: keychainLog, type: .default, label) + let addition = item.data(using: String.Encoding.utf8)! + var query: [String: Any] = [kSecClass as String: kSecClassGenericPassword, + kSecAttrLabel as String: label, + kSecValueData as String: addition, + kSecAttrComment as String: "FileVault recovery key generated by Crypt. Do NOT Delete!", + kSecAttrDescription as String: "recovery key", + kSecAttrIsInvisible as String: isInvisible, + kSecUseKeychain as String: secKeychain, + kSecAttrService as String: cryptBundleID, + kSecAttrAccount as String: cryptBundleID + ] + + if withAccess != nil { + query.updateValue(withAccess!, forKey: kSecAttrAccess as String) + } + + let status = SecItemAdd(query as CFDictionary, nil) + + if status != errSecSuccess { + os_log("Failed to add String to KeyChain with Error: %{public}s", log: keychainLog, + type: .error, translateErrCode(status)) + return false + } + return true +} + +/// Opens a keychain located at a given file path and returns a reference to it. +/// +/// This function uses the provided path to fetch and open a keychain, returning +/// a `SecKeychain` reference if successful, or `nil` if the operation fails. +/// +/// - Parameter path: A `String` representing the file system path to the keychain file. +/// - Returns: An optional `SecKeychain` reference to the opened keychain, or `nil` if +/// the opening operation fails. +/// +/// - Note: +/// The function uses `SecKeychainOpen` to attempt to open keychain files, employing +/// detailed logging for both successful operations and failures, capturing any error +/// codes that arise during the process. +/// +/// - Throws: +/// The function does not throw Swift-level errors but uses `os_log` for logging and +/// diagnostics, particularly when the keychain cannot be opened or when errors are encountered. +func getSecKeychain(path: String) -> SecKeychain? { + os_log("Fetching keychain at path: [%{public}s], with getSecKeychain.", log: keychainLog, type: .default, path) + + var keychain: SecKeychain? + + let openStatus = SecKeychainOpen(path, &keychain) + + if openStatus != kOSReturnSuccess { + os_log("Failed to open keychain with Error: %{public}s", log: keychainLog, + type: .error, translateErrCode(openStatus)) + return nil + } + + return keychain + +} + +/// Synchronizes a recovery key to the specified keychain with optional configurations for visibility and access control. +/// +/// This function interacts with the macOS Keychain to add or update a recovery key, ensuring that the caller is also +/// added to the list of applications allowed to manage Access Control Lists (ACLs). It performs checks to avoid unnecessary updates and logs relevant details. +/// +/// - Parameters: +/// - label: A unique label for the keychain item, used to identify the recovery key. +/// - recoveryKey: The recovery key string that needs to be synchronized with the keychain. +/// - keychain: Path to the specific keychain where the recovery key will be stored. +/// - apps: A list of file paths to applications that are permitted access to the recovery key. +/// - owners: A list of file paths to applications that are authorized to modify the item’s ACLs in the keychain. +/// - makeInvisible: A Boolean indicating if the keychain item should be made invisible to users. Defaults to `true`. +/// - Returns: A Boolean value indicating the success (`true`) or failure (`false`) of the operation. +/// +/// - Note: +/// This function requires that the Keychain Services framework is properly initialized and accessible within the +/// app. It may fail if the provided keychain path is invalid, access permissions are insufficient, or other +/// keychain operations (such as deletion or addition) are unsuccessful. +/// +/// - Throws: This function does not explicitly throw errors but may log failures via `os_log` for debugging purposes. +/// +/// ### Example: +/// ```swift +/// let success = syncRecoveryKeyToKeychain( +/// label: "com.example.recoveryKey", +/// recoveryKey: "example-recovery-key", +/// keychain: "/Library/Keychains/login.keychain-db", +/// apps: ["/Applications/ExampleApp.app"], +/// owners: ["/Applications/ExampleOwnerApp.app"], +/// makeInvisible: true +/// ) +/// print("Sync successful? \(success)") +/// ``` +func syncRecoveryKeyToKeychain(label: String, recoveryKey: String, keychain: String, apps: [String], owners: [String], makeInvisible: Bool = true) -> Bool { + + os_log("Starting user info sync of item label: [%{public}s] to the keychain: [%{public}s].", log: keychainLog, type: .default, label, keychain) + + // get a SecKeychain reference so we know which keychain to put the info in. + guard let secKeychain = getSecKeychain(path: keychain) else { + return false + } + + var needToSyncRecoveryKey: Bool = true + var needToDeleteAndReaddKey: Bool = false + + // get the current info value so we can check if it is up to date. + if let storedRecoveryKey = getPasswordFromKeychain(label: label, keyChain: secKeychain) { + needToSyncRecoveryKey = false + needToDeleteAndReaddKey = true + + // check to see if the stored key matches our current recovery key. It should almost never match. + if storedRecoveryKey != recoveryKey { + os_log("Stored recovery key does not match our recovery key. Need to update.", log: keychainLog, type: .default) + + needToSyncRecoveryKey = true + } + + if !needToSyncRecoveryKey { + os_log("Stored recovery key matches our recovery key. No need to update.", log: keychainLog, type: .default) + return true + } + } + + // + if needToDeleteAndReaddKey { + os_log("Found stored recovery key. Need to delete and readd.", log: keychainLog, type: .default) + let deleteStatus = deletePasswordByLabel(inKeychain: secKeychain, withLabel: label) + + if deleteStatus != true { + os_log("Failed to delete our user info of item label: [%{public}s] from the keychain: [%{public}s].", log: keychainLog, type: .error, label, keychain) + return false + } + } + + // greb the prompt description from the preferences + let aclDescription = getPref(key: .KeychainUIPromptDescription) as! String + + // create a new access instance, this way we can easily update the ACLs if you generate a new key. + let recoveryKeyAccess = createSecAccessWithAppACLAndOwner(aclOwnerApps: owners, appsWithAccess: apps, aclDescription: aclDescription) + + // add the recovery key to the keychain + let addStringStatus = addStringToKeychain(stringToAdd: recoveryKey, withLabel: label, keychain: keychain, isInvisible: makeInvisible, withAccess: recoveryKeyAccess) + + if addStringStatus != true { + os_log("Failed to add our user info of item label: [%{public}s] to the keychain: [%{public}s].", log: keychainLog, type: .error, label, keychain) + return false + } + + os_log("Successfully added recovery key with label: [%{public}s] to the keychain: [%{public}s].", log: keychainLog, type: .default, label, keychain) + return true + +} + +/// Retrieves a password from the keychain using a specified label. +/// +/// This function searches for a password item in the provided keychain using the given label. +/// If found, it returns the password as a `String`. The search operation includes returning +/// both the attributes and data of the matched item. +/// +/// - Parameters: +/// - label: A `String` representing the label of the password item to search for in the keychain. +/// - keyChain: Optional `SecKeychain` instance for a keychain where the password item is stored. +/// - Returns: An optional `String` containing the password if found and properly decoded, or `nil` if the item is +/// not found or an error occurs. +/// +/// - Note: +/// The function uses `SecItemCopyMatching` to perform the lookup and expects the data to be UTF-8 encoded. +/// Logs are created for both successful and unsuccessful operations, capturing details such as search failures +/// and any errors through error code translation. +/// +/// - Throws: +/// The function does not throw Swift-level errors but logs any issues encountered using `os_log`, which includes +/// scenarios where the password item cannot be located or read from the keychain. +func getPasswordFromKeychain(label: String, keyChain: SecKeychain? = nil) -> String? { + var query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecReturnAttributes: true, + kSecReturnData: true, + kSecAttrLabel: label + ] + + // Only add kSecMatchSearchList if keyChain is provided + if let keyChain = keyChain { + query[kSecMatchSearchList] = [keyChain] + } + + var item: CFTypeRef? + + os_log("Looking for password in keychain for label: [%{public}@].", log: keychainLog, type: .default, label) + let queryStatus = SecItemCopyMatching(query as CFDictionary, &item) + + if queryStatus != errSecSuccess { + let translatedCode = translateErrCode(queryStatus) + os_log("Could not find password in keycahin for label: [%{public}@] with message: [%{public}@]", log: keychainLog, type: .default, label, translatedCode) + } + + os_log("Found password item in keychain with label: [%{public}@], attempting to read password.", log: keychainLog, type: .default, label) + guard let existingItem = item as? [String: Any], + let passwordData = existingItem[kSecValueData as String] as? Data, + let password = String(data: passwordData, encoding: String.Encoding.utf8) + else { + os_log("Error: Failed to get password item in keychain with label: [%{public}@].", log: keychainLog, type: .error, label) + return nil + } + os_log("Was able to read password for item in keychain with label: [%{public}@].", log: keychainLog, type: .default, label) + return password +} + +/// Updates the password for a specified label in a given keychain, with optional access control settings. +/// +/// This function attempts to find a password item in the keychain matching the provided label and updates its +/// stored password. An optional `SecAccess` parameter can be provided to set access controls on the updated +/// item. +/// +/// - Parameters: +/// - label: A `String` representing the label of the keychain item whose password needs to be updated. +/// - password: The new password to be set for the keychain item, given as a `String`. +/// - keychain: The `SecKeychain` instance where the password item is stored. +/// - access: An optional `SecAccess` object specifying the access control settings for the password item; +/// defaults to `nil` if no such settings are to be applied. +/// - Returns: A `Bool` indicating success (`true`) if the password was successfully updated, or failure (`false`) +/// if the operation encountered an error. +/// +/// - Note: +/// This function leverages `SecItemUpdate` to perform the update operation and optionally uses access control +/// settings if provided. Logging is utilized to track operations and capture errors encountered during the +/// update process. +/// +/// - Throws: +/// No Swift-level errors are thrown, but issues are logged using `os_log`, capturing errors with details such as +/// failure in updating the keychain item, including error description through `translateErrCode`. +func updatePasswordForLabel(label: String, password: String, keychain: SecKeychain, access: SecAccess? = nil) -> Bool { + // now we can search for the key using the ref we got back from the import to update the label. + // this appears to prompt on update, need to investigate more. + let addition = password.data(using: String.Encoding.utf8)! + + let searchQ: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrLabel: label, + kSecUseKeychain: keychain + ] + + var updateQuery: [CFString: Any] = [ + kSecValueData: addition + ] + + // if acccess instance is passed in then lets use it to update the item. + if access != nil { + os_log("Adding access control to update query.", log: keychainLog, type: .default) + updateQuery[kSecAttrAccessControl] = access + } + + os_log("Attempting to update password for label %{pubic}@.", log: keychainLog, type: .default, label) + let updateResult = SecItemUpdate(searchQ as CFDictionary, updateQuery as CFDictionary) + + if updateResult != kOSReturnSuccess { + os_log("Failed to update item with error: %{pubic}@", log: keychainLog, type: .error, translateErrCode(updateResult)) + return false + } + + os_log("Updating password for label %{pubic}@ was successful.", log: keychainLog, type: .default, label) + return true +} + +/// Creates a `SecAccess` instance configured with application access control lists (ACLs) and owner permissions. +/// +/// This function generates a `SecAccess` object with a specified description and establishes ACLs for both +/// application access and owner management capabilities. It initially creates a basic access configuration from +/// application paths and then updates it with ACLs for applications permitted to manage access settings. +/// +/// - Parameters: +/// - aclOwnerApps: An array of strings representing file paths to applications that are allowed to alter the ACLs. +/// - appsWithAccess: An array of strings representing file paths to applications that are granted access under the ACL. +/// - aclDescription: The name of the keychain item as it should appear in security dialogs, such as when an untrusted app tries to gain access to the item and the system prompts the user for permission. Use a name that gives users enough information to make a decision about this item. If you only store one item, a simple description like "Server password" might be sufficient. If you store many similar items, you might need to be more specific. +/// - Returns: An optional `SecAccess` object representing the access configuration with ACLs and owner specifications, or +/// `nil` if the creation or update process fails. +/// +/// - Note: +/// This function utilizes `createAccess` to establish the initial access structure. It updates the access using +/// `bulkUpdateACLForExistingAccess`, configuring which applications have permission to change ACLs. Errors occurring +/// during these processes are logged. +/// +/// - Throws: +/// This function does not throw Swift-level errors but logs any issues encountered using `os_log`, including failing +/// to create or update the `SecAccess` instance with detailed error descriptions. +func createSecAccessWithAppACLAndOwner(aclOwnerApps: [String], appsWithAccess: [String], aclDescription: String) -> SecAccess? { + // creates a SecAccess Instance with Owner and App acls and the corresponding description. + os_log("Creating SecAccess with description: %{public}@.", log: keychainLog, type: .default, aclDescription) + guard let recoveryKeySecAccess = createAccess(withPaths: appsWithAccess, description: aclDescription) else { + os_log("Failed to create SecAccess.", log: keychainLog, type: .error) + return nil + } + + // create a dictionary of ACLs we want to update + let aclData: [String: [String]?] = [ + "kSecACLAuthorizationChangeACL": aclOwnerApps + ] + + // update the access with the apps that can change ACLs + os_log("Attempting to update ACLs for %{public}@.", log: keychainLog, type: .default, aclDescription) + guard let updatedRecoveryKeyAccess = bulkUpdateACLForExistingAccess(access: recoveryKeySecAccess, aclData: aclData, acldescription: aclDescription) else { + os_log("Failed to update ACL access for %{public}@.", log: keychainLog, type: .error, aclDescription) + return nil + } + return updatedRecoveryKeyAccess +} + +/// Creates a `SecAccess` instance using a description and a list of application paths. +/// +/// This function generates a list of trusted applications from the provided paths and +/// constructs a `SecAccess` object that uses these applications to define access controls. +/// +/// - Parameters: +/// - paths: An array of strings representing file paths to trusted applications. If empty, +/// the trusted applications list is set to `nil`, allowing access to all. +/// - description: The name of the keychain item as it should appear in security dialogs, such as when an untrusted app tries to gain access to the item and the system prompts the user for permission. Use a name that gives users enough information to make a decision about this item. If you only store one item, a simple description like "Server password" might be sufficient. If you store many similar items, you might need to be more specific. +/// - Returns: An optional `SecAccess` object representing the newly created access configuration, or +/// `nil` if the creation process fails. +/// +/// - Note: +/// The function relies on the `SecAccessCreate` API to create access instances. If any error +/// occurs during the creation process, a log entry is recorded with the error code, and `nil` +/// is returned to indicate the failure. +/// +/// - Throws: +/// This function does not throw Swift-level errors, but issues are logged using `os_log`, +/// including any encountered error codes when the `SecAccess` instance cannot be created. +func createAccess(withPaths paths: [String], description: String) -> SecAccess? { + os_log("Called createAccess.", log: keychainLog, type: .default) + var access: SecAccess? + + var trustedApplications: CFArray? + // if the paths array we got is empty we will set the the access to nil. Otherwise we'll create the trusted app list. + if paths.isEmpty { + trustedApplications = nil + } else { + trustedApplications = getTrustedApplicationsFromPaths(appPaths: paths) as CFArray + } + + os_log("Attempting to create a SecAccess instance with our app array.", log: keychainLog, type: .error) + // create the access instance with the description and trusted applications + let createStatus = SecAccessCreate(description as CFString, trustedApplications, &access) + + if createStatus != kOSReturnSuccess { + os_log("Failed to create SecAccess in createAccess with error: %{public}@", log: keychainLog, type: .error, translateErrCode(createStatus)) + return nil + } + + return access +} + +/// Creates an array of `SecTrustedApplication` instances from an array of application path strings. +/// +/// This function iterates through a list of application file paths, creating a corresponding +/// `SecTrustedApplication` object for each valid path. If a path is invalid or the creation fails, the +/// function logs a warning and skips the entry. +/// +/// - Parameters: +/// - appPaths: An array of strings representing file paths to applications. Provide an empty item in the array +/// to indicate that the calling application should be trusted. +/// - Returns: An array of `SecTrustedApplication` objects created from the provided application paths. +/// Paths that could not be processed or resulted in errors are excluded from the result. +/// +/// - Note: +/// Each `SecTrustedApplication` object is created using the `SecTrustedApplicationCreateFromPath` function. +/// Errors (e.g., invalid paths or inability to create trusted applications) are logged, and the associated +/// application is skipped during processing. +/// +/// - Throws: +/// No Swift-level errors are thrown, but failures are logged using `os_log`. Each skipped entry is explicitly logged +/// with details on why the `SecTrustedApplication` was not created. +func getTrustedApplicationsFromPaths(appPaths: [String]) -> [SecTrustedApplication] { + // creates an array of SecTrustedApplication from an array of application path strings + + var trustedAppPaths: [SecTrustedApplication] = [] + + for app in appPaths { + os_log("Creating SecTrustedApplication for [%{public}@]", log: keychainLog, type: .default, app) + + var trustedApplication: SecTrustedApplication? + + let pathToUse: String? = app.isEmpty ? nil : app + + let createResult = SecTrustedApplicationCreateFromPath(pathToUse, &trustedApplication) + + if createResult != kOSReturnSuccess { + os_log("Warning: Failed to create Trusted App for [%{public}@].", log: keychainLog, type: .default, app) + continue + } + + if trustedApplication == nil { + os_log("Warning: Failed to create Trusted App for [%{public}@], received a nil value.", log: keychainLog, type: .default, app) + continue + } + + os_log("Successfully created trusted app for [%{public}@]", log: keychainLog, type: .default, app) + + trustedAppPaths.append(trustedApplication!) + + } + + return trustedAppPaths +} + +/// Updates the Access Control List (ACL) for an existing access instance with new application paths and descriptions. +/// +/// This function iterates over provided ACL data, appending or modifying the ACL entries of a given `SecAccess` +/// instance. It expects that the authorizations to be updated already exist within the access instance, and updates +/// the descriptions and application lists to match the provided parameters. +/// +/// - Parameters: +/// - access: The `SecAccess` instance representing the existing access control to be updated. +/// - aclData: A dictionary mapping authorization types (as `String`) to arrays of application paths (`[String]?`). +/// The application paths should be those that need access under the specified authorization. +/// - acldescription: A description for the ACL changes, typically providing context for the modifications. +/// - Returns: The potentially modified `SecAccess` instance, or `nil` if an error occurred during processing. +/// +/// - Note: +/// This function assumes that the required authorizations are already present in the `SecAccess` instance. +/// If an authorization does not exist, the function will log the failure and return `nil`. The function +/// also handles updating the special "ACLAuthorizationPartitionID" differently by modifying the description directly. +/// +/// - Throws: +/// Errors are not directly thrown in Swift but operations are logged using `os_log`. This includes handling +/// failures when copying or setting ACL contents and logging any encountered error codes. +func bulkUpdateACLForExistingAccess(access: SecAccess, aclData: [String: [String]?], acldescription: String) -> SecAccess? { + // takes an existing access instance, and adds the given app paths with the provided description to the provided authorization attribute. + + // TODO: this assumes that the authorization acl you want to update already exists in the access instance, it will fail if it does not. + + for (authorization, apps) in aclData { + // key is of type CFString and value is of type [String]? + os_log("Called updateACLForExistingAccess", log: keychainLog, type: .default) + + if let unwrappedApps = apps { + os_log("Unwrapped apps and got: %{public}@", log: keychainLog, type: .default, unwrappedApps) + } + + guard let authorizationConstant = getACLAuthorizationConstant(from: authorization) else { + os_log("Failed to get ACL Constant. Doesn't exist in our list.", log: keychainLog, type: .error) + return nil + } + + guard let ACLList = SecAccessCopyMatchingACLList(access, authorizationConstant) else { + os_log("Failed to copy Matching ACL List", log: keychainLog, type: .error) + return nil + } + + os_log("Getting short auth form for %{public}@", log: keychainLog, type: .default, authorization) + let shortAuth = authorization.replacingOccurrences(of: "kSec", with: "") + + os_log("Got short auth form of %{public}@", log: keychainLog, type: .default, shortAuth) + let acls = ACLList as! [SecACL] + + var existingApplicationList: CFArray? + var existingDescription: CFString? + var promptSelector = SecKeychainPromptSelector() + + os_log("Looping through Existing ACLs", log: keychainLog, type: .default) + for acl in acls { + + let authArray = SecACLCopyAuthorizations(acl) + + os_log("Checking if: %{public}@ is in %{public}@.", log: keychainLog, type: .default, shortAuth, authArray as! [String]) + + if !(authArray as! [String]).contains(shortAuth) {continue} + + let copyContentsResult = SecACLCopyContents(acl, &existingApplicationList, &existingDescription, &promptSelector) + + if copyContentsResult != kOSReturnSuccess { + os_log("Failed to Copy ACL Contents with error: %{public}@.", log: keychainLog, type: .error, translateErrCode(copyContentsResult)) + } + + var description = acldescription as CFString + var appsToUpdate: CFArray? + + // if our apps are non-nil then will go ahead and update our appsToUpdate accordingly + if let appList = apps { + // to update the ACLAuthorizationPartitionID we need to update the description, we'll handle that differently + if (authArray as! [String]).contains("ACLAuthorizationPartitionID") { + os_log("Need to update ACLAuthorizationPartitionID.", log: keychainLog, type: .default) + description = updatePartitionIDDescription(existingDescription: existingDescription!, teamIDs: appList) + appsToUpdate = nil + } else { + os_log("Attempting to get Trusted Applications for: %{public}@", log: keychainLog, type: .default, appList) + // convert our app paths to TrustedApplicationPaths to add to the keychain item. + let trustedApps = getTrustedApplicationsFromPaths(appPaths: appList) + + // take our existing application list already on the item and add our new apps + appsToUpdate = updateApplicationList(existing: existingApplicationList, applications: trustedApps) + } + // if we got here we passed nil to apps which means we want the ACLs to be for everyone. + } else { + appsToUpdate = nil + } + + // set the changed apps back on the ACL. + let setContentsStatus = SecACLSetContents(acl, appsToUpdate, description as CFString, promptSelector) + + if setContentsStatus != kOSReturnSuccess { + os_log("Failed to set ACL Contents with error: %{public}@", log: keychainLog, type: .error, translateErrCode(setContentsStatus)) + return nil + } + } + } + + // return our updated access instance. + return access +} + +/// Retrieves the ACL authorization constant corresponding to a configuration string representation. +/// +/// This function maps a given configuration string, representing an ACL authorization, +/// to its corresponding Core Foundation string constant used in keychain access control lists. +/// It searches through a predefined dictionary of mappings between string representations and CFString constants. +/// +/// - Parameter configString: A `String` representing the name of the ACL authorization. +/// - Returns: An optional `CFString` that corresponds to the provided configuration string. +/// Returns `nil` if the string does not match any known ACL authorization constant. +/// +/// - Note: +/// The function uses a dictionary to map configuration strings to their respective `CFString` constants. +/// It logs the attempt to retrieve the ACL constant using `os_log`. +/// +/// - Throws: +/// This function does not throw errors but returns `nil` if no corresponding constant is found +/// for the given configuration string. +func getACLAuthorizationConstant(from configString: String) -> CFString? { + os_log("Called getACLAuthorizationConstant for: %{public}@", log: keychainLog, type: .default, configString) + let aclAuthorizationMapping: [String: CFString] = [ + "kSecACLAuthorizationAny": kSecACLAuthorizationAny, + "kSecACLAuthorizationLogin": kSecACLAuthorizationLogin, + "kSecACLAuthorizationGenKey": kSecACLAuthorizationGenKey, + "kSecACLAuthorizationDelete": kSecACLAuthorizationDelete, + "kSecACLAuthorizationExportWrapped": kSecACLAuthorizationExportWrapped, + "kSecACLAuthorizationExportClear": kSecACLAuthorizationExportClear, + "kSecACLAuthorizationImportWrapped": kSecACLAuthorizationImportWrapped, + "kSecACLAuthorizationImportClear": kSecACLAuthorizationImportClear, + "kSecACLAuthorizationSign": kSecACLAuthorizationSign, + "kSecACLAuthorizationEncrypt": kSecACLAuthorizationEncrypt, + "kSecACLAuthorizationDecrypt": kSecACLAuthorizationDecrypt, + "kSecACLAuthorizationMAC": kSecACLAuthorizationMAC, + "kSecACLAuthorizationDerive": kSecACLAuthorizationDerive, + "kSecACLAuthorizationKeychainCreate": kSecACLAuthorizationKeychainCreate, + "kSecACLAuthorizationKeychainDelete": kSecACLAuthorizationKeychainDelete, + "kSecACLAuthorizationKeychainItemRead": kSecACLAuthorizationKeychainItemRead, + "kSecACLAuthorizationKeychainItemInsert": kSecACLAuthorizationKeychainItemInsert, + "kSecACLAuthorizationKeychainItemModify": kSecACLAuthorizationKeychainItemModify, + "kSecACLAuthorizationKeychainItemDelete": kSecACLAuthorizationKeychainItemDelete, + "kSecACLAuthorizationChangeACL": kSecACLAuthorizationChangeACL, + "kSecACLAuthorizationChangeOwner": kSecACLAuthorizationChangeOwner, + "kSecACLAuthorizationIntegrity": kSecACLAuthorizationIntegrity, + "kSecACLAuthorizationPartitionID": kSecACLAuthorizationPartitionID, + "kSecClassKey": kSecClassKey, + "kSecClassGenericPassword": kSecClassGenericPassword, + "kSecClassCertificate": kSecClassCertificate, + "kSecClassInternetPassword": kSecClassInternetPassword, + "kSecClassIdentity": kSecClassIdentity + ] + + return aclAuthorizationMapping[configString] +} + +/// Updates a list of trusted applications by appending new applications that are not already included. +/// +/// This method takes an existing list of trusted applications and a new list of applications to potentially add. It checks each application to ensure it is not already present based on its data representation, and adds it if necessary. +/// +/// - Parameters: +/// - existing: An optional `CFArray` containing existing `SecTrustedApplication` instances. If `nil`, a new array is started. +/// - applications: An array of `SecTrustedApplication` instances intended for addition to the trusted list. +/// - Returns: A `CFArray` containing the updated list of `SecTrustedApplication` instances, or `nil` if errors occurred during processing. +/// +/// - Note: +/// The function converts `SecTrustedApplication` objects to `Data` for comparison. It relies on the Keychain Services API, particularly `SecTrustedApplicationCopyData`, to perform these operations. Logging is used to track errors and operational flow. +/// +/// - Throws: +/// Errors are not thrown to the caller but are logged using `os_log` in case data copying fails or other errors are encountered. +private func updateApplicationList(existing: CFArray?, applications: [ SecTrustedApplication ]) -> CFArray? { + + var applicationListArray = existing == nil ? [ SecTrustedApplication ]() : existing as! [ SecTrustedApplication ] + + let existingApplicationData = applicationListArray.map { (item) -> Data? in + var data: CFData? + + let appCopyDataStatus = SecTrustedApplicationCopyData(item, &data) + + if appCopyDataStatus != kOSReturnSuccess { + os_log("Couldn't get App Data Status.", log: keychainLog, type: .error) + return nil + } + return data! as Data + } + + for applicationToAdd in applications { + var data: CFData? + + let appCopyDataStatus = SecTrustedApplicationCopyData(applicationToAdd, &data) + + if appCopyDataStatus != kOSReturnSuccess { + os_log("Couldn't get data from SecTrustedApplication.", log: keychainLog, type: .error) + return nil + } + + let castData = data! as Data + + if existingApplicationData.contains(castData) == false { + applicationListArray.append(applicationToAdd) + } + } + + return applicationListArray as CFArray +} + +/// Updates the partition ID description by adding new team IDs, if they are not already present. +/// +/// This function takes an existing partition ID description and a list of team IDs. It serializes the +/// existing description, checks if each of the provided team IDs is included, and adds any that are not. +/// It then serializes the updated list back into the appropriate format. +/// +/// - Parameters: +/// - existingDescription: A `CFString` representing the current partition ID description in a hex-encoded string format. +/// - teamIDs: An array of `String` instances representing the team IDs to be added to the partition list if not already present. +/// - Returns: A `CFString` that represents the updated partition ID description in hex-encoded string format. +/// If no updates were necessary, the original description is returned. +/// +/// - Note: +/// This function relies on `PropertyListSerialization` to handle the conversion of descriptions to and from a property list format. +/// It logs operations and issues encountered, such as deserialization failures or if no updates were necessary. +/// +/// - Throws: +/// This function may internally handle errors related to property list serialization and log them, but it does not throw errors at the Swift level. +func updatePartitionIDDescription(existingDescription: CFString, teamIDs: [String]) -> CFString { + // generates a plist hexencodedstring for the partionID description. + os_log("Called updatePartitionIDDescription.", log: keychainLog, type: .default) + let rawData = Data.init(fromHexEncodedString: existingDescription as String) + var format: PropertyListSerialization.PropertyListFormat = .xml + + var propertyListObject = [String: [String]]() + + do { + propertyListObject = try PropertyListSerialization.propertyList(from: rawData!, options: [], format: &format) as! [String: [String]] + } catch { + os_log("No teamid in ACLAuthorizationPartitionID.", log: keychainLog, type: .error) + } + + var existingParitions = propertyListObject["Partitions"] + + os_log("Existing TeamID Description: [%{public}@]", log: keychainLog, type: .default, existingParitions!) + + var updatedIDs: Bool = false + + for id in teamIDs { + os_log("Processing TeamID: [%{public}@].", log: keychainLog, type: .default, id) + if existingParitions?.contains(id) == false { + os_log("Did not find TeamID [%{public}@] in existing IDs, adding...", log: keychainLog, type: .default, id) + existingParitions?.append(id) + updatedIDs = true + } + } + + if updatedIDs == false { + os_log("Did not update the TeamIDs returning original description.", log: keychainLog, type: .default) + return existingDescription + } + + propertyListObject["Partitions"] = existingParitions + + os_log("Need to serialize plist data of %{public}@.", log: keychainLog, type: .default, propertyListObject) + + // now serialize it back into a plist + + let xmlObject = try? PropertyListSerialization.data(fromPropertyList: propertyListObject as Any, format: format, options: 0) + + return xmlObject!.hexEncodedString() as CFString + +} + +/// Deletes a password from the specified keychain that matches the given label. +/// +/// This function searches the provided keychain for a generic password item +/// with the specified label and deletes it if it exists. Logs are generated to +/// provide insights into the status of the operation. +/// +/// - Parameters: +/// - inKeychain: A reference to the `SecKeychain` where the password item is stored. +/// - withLabel: The label of the password item to delete. This serves as a unique identifier +/// for the item within the keychain. +/// - Returns: A Boolean value indicating whether the password was successfully deleted (`true`) +/// or not (`false`). +/// +/// - Note: +/// This method relies on the Keychain Services API. It may fail if the label does not exist +/// in the specified keychain or if the application lacks the necessary permissions. +/// +/// - Throws: +/// This function does not throw Swift-level errors but logs failures via `os_log`, +/// including the translated error code for debugging purposes. +func deletePasswordByLabel(inKeychain: SecKeychain, withLabel: String) -> Bool { + os_log("Called deletePasswordByLabel with label: %{public}@", log: keychainLog, type: .info, withLabel) + let searchQ: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecUseKeychain: inKeychain, + kSecAttrLabel: withLabel + ] + os_log("Attempting to delete password with label: %{public}@", log: keychainLog, type: .info, withLabel) + let status = SecItemDelete(searchQ as CFDictionary) + + if status != errSecSuccess { + os_log("Failed to delete password with label: [%{public}@] with the Error: %{public}@", log: keychainLog, type: .error, withLabel, translateErrCode(status)) + return false + } + + os_log("Successfully deleted password with label: %{public}@", log: keychainLog, type: .info, withLabel) + return true +} diff --git a/Logging.swift b/Logging.swift new file mode 100644 index 0000000..b3b2df4 --- /dev/null +++ b/Logging.swift @@ -0,0 +1,25 @@ +/* + Crypt + + Copyright 2025 The Crypt Project. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ +import os.log + +let keychainLog = OSLog(subsystem: cryptBundleID, category: "Keychain") +let filevaultLog = OSLog(subsystem: cryptBundleID, category: "Filevault") +let prefLog = OSLog(subsystem: cryptBundleID, category: "Preferences") +let enablementLog = OSLog(subsystem: cryptBundleID, category: "Enablement") +let coreLog = OSLog(subsystem: cryptBundleID, category: "Core") +let checkLog = OSLog(subsystem: cryptBundleID, category: "Check") diff --git a/Makefile b/Makefile index 0e48fa2..8b51b01 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ include /usr/local/share/luggage/luggage.make include config.mk USE_PKGBUILD=1 -PB_EXTRA_ARGS+= --info "./Package/PackageInfo" --sign "${DEV_INSTALL_CERT}" +PB_EXTRA_ARGS+= --info "./Package/PackageInfo" TITLE=Crypt GITVERSION=$(shell ./Package/build_no.sh) BUNDLE_VERSION=$(shell /usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "Crypt/Info.plist") @@ -17,6 +17,7 @@ PAYLOAD=\ .PHONY: coverage + ################################################# ## Why is all the bazel stuff commented out? It seems to have issues with Cgo. gazelle: @@ -37,7 +38,8 @@ coverage: ./tools/coverage.sh build: check_variables clean-crypt build_binary - xcodebuild -project Crypt.xcodeproj -configuration Release + xcodebuild -project Crypt.xcodeproj -configuration Release -scheme Crypt -derivedDataPath ./build OTHER_CODE_SIGN_FLAGS="--timestamp" CODE_SIGN_IDENTITY="${DEV_APP_CERT}" + clean-crypt: @sudo rm -rf build @@ -48,9 +50,7 @@ pack-plugin: build l_private_etc @sudo mkdir -p ${WORK_D}/private/etc/newsyslog.d @sudo ${CP} Package/newsyslog.d/crypt.conf ${WORK_D}/private/etc/newsyslog.d/crypt.conf @sudo mkdir -p ${WORK_D}/Library/Security/SecurityAgentPlugins - @sudo ${CP} -R build/Release/Crypt.bundle ${WORK_D}/Library/Security/SecurityAgentPlugins/Crypt.bundle - @sudo codesign --timestamp --force --deep -s "${DEV_APP_CERT}" ${WORK_D}/Library/Security/SecurityAgentPlugins/Crypt.bundle/Contents/Frameworks/* - @sudo codesign --timestamp --force --deep -s "${DEV_APP_CERT}" ${WORK_D}/Library/Security/SecurityAgentPlugins/Crypt.bundle/Contents/MacOS/* + @sudo ${CP} -R build/Build/Products/Release/Crypt.bundle ${WORK_D}/Library/Security/SecurityAgentPlugins/Crypt.bundle pack-scripts: @sudo ${INSTALL} -o root -g wheel -m 755 Package/postinstall ${SCRIPT_D} @@ -60,28 +60,28 @@ build_binary: # bazel build --platforms=@io_bazel_rules_go//go/toolchain:darwin_amd64 //:cmd:crypt-amd # bazel build --platforms=@io_bazel_rules_go//go/toolchain:darwin_arm //cmd:crypt-arm # tools/bazel_to_builddir.sh - CGO_ENABLED=1 CC=/opt/homebrew/opt/llvm/bin/clang CXX=/opt/homebrew/opt/llvm/bin/clang++ GOOS=darwin GOARCH=arm64 go build -ldflags "-X main.version=${BUNDLE_VERSION}" -o build/checkin.arm64 cmd/main.go - CGO_ENABLED=1 CC=/opt/homebrew/opt/llvm/bin/clang CXX=/opt/homebrew/opt/llvm/bin/clang++ GOOS=darwin GOARCH=amd64 go build -ldflags "-X main.version=${BUNDLE_VERSION}" -o build/checkin.amd64 cmd/main.go + MACOSX_DEPLOYMENT_TARGET=13.0 CGO_ENABLED=1 CC=/opt/homebrew/opt/llvm/bin/clang CXX=/opt/homebrew/opt/llvm/bin/clang++ GOOS=darwin GOARCH=arm64 go build -ldflags "-X main.version=${BUNDLE_VERSION}" -o build/checkin.arm64 cmd/main.go + MACOSX_DEPLOYMENT_TARGET=13.0 CGO_ENABLED=1 CC=/opt/homebrew/opt/llvm/bin/clang CXX=/opt/homebrew/opt/llvm/bin/clang++ GOOS=darwin GOARCH=amd64 go build -ldflags "-X main.version=${BUNDLE_VERSION}" -o build/checkin.amd64 cmd/main.go /usr/bin/lipo -create -output build/checkin build/checkin.arm64 build/checkin.amd64 /bin/rm build/checkin.arm64 /bin/rm build/checkin.amd64 @sudo chown root:wheel build/checkin @sudo chmod 755 build/checkin - + sign_binary: build_binary - @sudo codesign --timestamp --force --deep -s "${DEV_APP_CERT}" build/checkin + codesign --timestamp --force --deep -s "${DEV_APP_CERT}" build/checkin pack-checkin: l_Library l_Library_LaunchDaemons build_binary sign_binary @sudo mkdir -p ${WORK_D}/Library/Crypt @sudo ${CP} build/checkin ${WORK_D}/Library/Crypt/checkin @sudo chown -R root:wheel ${WORK_D}/Library/Crypt @sudo chmod 755 ${WORK_D}/Library/Crypt/checkin + @sudo chown -R wesw:wheel ${WORK_D}/payload @sudo ${INSTALL} -m 644 -g wheel -o root Package/com.grahamgilbert.crypt.plist ${WORK_D}/Library/LaunchDaemons dist: pkg @sudo rm -f Distribution - python3 generate_dist.py @sudo productbuild --distribution Distribution Crypt-${BUNDLE_VERSION}.pkg @sudo rm -f Crypt.pkg @sudo rm -f Distribution @@ -90,16 +90,16 @@ notarize: @./notarize.sh "${APPLE_ACC_USER}" "${APPLE_ACC_PWD}" "./Crypt.pkg" remove-xattrs: - @sudo xattr -rd com.dropbox.attributes ${WORK_D} - @sudo xattr -rd com.dropbox.internal ${WORK_D} - @sudo xattr -rd com.apple.ResourceFork ${WORK_D} - @sudo xattr -rd com.apple.FinderInfo ${WORK_D} - @sudo xattr -rd com.apple.metadata:_kMDItemUserTags ${WORK_D} - @sudo xattr -rd com.apple.metadata:kMDItemFinderComment ${WORK_D} - @sudo xattr -rd com.apple.metadata:kMDItemOMUserTagTime ${WORK_D} - @sudo xattr -rd com.apple.metadata:kMDItemOMUserTags ${WORK_D} - @sudo xattr -rd com.apple.metadata:kMDItemStarRating ${WORK_D} - @sudo xattr -rd com.dropbox.ignored ${WORK_D} + @sudo /usr/bin/xattr -rd com.dropbox.attributes ${WORK_D} + @sudo /usr/bin/xattr -rd com.dropbox.internal ${WORK_D} + @sudo /usr/bin/xattr -rd com.apple.ResourceFork ${WORK_D} + @sudo /usr/bin/xattr -rd com.apple.FinderInfo ${WORK_D} + @sudo /usr/bin/xattr -rd com.apple.metadata:_kMDItemUserTags ${WORK_D} + @sudo /usr/bin/xattr -rd com.apple.metadata:kMDItemFinderComment ${WORK_D} + @sudo /usr/bin/xattr -rd com.apple.metadata:kMDItemOMUserTagTime ${WORK_D} + @sudo /usr/bin/xattr -rd com.apple.metadata:kMDItemOMUserTags ${WORK_D} + @sudo /usr/bin/xattr -rd com.apple.metadata:kMDItemStarRating ${WORK_D} + @sudo /usr/bin/xattr -rd com.dropbox.ignored ${WORK_D} check_variables: ifndef DEV_INSTALL_CERT @@ -116,4 +116,4 @@ $(error "APPLE_ACC_PWD" is not set) endif ifeq ("$(wildcard $(CLANG_DIR))","") $(error The directory $(CLANG_DIR) does not exist) -endif \ No newline at end of file +endif diff --git a/Package/notarize b/Package/notarize new file mode 100755 index 0000000..b9ea6d1 --- /dev/null +++ b/Package/notarize @@ -0,0 +1,64 @@ +#!/bin/zsh +# encoding: utf-8 + +# Borrowed with love from https://github.com/munki/munki/pull/986/files +# Big thanks to https://github.com/lifeunexpected + +# Tip: if you get “You must first sign the relevant contracts online. (1048)” error +# Go to Apple.developer.com and sign in with the account you are trying to notarize the app with and agree to the updated license agreement. + +BUNDLE_ID="com.grahamgilbert.Crypt" +BUNDLE_PKG=$3 + +if [[ "$1" == "" ]]; then + echo "Couldn't find a 'Apple Developer account e-mail' as argument 1" + exit -1 +else + AppleAcc=$1 +fi +if [[ "$2" == "" ]]; then + echo "Couldn't find an 'Apple Developer app-specific password' as argument 2" + echo "More info at https://support.apple.com/en-us/HT204397" + exit -1 +else + AppleAccPwd=$2 +fi + +# create temporary files +NOTARIZE_APP_LOG=$(mktemp -t notarize-app) +NOTARIZE_INFO_LOG=$(mktemp -t notarize-info) + +# delete temporary files on exit +function finish { + rm "$NOTARIZE_APP_LOG" "$NOTARIZE_INFO_LOG" +} +trap finish EXIT + +# submit app for notarization +echo "Submitting App $BUNDLE_PKG for Notarization." +if ! xcrun altool --notarize-app --primary-bundle-id "$BUNDLE_ID" --username "$AppleAcc" --password "$AppleAccPwd" -f "$BUNDLE_PKG" > "$NOTARIZE_APP_LOG" 2>&1; then + cat "$NOTARIZE_APP_LOG" 1>&2 + exit 1 +fi + +cat "$NOTARIZE_APP_LOG" +RequestUUID=$(awk -F ' = ' '/RequestUUID/ {print $2}' "$NOTARIZE_APP_LOG") + +# check status periodically +while sleep 30 && date; do +echo "Waiting on Apple to approve the notarization so it can be stapled. This can take a few minutes or more. Script auto checks every 30 sec" + # check notarization status + + if ! xcrun altool --notarization-info "$RequestUUID" --username "$AppleAcc" --password "$AppleAccPwd" > "$NOTARIZE_INFO_LOG" 2>&1; then + cat "$NOTARIZE_INFO_LOG" 1>&2 + exit 1 + fi + cat "$NOTARIZE_INFO_LOG" + + # once notarization is complete, run stapler and exit + if ! grep -q "Status: in progress" "$NOTARIZE_INFO_LOG"; then + xcrun stapler staple "$BUNDLE_PKG" + exit $? + fi + +done diff --git a/Preferences.swift b/Preferences.swift index 40994f9..b3b688a 100644 --- a/Preferences.swift +++ b/Preferences.swift @@ -1,21 +1,130 @@ -// -// Preferences.swift -// Crypt -// -// Created by Joel Rennich on 7/11/17. -// Copyright © 2021 Graham Gilbert. All rights reserved. -// - +/* + Crypt + + Copyright 2025 The Crypt Project. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ import Foundation +import os.log + +let cryptBundleID = "com.grahamgilbert.crypt" + +enum Preference: String { + case AppsAllowedToChangeKey + case AppsAllowedToReadKey + case GenerateNewKey + case InvisibleInKeychain + case KeychainUIPromptDescription + case LastEscrow + case OutputPath + case RemovePlist + case RotatedKey + case RotateUsedKey + case ServerURL + case SkipUsers + case StoreRecoveryKeyInKeychain + case ValidateKey + + // Default preferences as a computed property + static var defaultPreferences: [Preference: Any] { + return [ + .AppsAllowedToChangeKey: [], + .AppsAllowedToReadKey: ["/Library/Crypt/checkin"], + .GenerateNewKey: false, + .InvisibleInKeychain: false, + .KeychainUIPromptDescription: "Crypt FileVault Recovery Key", + .LastEscrow: Date(timeIntervalSince1970: 0), + .OutputPath: "/var/root/crypt_output.plist", + .RemovePlist: true, + .RotateUsedKey: true, + .RotatedKey: false, + .SkipUsers: [], + .StoreRecoveryKeyInKeychain: true, + .ValidateKey: true + ] + } +} + +/** + Retrieves a preference value. + + This function first attempts to retrieve the value from the application preferences. + If no value is found, it checks if the key exists in the default preferences dictionary. + + - Parameter key: The preference key to retrieve the value for. + - Returns: The preference value, or nil if the key does not exist in either the application or default preferences. + */ +func getPref(key: Preference) -> Any? { + + os_log("Attempting to retrieve value for key: %{public}@", log: prefLog, type: .info, key.rawValue) + + // Get the value from the application preferences + if let value = CFPreferencesCopyAppValue(key.rawValue as CFString, cryptBundleID as CFString) { + os_log("Value found in application preferences for key: %{public}@", log: prefLog, type: .info, key.rawValue) + return value + } + + // If value is nil, check if the key exists in the default preferences dictionary + if let defaultValue = Preference.defaultPreferences[key] { + os_log("Value found in default preferences for key: %{public}@", log: prefLog, type: .info, key.rawValue) + return defaultValue + } + os_log("Did not find a default for key: %{public}@, returning nil", log: prefLog, type: .info, key.rawValue) + return nil +} -/// A convenience name for `UserDefaults.standard` -let defaults = UserDefaults.standard +/** + Retrieves a managed preference. + + This function checks the specified preference domain for a managed preference. + If a preference is found, it logs the preference and checks if it is enforced. + + - Parameter key: The preference key to check. + - Returns: A tuple containing the preference value and a boolean indicating if it is enforced, or nil if no preference is found. + */ +func getManagedPref(key: Preference) -> (Any?, Bool?) { -/// The preference keys for the crypt defaults domain. + os_log("Checking %{public}@ preference domain for managed preference.", type: .info, cryptBundleID) + + if let preference = getPref(key: key) as Any? { + os_log("Found preference: %{public}@ with value: %{public}@", log: prefLog, type: .info, key.rawValue, String(describing: preference)) + + if CFPreferencesAppValueIsForced(key.rawValue as CFString, cryptBundleID as CFString) { + os_log("Preference %{public}@ is enforced.", log: prefLog, type: .info, key.rawValue) + return (preference, true) + } + + os_log("Preference %{public}@ is not enforced.", log: prefLog, type: .info, key.rawValue) + return (preference, false) + } + + os_log("No preference found for key: %{public}@", log: prefLog, type: .info, key.rawValue) + return (nil, nil) +} + +/// Sets a preference in the com.grahamgilbert.crypt domain +/// +/// +/// - Parameters: +/// - key: The string name of the key you want to set +/// - value: The value in Any that you want to set /// -/// Use these keys, rather than raw strings. -enum Preferences { - static let outputPath = "OutputPath" - static let keyRotateDays = "KeyRotateDays" - static let removePlist = "RemovePlist" +/// - Returns: A Boolean of success. +func setPref(key: Preference, value: Any) -> Bool { + Foundation.CFPreferencesSetValue(key.rawValue as CFString, value as CFPropertyList, cryptBundleID as CFString, kCFPreferencesAnyUser, kCFPreferencesCurrentHost) + + let syncStatus = CFPreferencesAppSynchronize(cryptBundleID as CFString) + + return syncStatus } diff --git a/README.md b/README.md index 2d86c12..b9304d6 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ When using Crypt with macOS 10.15 and higher, you will also need to deploy a PPC - Uses native authorization plugin so FileVault enforcement cannot be skipped. - Escrow is delayed until there is an active user, so FileVault can be enforced when the Mac is offline. - Administrators can specify a series of username that should not have to enable FileVault (IT admin, for example). +- Can securely store the recovery key in the keychain. ## Configuration @@ -94,6 +95,62 @@ $ sudo defaults write /Library/Preferences/com.grahamgilbert.crypt AdditionalCur This is a command that is run after Crypt has detected an error condition with a stored key that cannot be resolved silently - either it has failed validation or the server has instructed the client to rotate the key. These cannot be resolved silently on APFS volumes, so the user will need to log in again. If you have a tool that can enforce a logout or a reboot, you can run it here. This preference can either be a string if your command has no spaces, or an array if there are spaces in the command. +### AppsAllowedToChangeKey + +An array of applications allowed to change the ACLs for the FileVault recovery key in the keychain. This most likely doesn't need to be changed from it's default. Only works with `StoreRecoveryKeyInKeychain` (Available in Crypt version 6 and later) + +```bash +$ sudo defaults write /Library/Preferences/com.grahamgilbert.crypt AppsAllowedToChangeKey -array "/path/to/app1" "/path/to/app2" +``` + +### AppsAllowedToReadKey + +An array of applications allowed to read the FileVault recovery key. By default, this includes "/Library/Crypt/checkin". Note: It is crucial to include "/Library/Crypt/checkin" in this array, or Crypt may not function correctly. Only works with `StoreRecoveryKeyInKeychain` (Available in Crypt version 6 and later) + +```bash +$ sudo defaults write /Library/Preferences/com.grahamgilbert.crypt AppsAllowedToReadKey -array "/Library/Crypt/checkin" "/path/to/custom/app" +``` + +### InvisibleInKeychain + +A boolean value indicating whether the recovery key should be invisible in the Keychain. If set to `true` the recovery will not be viewable in Keychain.app. The icon can still be listable with the `security` command. Default is `false`. (Available in Crypt version 6 and later) + +```bash +$ sudo defaults write /Library/Preferences/com.grahamgilbert.crypt InvisibleInKeychain -bool TRUE +``` + +### KeychainUIPromptDescription + +The description shown in the Keychain UI prompt when a process tries to access or modify the item that doesn't have permission. You could use this a way to instruct folks on whether or not to allow it. Default is "Crypt FileVault Recovery Key". (Available in Crypt version 6 and later) + +```bash +$ sudo defaults write /Library/Preferences/com.grahamgilbert.crypt KeychainUIPromptDescription -string "Custom FileVault Recovery Key Description" +``` + +### StoreRecoveryKeyInKeychain + +A boolean value indicating whether the recovery key should be stored in the Keychain. Default is `true`. (Available in Crypt version 6 and later) + +```bash +$ sudo defaults write /Library/Preferences/com.grahamgilbert.crypt StoreRecoveryKeyInKeychain -bool FALSE +``` + +### CommonNameForEscrow + +A string value matching the Issuer Common Name of a certificate in the macOS keychain. Empty/not set by default. Available in Crypt version 6 and later you can use this preference to have crypt use native gocode for the escrow request (not `curl`) and use a certificate in the keychain matching the Issuer Common Name provided for mTLS. The private key associated with the certificate must be accessible and signable by /Library/Crypt/checkin. + +```bash +$ sudo defaults write /Library/Preferences/com.grahamgilbert.crypt CommonNameForEscrow -string "Custom Common Name" +``` + +### GenerateNewKey + +A boolean value indicating that Crypt should generate a new recovery key during login. + +```bash +$ sudo defaults write /Library/Preferences/com.grahamgilbert.crypt GenerateNewKey -bool TRUE +``` + ## Uninstalling The install package will modify the Authorization DB - you need to remove these entries before removing the Crypt Authorization Plugin. To do this, use the `-uninstall` flag in the `checkin` binary (`sudo /Library/Crypt/checkin -uninstall`). diff --git a/authmod/main.swift b/authmod/main.swift new file mode 100644 index 0000000..307fd56 --- /dev/null +++ b/authmod/main.swift @@ -0,0 +1,124 @@ +// +// main.swift +// authmod +// +// Created by Wes Whetstone on 7/22/23. +// Copyright © 2023 Graham Gilbert. All rights reserved. +// + +import Foundation +import ArgumentParser +import Security.AuthorizationDB + +struct authmod: ParsableCommand { + + static var configuration = CommandConfiguration( + commandName: "authmod", + abstract: "A command-line tool for modifying the Crypt Authorization Database." + ) + + @Flag(name: .shortAndLong, help: "Remove the Crypt Mechanisms from the Authorization Database instead of adding them.") + var uninstall: Bool = false + + func run() throws { + if uninstall { + // Perform the uninstallation logic + _ = setMechanisms(remove: true) + } else { + // Perform the default behavior + _ = setMechanisms(remove: false) + } + } +} + +func setMechanisms(remove: Bool) -> Bool { + let kSystemRightConsole = "system.login.console" + let kloginwindowDone = "loginwindow:done" + let authBuddyMechs = ["Crypt:Check,privileged"] + let kmechanisms = "mechanisms" + + var rights: CFDictionary? + var err = OSStatus.init(0) + var authRef: AuthorizationRef? + + // get an authorization context to save this back + // need to be root, if we are this should return clean + err = AuthorizationCreate(nil, nil, AuthorizationFlags(rawValue: 0), &authRef) + + // get the current rights for system.login.console + err = AuthorizationRightGet(kSystemRightConsole, &rights) + + // Now to iterate through the list and add what we need + if err != 0 { + print("Encountered Error setting up Authorization Database with err \(String(describing: SecCopyErrorMessageString(err, nil)))") + exit(err) + } + + guard var rightsDict = rights as? [String: AnyObject] else { + print("Failed to cast rights to [String: AnyObject]") + exit(1) + } + + guard var currentMechanisms: [String] = rightsDict[kmechanisms] as? [String] else { + print("Failed to cast rightsDict[kmechanisms] to [String]") + exit(1) + } + + // remove any previous mechs before we add the new ones. + for mech in authBuddyMechs { + print("Working with \(mech)") + if let rmindex = currentMechanisms.firstIndex(of: mech) { + print("Removing: \(mech).") + currentMechanisms.remove(at: rmindex) + } + } + + if remove == true { + // we want to remove them so we can just set the mechanisms back now since we removed them prior to setting them. + rightsDict[kmechanisms] = currentMechanisms as AnyObject + + print("Setting back removed mechanisms.") + err = AuthorizationRightSet(authRef!, kSystemRightConsole, rightsDict as CFTypeRef, nil, nil, nil) + + if err != 0 { + print("Failed to set mechanisms with err \(String(describing: SecCopyErrorMessageString(err, nil)))") + exit(err) + } + + print("Successfully removed Mechanisms \(authBuddyMechs) from Authorization Database.") + exit(0) + } + + // Now to iterate through the list and add what we need + if let index = currentMechanisms.firstIndex(of: kloginwindowDone) { + print("Found \(kloginwindowDone) at index \(index)") + var ind: Int = 0 + for mech in authBuddyMechs { + let indexToAddAt = (index + ind) + print("Adding: \(mech) at index: \(indexToAddAt).") + currentMechanisms.insert(mech, at: indexToAddAt) + ind += 1 + } + rightsDict[kmechanisms] = currentMechanisms as AnyObject + + // write out the new mechanism array + err = AuthorizationRightSet(authRef!, kSystemRightConsole, rightsDict as CFTypeRef, nil, nil, nil) + + if err != 0 { + print("Failed to set mechanisms with err \(String(describing: SecCopyErrorMessageString(err, nil)))") + exit(err) + } + + print("Successfully set Mechanisms \(authBuddyMechs) from Authorization Database.") + exit(0) + } + + print("Couldn't find \(kloginwindowDone) in mechanism list.") + exit(1) +} + +func main() { + authmod.main() +} + +main() diff --git a/go.mod b/go.mod index 0c2ed11..69ac151 100644 --- a/go.mod +++ b/go.mod @@ -7,10 +7,13 @@ require ( github.com/hashicorp/go-version v1.6.0 github.com/pkg/errors v0.9.1 github.com/stretchr/testify v1.8.4 + golang.org/x/sys v0.15.0 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect + github.com/korylprince/macserial v1.0.0 github.com/pmezard/go-difflib v1.0.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 22322ac..6deb4b6 100644 --- a/go.sum +++ b/go.sum @@ -1,15 +1,21 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw= +github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA= github.com/groob/plist v0.0.0-20220217120414-63fa881b19a5 h1:saaSiB25B1wgaxrshQhurfPKUGJ4It3OxNJUy0rdOjU= github.com/groob/plist v0.0.0-20220217120414-63fa881b19a5/go.mod h1:itkABA+w2cw7x5nYUS/pLRef6ludkZKOigbROmCTaFw= github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/korylprince/macserial v1.0.0 h1:g9K6tlc7L1vMC9qb5g8J5WhXQ42VzGJdnBpsLM9HXYs= +github.com/korylprince/macserial v1.0.0/go.mod h1:yx9H1/hSbRlqOUh+LLWXWXh96eOjfpNQcJJ0ZMfYgCE= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/pkg/authmechs/authemechs.go b/pkg/authmechs/authemechs.go index 2406e61..4b4eab1 100644 --- a/pkg/authmechs/authemechs.go +++ b/pkg/authmechs/authemechs.go @@ -12,9 +12,10 @@ import ( ) var ( - fv2Mechs = []string{"Crypt:Check,privileged", "Crypt:CryptGUI", "Crypt:Enablement,privileged"} - fv2IndexMech = "loginwindow:done" - fv2IndexOffset = 0 + fv2Mechs = []string{"Crypt:Check,privileged"} + fv2MechsToRemove = []string{"Crypt:Check,privileged", "Crypt:CryptGUI", "Crypt:Enablement,privileged"} + fv2IndexMech = "loginwindow:done" + fv2IndexOffset = 0 ) type AuthDB struct { @@ -54,13 +55,12 @@ func checkMechsInDB(db AuthDB, mechList []string, indexMech string, indexOffset return reflect.DeepEqual(db.Mechanisms[insertIndex:insertIndex+len(mechList)], mechList) } -func setMechsInDB(db AuthDB, mechList []string, indexMech string, indexOffset int, add bool) AuthDB { - db = removeMechsInDB(db, mechList) +func setMechsInDB(db AuthDB, mechList []string, indexMech string, indexOffset int) AuthDB { + // Remove all the mechanisms that crypt ever added but are not needed anymore we'll re-add the ones we need + db = removeMechsInDB(db, fv2MechsToRemove) - if add { - insertIndex := indexOf(db.Mechanisms, indexMech) + indexOffset - db.Mechanisms = insertMechsAtPosition(db.Mechanisms, mechList, insertIndex) - } + insertIndex := indexOf(db.Mechanisms, indexMech) + indexOffset + db.Mechanisms = insertMechsAtPosition(db.Mechanisms, mechList, insertIndex) return db } @@ -113,7 +113,7 @@ func editAuthDB(r utils.Runner, add bool) error { return err } - d = setMechsInDB(d, fv2Mechs, fv2IndexMech, fv2IndexOffset, add) + d = setMechsInDB(d, fv2Mechs, fv2IndexMech, fv2IndexOffset) data, err := plist.Marshal(d) if err != nil { return err diff --git a/pkg/authmechs/authmechs_test.go b/pkg/authmechs/authmechs_test.go index 53fd1ce..abf2697 100644 --- a/pkg/authmechs/authmechs_test.go +++ b/pkg/authmechs/authmechs_test.go @@ -101,7 +101,6 @@ func TestSetMechsInDB(t *testing.T) { mechList []string indexMech string indexOffset int - add bool want AuthDB }{ { @@ -110,7 +109,6 @@ func TestSetMechsInDB(t *testing.T) { mechList: []string{"mech1", "mech2"}, indexMech: "mech1", indexOffset: 1, - add: true, want: AuthDB{Mechanisms: []string{"mech1", "mech2"}}, }, { @@ -119,7 +117,6 @@ func TestSetMechsInDB(t *testing.T) { mechList: []string{"mech4", "mech5"}, indexMech: "mech2", indexOffset: 1, - add: true, want: AuthDB{Mechanisms: []string{"mech1", "mech2", "mech4", "mech5", "mech3"}}, }, { @@ -128,32 +125,29 @@ func TestSetMechsInDB(t *testing.T) { mechList: []string{}, indexMech: "mech2", indexOffset: 1, - add: true, want: AuthDB{Mechanisms: []string{"mech1", "mech2", "mech3"}}, }, { - name: "Test with non-empty db, add is false", + name: "Test with non-empty db and mechList to add", db: AuthDB{Mechanisms: []string{"mech1", "mech2", "mech3"}}, - mechList: []string{"mech4", "mech3"}, + mechList: []string{"mech4", "mech5"}, indexMech: "mech2", indexOffset: 1, - add: false, - want: AuthDB{Mechanisms: []string{"mech1", "mech2"}}, + want: AuthDB{Mechanisms: []string{"mech1", "mech2", "mech4", "mech5", "mech3"}}, }, { - name: "Test with non-empty db, mechList is empty, add is false", + name: "Test with non-empty db, empty mechList", db: AuthDB{Mechanisms: []string{"mech1", "mech2", "mech3"}}, mechList: []string{}, indexMech: "mech2", indexOffset: 1, - add: false, want: AuthDB{Mechanisms: []string{"mech1", "mech2", "mech3"}}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := setMechsInDB(tt.db, tt.mechList, tt.indexMech, tt.indexOffset, tt.add) + got := setMechsInDB(tt.db, tt.mechList, tt.indexMech, tt.indexOffset) assert.Equal(t, tt.want, got) }) } diff --git a/pkg/checkin/BUILD.bazel b/pkg/checkin/BUILD.bazel index 4c9e10a..444c7a6 100644 --- a/pkg/checkin/BUILD.bazel +++ b/pkg/checkin/BUILD.bazel @@ -12,6 +12,7 @@ go_library( "@com_github_groob_plist//:plist", "@com_github_hashicorp_go_version//:go_default_library", "@com_github_pkg_errors//:errors", + "@com_github_googleapis_enterprise_certificate_proxy_darwin//:go_default_library", # Added dependency ], ) diff --git a/pkg/checkin/escrow.go b/pkg/checkin/escrow.go index d8f7195..ba52b65 100644 --- a/pkg/checkin/escrow.go +++ b/pkg/checkin/escrow.go @@ -1,14 +1,18 @@ package checkin import ( + "crypto/tls" "encoding/json" "fmt" + "io" "log" + "net/http" "net/url" "os" "strings" "time" + "github.com/googleapis/enterprise-certificate-proxy/darwin" "github.com/grahamgilbert/crypt/pkg/authmechs" "github.com/grahamgilbert/crypt/pkg/pref" "github.com/grahamgilbert/crypt/pkg/utils" @@ -27,20 +31,29 @@ type CryptData struct { EnabledDate string `plist:"EnabledDate"` } +// RunEscrow manages the process of escrowing a FileVault recovery key to a server. +// Parameters: +// - r: Runner interface for executing system commands +// - p: PrefInterface for accessing configuration preferences +// +// Returns: +// - error: Any error encountered during the escrow process func RunEscrow(r utils.Runner, p pref.PrefInterface) error { - plistPath, err := p.GetString("OutputPath") + // Get preferences early + useKeychain, err := p.GetBool("StoreRecoveryKeyInKeychain") if err != nil { - return errors.Wrap(err, "failed to get output path") + return errors.Wrap(err, "failed to get StoreRecoveryKeyInKeychain preference") } - rotateUsedKey, err := p.GetBool("RotateUsedKey") + manageAuthMechs, err := p.GetBool("ManageAuthMechs") if err != nil { - return errors.Wrap(err, "failed to get rotate used key preference") + return errors.Wrap(err, "failed to get manage auth mechs preference") } - validateKey, err := p.GetBool("ValidateKey") - if err != nil { - return errors.Wrap(err, "failed to get validate key preference") + if manageAuthMechs { + if err := authmechs.Ensure(r); err != nil { + return errors.Wrap(err, "failed to ensure auth mechs") + } } removePlist, err := p.GetBool("RemovePlist") @@ -48,50 +61,55 @@ func RunEscrow(r utils.Runner, p pref.PrefInterface) error { return errors.Wrap(err, "failed to get remove plist preference") } - manageAuthMechs, err := p.GetBool("ManageAuthMechs") + plistPath, err := p.GetString("OutputPath") if err != nil { - return errors.Wrap(err, "failed to get manage auth mechs preference") + return errors.Wrap(err, "failed to get output path") } - if manageAuthMechs { - err := authmechs.Ensure(r) - if err != nil { - return errors.Wrap(err, "failed to ensure auth mechs") - } + rotateUsedKey, err := p.GetBool("RotateUsedKey") + if err != nil { + return errors.Wrap(err, "failed to get rotate used key preference") + } + + validateKey, err := p.GetBool("ValidateKey") + if err != nil { + return errors.Wrap(err, "failed to get validate key preference") } if rotateUsedKey && validateKey && !removePlist { - err := rotateInvalidKey(plistPath, r, p) - if err != nil { + log.Println("Checking that current key is valid.") + if err := rotateInvalidKey(plistPath, r, p); err != nil { return errors.Wrap(err, "rotateInvalidKey") } - // TODO: Post run command } - // Return nil if plist does not exist - _, err = os.Stat(plistPath) - if os.IsNotExist(err) { - return nil - } else if err != nil { - return errors.Wrap(err, "failed to check if plist exists") - } + var cryptData CryptData - cryptData, err := parsePlist(plistPath) - if err != nil { - return errors.Wrap(err, "failed to parse plist") - } + if useKeychain { + log.Println("Configured to use keychain for recovery key storage.") + recoveryKey, err := utils.GetSecret() + if err != nil { + return errors.Wrap(err, "failed to get recovery key from keychain.") + } - if cryptData.EnabledUser == "" { - cryptData.EnabledUser, err = utils.GetConsoleUser() + // create our cryptData from current system information since we don't have it in the plist + cryptData, err = buildCryptData(p, r) if err != nil { - return errors.Wrap(err, "failed to get enabled user") + return errors.Wrap(err, "failed to build crypt data") + } + cryptData.RecoveryKey = recoveryKey + } else { + // Not using keychain, gather the cryptData from the plist on disk. + // Check if plist exists + if _, err := os.Stat(plistPath); os.IsNotExist(err) { + return nil + } else if err != nil { + return errors.Wrap(err, "failed to check if plist exists") } - } - if userShouldBeSkipped(cryptData.EnabledUser) || cryptData.EnabledUser == "" { - cryptData.EnabledUser, err = getEnabledUser(p, r) + cryptData, err = parsePlist(plistPath) if err != nil { - return errors.Wrap(err, "failed to get enabled user") + return errors.Wrap(err, "failed to parse plist") } } @@ -105,24 +123,39 @@ func RunEscrow(r utils.Runner, p pref.PrefInterface) error { return nil } - keyRotated, err := escrowKey(cryptData, r, p) + // Handle escrow + var keyRotated bool + mTLScommonName, err := p.GetString("CommonNameForEscrow") if err != nil { - return errors.Wrap(err, "escrowKey") + return errors.Wrap(err, "failed to get mTLS common name for escrow") } - cryptData.LastRun = time.Now() - cryptData.EscrowSuccess = true + keyRotated, err = escrowKey(cryptData, r, p, mTLScommonName) + if err != nil { + return errors.Wrap(err, "escrow operation failed") + } - if !keyRotated { - err = writePlist(cryptData, plistPath) + // if using the keychain and the key wasn't rotated, update the preference last escrow date and return + if useKeychain && !keyRotated { + // write the last escrow date to preferences if using keychain. + err = p.SetDate("LastEscrow", time.Now()) if err != nil { + return errors.Wrap(err, "failed to set last escrow date") + } + return nil + } + + // Handle plist operations if not using keychain + if !keyRotated { + cryptData.LastRun = time.Now() + cryptData.EscrowSuccess = true + if err := writePlist(cryptData, plistPath); err != nil { return errors.Wrap(err, "failed to write plist") } } if removePlist { - err = os.Remove(plistPath) - if err != nil { + if err := os.Remove(plistPath); err != nil { return errors.Wrap(err, "failed to remove plist") } } @@ -130,6 +163,56 @@ func RunEscrow(r utils.Runner, p pref.PrefInterface) error { return nil } +// buildCryptData constructs a CryptData structure with current system information. +// Parameters: +// - p: PrefInterface for accessing configuration preferences +// - r: Runner interface for executing system commands +// +// Returns: +// - CryptData: Populated structure with system information +// - error: Any error encountered during data collection +func buildCryptData(p pref.PrefInterface, r utils.Runner) (CryptData, error) { + var cryptData CryptData + var err error + + // Get serial number + cryptData.SerialNumber = utils.GetSerial() + + // Get enabled user + cryptData.EnabledUser, err = utils.GetConsoleUser() + if err != nil { + return CryptData{}, errors.Wrap(err, "failed to get enabled user") + } + + // Handle skipped users + if userShouldBeSkipped(cryptData.EnabledUser) || cryptData.EnabledUser == "" { + cryptData.EnabledUser, err = getEnabledUser(p, r) + if err != nil { + return CryptData{}, errors.Wrap(err, "failed to get enabled user") + } + } + + // Get last run time + lastRun, err := p.GetDate("LastEscrow") + if err != nil { + return CryptData{}, errors.Wrap(err, "failed to get last escrow date") + } + if lastRun != (time.Time{}) { + cryptData.LastRun = lastRun + } + + return cryptData, nil +} + +// escrowRequired determines if a key needs to be escrowed based on the last escrow +// time and the configured escrow interval. +// Parameters: +// - cryptData: CryptData containing the last escrow time +// - p: PrefInterface for accessing configuration preferences +// +// Returns: +// - bool: True if escrow is required, false otherwise +// - error: Any error encountered during the check func escrowRequired(cryptData CryptData, p pref.PrefInterface) (bool, error) { if cryptData.LastRun.IsZero() { return true, nil @@ -151,11 +234,25 @@ func escrowRequired(cryptData CryptData, p pref.PrefInterface) (bool, error) { return true, nil } +// userShouldBeSkipped checks if a given username is in the list of users that +// should be skipped during the escrow process. +// Parameters: +// - user: String containing the username to check +// +// Returns: +// - bool: True if user should be skipped, false otherwise func userShouldBeSkipped(user string) bool { skipUsers := []string{"root", "_mbsetupuser"} return utils.StringInSlice(user, skipUsers) } +// parsePlist reads and unmarshals a property list file into a CryptData structure. +// Parameters: +// - plistPath: String path to the plist file +// +// Returns: +// - CryptData: Unmarshaled data structure +// - error: Any error encountered during parsing func parsePlist(plistPath string) (CryptData, error) { var cryptData CryptData plistBytes, err := os.ReadFile(plistPath) @@ -171,6 +268,14 @@ func parsePlist(plistPath string) (CryptData, error) { return cryptData, nil } +// writePlist marshals CryptData into a property list format and writes it to the +// specified file path. +// Parameters: +// - cryptData: CryptData to be written +// - plistPath: String path where the plist should be written +// +// Returns: +// - error: Any error encountered during writing func writePlist(cryptData CryptData, plistPath string) error { plistBytes, err := plist.Marshal(cryptData) if err != nil { @@ -185,10 +290,15 @@ func writePlist(cryptData CryptData, plistPath string) error { return nil } -// rotateInvalidKey will send the key (if present) for validation. If validation fails, -// it will remove the plist so the key can be regenerated at next login. -// Due to the bug that restricts the number of validations before reboot -// in versions of macOS prior to 10.12.5, this will only run there. +// rotateInvalidKey validates the current recovery key and removes the plist if +// validation fails, allowing key regeneration at next login. +// Parameters: +// - plistPath: String path to the plist file +// - r: Runner interface for executing system commands +// - p: PrefInterface for accessing configuration preferences +// +// Returns: +// - error: Any error encountered during rotation func rotateInvalidKey(plistPath string, r utils.Runner, p pref.PrefInterface) error { _, err := utils.GetConsoleUser() if err != nil { @@ -207,15 +317,21 @@ func rotateInvalidKey(plistPath string, r utils.Runner, p pref.PrefInterface) er } if macOSVersionParsed.LessThan(version.Must(version.NewVersion("10.12.5"))) { + log.Println("Version is less than 10.12.5, skipping key validation.") return nil } + useKeychain, err := p.GetBool("StoreRecoveryKeyInKeychain") + if err != nil { + return errors.Wrap(err, "failed to get StoreRecoveryKeyInKeychain preference") + } + _, err = os.Stat(plistPath) - if os.IsNotExist(err) { + if !useKeychain && os.IsNotExist(err) { return nil } - recoveryKey, err := getRecoveryKey(plistPath) + recoveryKey, err := getRecoveryKey(plistPath, p) if err != nil { return errors.Wrap(err, "failed to get recovery key") } @@ -225,11 +341,13 @@ func rotateInvalidKey(plistPath string, r utils.Runner, p pref.PrefInterface) er return errors.Wrap(err, "validateRecoveryKey") } - if !keyValid { - err := os.Remove(plistPath) - if err != nil { - return errors.Wrap(err, "os.remove plistPath") - } + if keyValid { + return nil + } + + err = removeInvalidKey(plistPath, useKeychain) + if err != nil { + return err } err = postRunCommand(r, p) @@ -237,9 +355,48 @@ func rotateInvalidKey(plistPath string, r utils.Runner, p pref.PrefInterface) er return errors.Wrap(err, "postRunCommand") } + return errors.New("Removed invalid key") +} + +// removeInvalidKey removes an invalid key either from the keychain or from a specified plist file. +// If usingKeychain is true, it attempts to delete the key from the keychain using utils.DeleteSecret(). +// If usingKeychain is false, it attempts to remove the key from the specified plist file path. +// +// Parameters: +// - plistPath: The path to the plist file from which the key should be removed if not using the keychain. +// - usingKeychain: A boolean indicating whether to remove the key from the keychain (true) or from the plist file (false). +// +// Returns: +// - An error if the key removal operation fails, otherwise nil. +func removeInvalidKey(plistPath string, usingKeychain bool) error { + var err error + if usingKeychain { + log.Println("Removing invalid recovery key from keychain.") + err = utils.DeleteSecret() + if err != nil { + return errors.Wrap(err, "failed to delete recovery key from keychain") + } + return nil + } + + log.Printf("Removing invalid key at path: %s\n", plistPath) + err = os.Remove(plistPath) + if err != nil { + return errors.Wrap(err, "os.remove plistPath") + } + return nil } +// validateRecoveryKey checks if a given recovery key is valid by testing it +// against the system's FileVault configuration. +// Parameters: +// - recoveryKey: String containing the recovery key to validate +// - r: Runner interface for executing system commands +// +// Returns: +// - bool: True if key is valid, false otherwise +// - error: Any error encountered during validation func validateRecoveryKey(recoveryKey string, r utils.Runner) (bool, error) { type Key struct { Password string @@ -272,6 +429,15 @@ func validateRecoveryKey(recoveryKey string, r utils.Runner) (bool, error) { } } +// getEnabledUser retrieves the first enabled FileVault user that isn't in the +// skip users list. +// Parameters: +// - p: PrefInterface for accessing configuration preferences +// - r: Runner interface for executing system commands +// +// Returns: +// - string: Username of the first valid enabled user +// - error: Any error encountered during the search func getEnabledUser(p pref.PrefInterface, r utils.Runner) (string, error) { skipUsers, err := p.GetArray("SkipUsers") if err != nil { @@ -294,6 +460,14 @@ func getEnabledUser(p pref.PrefInterface, r utils.Runner) (string, error) { return "", nil } +// buildData constructs the form data for the escrow request. +// Parameters: +// - cryptData: CryptData containing the information to be sent +// - runner: Runner interface for executing system commands +// +// Returns: +// - string: Encoded form data +// - error: Any error encountered during construction func buildData(cryptData CryptData, runner utils.Runner) (string, error) { computerName, err := utils.GetComputerName(runner) if err != nil { @@ -308,6 +482,13 @@ func buildData(cryptData CryptData, runner utils.Runner) (string, error) { return data.Encode(), nil } +// buildCheckinURL constructs the complete URL for the escrow check-in endpoint. +// Parameters: +// - p: PrefInterface for accessing configuration preferences +// +// Returns: +// - string: Complete checkin URL +// - error: Any error encountered during URL construction func buildCheckinURL(p pref.PrefInterface) (string, error) { serverURL, err := p.GetString("ServerURL") if err != nil { @@ -319,6 +500,15 @@ func buildCheckinURL(p pref.PrefInterface) (string, error) { return serverURL + "checkin/", nil } +// runCurl executes a curl command with the specified configuration. +// Parameters: +// - configFile: String containing curl configuration +// - r: Runner interface for executing system commands +// - p: PrefInterface for accessing configuration preferences +// +// Returns: +// - string: Command output +// - error: Any error encountered during execution func runCurl(configFile string, r utils.Runner, p pref.PrefInterface) (string, error) { // --fail: Fail silently (no output at all) on server errors. // --silent: Silent mode. Don't show progress meter or error messages. @@ -337,9 +527,11 @@ func runCurl(configFile string, r utils.Runner, p pref.PrefInterface) (string, e return "", errors.Wrap(err, "failed to get additional curl options") } if additionalCurlOpts != nil { + log.Println("Additional curl options found.. Adding to curl command") args = append(args, additionalCurlOpts...) } args = append(args, "--config", "-") + out, err := r.Runner.RunCmdWithStdin(cmd, configFile, args...) if err != nil { theErr := fmt.Errorf("stdout: %s err: %s", out, err) @@ -348,34 +540,72 @@ func runCurl(configFile string, r utils.Runner, p pref.PrefInterface) (string, e return string(out), nil } -func escrowKey(plist CryptData, r utils.Runner, p pref.PrefInterface) (bool, error) { +// escrowKey attempts to escrow a key to the server, using either mTLS or curl based on configuration. +// It builds the check-in URL, constructs the form data, and sends the escrow request using +// the appropriate method based on whether a CommonNameForEscrow is configured. +// +// Parameters: +// - plist: CryptData containing the data to be sent +// - r: utils.Runner interface for executing commands +// - p: pref.PrefInterface for accessing preferences +// - mTLScommonName: Optional common name for mTLS authentication. If empty, curl will be used. +// +// Returns: +// - bool: Indicates if the key was rotated as part of the escrow process +// - error: Any error encountered during the process +func escrowKey(plist CryptData, r utils.Runner, p pref.PrefInterface, mTLScommonName string) (bool, error) { log.Println("Attempting to Escrow Key...") - // serverURL, err := p.GetString("ServerURL") - // if err != nil { - // return errors.Wrap(err, "failed to get server URL") - // } + theURL, err := buildCheckinURL(p) if err != nil { return false, errors.Wrap(err, "failed to build checkin URL") } + + // Build form data data, err := buildData(plist, r) if err != nil { return false, errors.Wrap(err, "failed to build data") } - configFile := utils.BuildCurlConfigFile(map[string]string{"url": theURL, "data": data}) - output, err := runCurl(configFile, r, p) - if err != nil { - return false, errors.Wrap(err, "failed to run curl") + + var responseBody string + + // Determine whether to use mTLS or curl based on whether a common name is provided + if mTLScommonName != "" { + log.Printf("Using mTLS for escrow with common name: %s", mTLScommonName) + body, err := sendRequest(theURL, data, mTLScommonName) + if err != nil { + return false, errors.Wrap(err, "failed to send request with mTLS") + } + responseBody = string(body) + } else { + log.Println("Using curl for escrow") + configFile := utils.BuildCurlConfigFile(map[string]string{"url": theURL, "data": data}) + output, err := runCurl(configFile, r, p) + if err != nil { + return false, errors.Wrap(err, "failed to run curl") + } + responseBody = output } + log.Println("Key escrow successful.") - keyRotated, err := serverInitiatedRotation(output, r, p) + keyRotated, err := serverInitiatedRotation(responseBody, r, p) if err != nil { return false, errors.Wrap(err, "serverInitiatedRotation") } + return keyRotated, nil } +// serverInitiatedRotation processes the server's response for key rotation. +// Parameters: +// - output: String containing server response +// - r: Runner interface for executing system commands +// - p: PrefInterface for accessing configuration preferences +// +// Returns: +// - bool: Whether rotation was completed +// - error: Any error encountered during rotation func serverInitiatedRotation(output string, r utils.Runner, p pref.PrefInterface) (bool, error) { var rotation struct { RotationRequired bool `json:"rotation_required"` @@ -399,21 +629,28 @@ func serverInitiatedRotation(output string, r utils.Runner, p pref.PrefInterface return rotationCompleted, nil } + useKeychain, err := p.GetBool("StoreRecoveryKeyInKeychain") + if err != nil { + return rotationCompleted, nil + } + outputPath, err := p.GetString("OutputPath") if err != nil { return rotationCompleted, errors.Wrap(err, "failed to get output path preference") } - _, err = os.Stat(outputPath) - if os.IsNotExist(err) { - return rotationCompleted, nil + + if !useKeychain { + _, err = os.Stat(outputPath) + if os.IsNotExist(err) { + return rotationCompleted, nil + } } if rotation.RotationRequired { - log.Println("Removing output plist for rotation at next login.") - err = os.Remove(outputPath) + log.Println("Found server initiated key rotation. Removing used/invalid key.") + err = removeInvalidKey(outputPath, useKeychain) if err != nil { - log.Println("Failed to remove output plist:", err) - return rotationCompleted, errors.Wrap(err, "failed to remove output plist") + return rotationCompleted, errors.Wrap(err, "failed to remove invalid key") } rotationCompleted = true } @@ -426,6 +663,13 @@ func serverInitiatedRotation(output string, r utils.Runner, p pref.PrefInterface return rotationCompleted, nil } +// getCommand retrieves and formats the post-run command from preferences. +// Parameters: +// - p: PrefInterface for accessing configuration preferences +// +// Returns: +// - string: Formatted command string +// - error: Any error encountered during retrieval func getCommand(p pref.PrefInterface) (string, error) { var command string @@ -448,6 +692,13 @@ func getCommand(p pref.PrefInterface) (string, error) { return command, nil } +// postRunCommand executes a configured command after the escrow process. +// Parameters: +// - r: Runner interface for executing system commands +// - p: PrefInterface for accessing configuration preferences +// +// Returns: +// - error: Any error encountered during command execution func postRunCommand(r utils.Runner, p pref.PrefInterface) error { command, err := getCommand(p) if err != nil { @@ -474,7 +725,44 @@ func postRunCommand(r utils.Runner, p pref.PrefInterface) error { return nil } -func getRecoveryKey(keyLocation string) (string, error) { +// getRecoveryKey retrieves the recovery key from either the keychain or a plist file based on the user's preference. +// +// Parameters: +// - keyLocation: The file path to the plist file containing the recovery key. +// - p: An implementation of the PrefInterface used to get user preferences. +// +// Returns: +// - A string containing the recovery key. +// - An error if there is any issue retrieving the recovery key. +// +// The function first checks the user preference "StoreRecoveryKeyInKeychain" to determine where to retrieve the recovery key from. +// If the preference is set to true, it attempts to get the recovery key from the keychain using utils.GetSecret(). +// If the keychain retrieval fails or the key is empty, an error is returned. +// If the preference is set to false, it reads the recovery key from the specified plist file. +// If reading the plist file or unmarshalling its contents fails, an error is returned. +func getRecoveryKey(keyLocation string, p pref.PrefInterface) (string, error) { + useKeychain, err := p.GetBool("StoreRecoveryKeyInKeychain") + if err != nil { + return "", errors.Wrap(err, "failed to get StoreRecoveryKeyInKeychain preference") + } + + if useKeychain { + log.Println("Using keychain to get recovery key.") + keychainRecoveryKey, err := utils.GetSecret() + if err != nil { + return "", errors.Wrap(err, "failed to get recovery key from keychain") + } + + // if the recovery key isn't found in the keychain it will return an empty string + // check if the recovery key is empty and return an error if it is + if keychainRecoveryKey == "" { + return "", errors.New("recovery key is empty") + } + log.Println("Found recovery key in keychain.") + return keychainRecoveryKey, nil + } + + // if we are not using the keychain, we will read the recovery key from the plist type keyPlist struct { RecoveryKey string `plist:"RecoveryKey"` } @@ -494,3 +782,80 @@ func getRecoveryKey(keyLocation string) (string, error) { return key.RecoveryKey, nil } + +// sendRequest sends an HTTP POST request to the specified URL with the given data +// and uses mTLS (mutual TLS) for authentication with the provided common name. +// It returns the response body as a byte slice or an error if the request fails. +// +// Parameters: +// - url: The URL to send the request to. +// - data: The data to include in the request body. +// - commonName: The common name used to retrieve the secure key from the keychain. +// +// Returns: +// - []byte: The response body from the server. +// - error: An error if the request fails or the server returns a non-200 status. +func sendRequest(url string, data string, commonName string) ([]byte, error) { + // Create request + req, err := http.NewRequest( + "POST", + url, + strings.NewReader(data), + ) + if err != nil { + return nil, errors.Wrap(err, "failed to create request") + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + // Get the secure key from the keychain + log.Println("Using mTLS for escrow with common name: ", commonName) + secureKey, err := darwin.NewSecureKey(commonName) + if err != nil { + return nil, errors.Wrap(err, "failed to get secure key from keychain") + } + defer secureKey.Close() // Make sure to close the secure key when done + + // Get the certificate chain + certChain := secureKey.CertificateChain() + if len(certChain) == 0 { + return nil, errors.New("no certificates found in chain") + } + + // Create TLS config with the secure key and certificates + tlsConfig := &tls.Config{ + GetClientCertificate: func(*tls.CertificateRequestInfo) (*tls.Certificate, error) { + return &tls.Certificate{ + Certificate: certChain, + PrivateKey: secureKey, + }, nil + }, + } + + // Create transport with the TLS config + transport := &http.Transport{ + TLSClientConfig: tlsConfig, + } + + client := &http.Client{Transport: transport} + + // Execute request + resp, err := client.Do(req) + if err != nil { + return nil, errors.Wrap(err, "failed to execute request") + } + defer resp.Body.Close() + + // Read response body + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, errors.Wrap(err, "failed to read response") + } + + // Check response status + if resp.StatusCode != http.StatusOK { + return nil, errors.Errorf("server returned non-200 status: %d, body: %s", + resp.StatusCode, string(body)) + } + return body, nil +} diff --git a/pkg/checkin/escrow_test.go b/pkg/checkin/escrow_test.go index c4fc7ef..cc0d75f 100644 --- a/pkg/checkin/escrow_test.go +++ b/pkg/checkin/escrow_test.go @@ -80,6 +80,14 @@ func (m *MockPref) Delete(key string) error { return nil } +func (m *MockPref) GetDate(key string) (time.Time, error) { + return time.Now(), nil +} + +func (m *MockPref) SetDate(key string, value time.Time) error { + return nil +} + func TestGetCommand(t *testing.T) { p := &MockPref{} @@ -272,6 +280,7 @@ func TestGetRecoveryKey(t *testing.T) { } key := keyPlist{RecoveryKey: "test_recovery_key"} + p := &MockPref{} tmpFile, err := os.CreateTemp(os.TempDir(), "crypt-testing-") assert.NoError(t, err) @@ -283,10 +292,215 @@ func TestGetRecoveryKey(t *testing.T) { err = os.WriteFile(tmpFile.Name(), plistBytes, 0644) assert.NoError(t, err) - out, err := getRecoveryKey(tmpFile.Name()) + out, err := getRecoveryKey(tmpFile.Name(), p) if err != nil { t.Fatalf("getRecoveryKey failed with error: %v", err) } assert.Equal(t, key.RecoveryKey, out) } + +// TestEscrowKeySignatureCheck just verifies the escrowKey function accepts the mTLS parameter +func TestEscrowKeySignatureCheck(t *testing.T) { + // No real test implementation, just verifying function signature + t.Run("verify function signature", func(t *testing.T) { + // We're not actually calling the function, just making sure + // the compiler recognizes the function signature with mTLScommonName + var _ = escrowKey + }) +} + +func TestBuildCryptData(t *testing.T) { + // Create a mock PrefInterface that returns specific values for testing + mockPref := &MockPref{} + + // Create a mock runner that returns specific values for system commands + mockRunner := utils.MockCmdRunner{ + Output: "enabled_user,19F18F252-781C-4754-820D-C49346C386C4", + Err: nil, + } + r := utils.Runner{} + r.Runner = mockRunner + + // Test building CryptData + cryptData, err := buildCryptData(mockPref, r) + assert.NoError(t, err) + assert.NotEmpty(t, cryptData.SerialNumber) + // GetConsoleUser returns the actual current user, so we just verify it's not empty + assert.NotEmpty(t, cryptData.EnabledUser) +} + +func TestRemoveInvalidKey(t *testing.T) { + t.Run("remove from plist", func(t *testing.T) { + // Create a temporary file + tempFile, err := os.CreateTemp("", "test_plist_*.plist") + assert.NoError(t, err) + defer os.Remove(tempFile.Name()) // clean up + + // Write some content to the file + err = os.WriteFile(tempFile.Name(), []byte("test content"), 0644) + assert.NoError(t, err) + + // Remove the invalid key (should delete the file) + err = removeInvalidKey(tempFile.Name(), false) + assert.NoError(t, err) + + // Verify the file was deleted + _, err = os.Stat(tempFile.Name()) + assert.True(t, os.IsNotExist(err)) + }) + + t.Run("remove from keychain", func(t *testing.T) { + // Test removing from keychain - this will fail on systems without keychain setup + // but we can test that the function calls the correct utility function + err := removeInvalidKey("", true) + // We expect this to potentially fail since we don't have a keychain setup in tests + // but we're testing that the function path is correct + assert.Error(t, err) // Expected to fail in test environment + }) +} + +// MockKeychainPref is a mock preference that indicates keychain usage +type MockKeychainPref struct { + MockPref +} + +func (m *MockKeychainPref) GetBool(key string) (bool, error) { + if key == "StoreRecoveryKeyInKeychain" { + return true, nil + } + return m.MockPref.GetBool(key) +} + +func TestGetRecoveryKeyWithKeychain(t *testing.T) { + // Create a mock pref that indicates keychain usage + mockPref := &MockKeychainPref{} + + // This test will fail in most environments since we don't have the keychain set up + // but it tests the code path + _, err := getRecoveryKey("", mockPref) + assert.Error(t, err) // Expected to fail since no keychain entry exists +} + +// MockExtendedPref extends MockPref to support additional functionality for testing +type MockExtendedPref struct { + MockPref + stringValues map[string]string + boolValues map[string]bool + intValues map[string]int + arrayValues map[string][]string + dateValues map[string]time.Time +} + +func NewMockExtendedPref() *MockExtendedPref { + return &MockExtendedPref{ + stringValues: make(map[string]string), + boolValues: make(map[string]bool), + intValues: make(map[string]int), + arrayValues: make(map[string][]string), + dateValues: make(map[string]time.Time), + } +} + +func (m *MockExtendedPref) GetString(key string) (string, error) { + if val, ok := m.stringValues[key]; ok { + return val, nil + } + return m.MockPref.GetString(key) +} + +func (m *MockExtendedPref) GetBool(key string) (bool, error) { + if val, ok := m.boolValues[key]; ok { + return val, nil + } + return m.MockPref.GetBool(key) +} + +func (m *MockExtendedPref) GetInt(key string) (int, error) { + if val, ok := m.intValues[key]; ok { + return val, nil + } + return m.MockPref.GetInt(key) +} + +func (m *MockExtendedPref) GetArray(key string) ([]string, error) { + if val, ok := m.arrayValues[key]; ok { + return val, nil + } + return m.MockPref.GetArray(key) +} + +func (m *MockExtendedPref) GetDate(key string) (time.Time, error) { + if val, ok := m.dateValues[key]; ok { + return val, nil + } + return m.MockPref.GetDate(key) +} + +func TestBuildCryptDataWithSkippedUser(t *testing.T) { + // Test buildCryptData when we get date information + mockPref := NewMockExtendedPref() + mockPref.arrayValues["SkipUsers"] = []string{"test_user"} + mockPref.dateValues["LastEscrow"] = time.Now().Add(-2 * time.Hour) + + // Mock runner that returns enabled users for getEnabledUser fallback + mockRunner := utils.MockCmdRunner{ + Output: "enabled_user,19F18F252-781C-4754-820D-C49346C386C4", + Err: nil, + } + r := utils.Runner{} + r.Runner = mockRunner + + cryptData, err := buildCryptData(mockPref, r) + assert.NoError(t, err) + assert.NotEmpty(t, cryptData.SerialNumber) + // GetConsoleUser returns the actual current user, so we just verify it's not empty + assert.NotEmpty(t, cryptData.EnabledUser) + assert.NotZero(t, cryptData.LastRun) +} + +func TestSendRequestErrorCases(t *testing.T) { + // Test sendRequest error cases without actually making network calls + // This tests the function structure and error handling + + t.Run("invalid URL", func(t *testing.T) { + // Test with invalid URL to trigger request creation error + _, err := sendRequest(":", "data", "commonName") + assert.Error(t, err) + }) +} + +func TestEscrowKeyConditionalBehavior(t *testing.T) { + // Test that escrowKey properly chooses between mTLS and curl + mockPref := NewMockExtendedPref() + mockPref.stringValues["ServerURL"] = "https://test.example.com" + + mockRunner := utils.MockCmdRunner{ + Output: "test_computer_name", + Err: nil, + } + r := utils.Runner{} + r.Runner = mockRunner + + cryptData := CryptData{ + SerialNumber: "test_serial", + RecoveryKey: "test_key", + EnabledUser: "test_user", + } + + t.Run("with mTLS common name", func(t *testing.T) { + // This should attempt mTLS path but will fail due to missing keychain setup + _, err := escrowKey(cryptData, r, mockPref, "test-common-name") + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to send request with mTLS") + }) + + t.Run("without mTLS common name", func(t *testing.T) { + // This should attempt curl path + _, err := escrowKey(cryptData, r, mockPref, "") + assert.Error(t, err) + // The exact error depends on what curl returns, but we expect some error + // since we're not actually making real network calls + assert.NotNil(t, err) + }) +} diff --git a/pkg/pref/pref.go b/pkg/pref/pref.go index b51e168..8d0cba9 100644 --- a/pkg/pref/pref.go +++ b/pkg/pref/pref.go @@ -31,7 +31,9 @@ Boolean Go_CFStringGetCString(CFStringRef str, char *buffer, CFIndex bufferSize, import "C" import ( "fmt" + "math" "os/user" + "time" "unsafe" "github.com/pkg/errors" @@ -40,13 +42,15 @@ import ( const BundleID = "com.grahamgilbert.crypt" var defaultPrefs = map[string]interface{}{ - "RemovePlist": true, - "RotateUsedKey": true, - "OutputPath": "/private/var/root/crypt_output.plist", - "ValidateKey": true, - "KeyEscrowInterval": 1, - "AdditionalCurlOpts": []string{}, - "ManageAuthMechs": true, + "RemovePlist": true, + "RotateUsedKey": true, + "OutputPath": "/private/var/root/crypt_output.plist", + "ValidateKey": true, + "KeyEscrowInterval": 1, + "AdditionalCurlOpts": []string{}, + "ManageAuthMechs": true, + "StoreRecoveryKeyInKeychain": true, + "CommonNameForEscrow": "", } func (p *Pref) Get(prefName string) (interface{}, error) { @@ -95,6 +99,8 @@ func (p *Pref) Get(prefName string) (interface{}, error) { return C.GoString(&buffer[0]), nil case C.CFBooleanGetTypeID(): return C.CFBooleanGetValue(C.CFBooleanRef(prefValue)) != 0, nil + case C.CFDateGetTypeID(): + return CFDateToTime(C.CFDateRef(prefValue)), nil case C.CFNumberGetTypeID(): var num int C.CFNumberGetValue(C.CFNumberRef(prefValue), C.kCFNumberIntType, unsafe.Pointer(&num)) @@ -164,6 +170,9 @@ func (p *Pref) Set(prefName string, prefValue interface{}) error { } case int: args = append(args, "-int", fmt.Sprintf("%d", prefValue)) + case time.Time: + iso8601 := v.UTC().Format(time.RFC3339) + args = append(args, "-date", iso8601) case []string: args = append(args, "-array") for _, s := range prefValue.([]string) { //nolint:gosimple @@ -180,3 +189,20 @@ func (p *Pref) Set(prefName string, prefValue interface{}) error { return nil } + +const nsPerSec = 1000 * 1000 * 1000 + +func absoluteTimeToUnix(abs C.CFAbsoluteTime) (int64, int64) { + int, frac := math.Modf(float64(abs)) + return int64(int) + absoluteTimeIntervalSince1970(), int64(frac * nsPerSec) +} + +func absoluteTimeIntervalSince1970() int64 { + return int64(C.kCFAbsoluteTimeIntervalSince1970) +} + +func CFDateToTime(d C.CFDateRef) time.Time { + abs := C.CFDateGetAbsoluteTime(d) + s, ns := absoluteTimeToUnix(abs) + return time.Unix(s, ns) +} diff --git a/pkg/pref/pref_helpers.go b/pkg/pref/pref_helpers.go index 957c80e..0120381 100644 --- a/pkg/pref/pref_helpers.go +++ b/pkg/pref/pref_helpers.go @@ -1,6 +1,8 @@ package pref import ( + "time" + "github.com/grahamgilbert/crypt/pkg/utils" "github.com/pkg/errors" ) @@ -15,10 +17,12 @@ type PrefInterface interface { GetBool(prefName string) (bool, error) GetInt(prefName string) (int, error) GetArray(prefName string) ([]string, error) + GetDate(prefName string) (time.Time, error) SetString(prefName string, value string) error SetBool(prefName string, value bool) error SetInt(prefName string, value int) error SetArray(prefName string, prefValue []string) error + SetDate(prefName string, value time.Time) error Get(prefName string) (interface{}, error) Set(prefName string, value interface{}) error } @@ -87,6 +91,18 @@ func (p *Pref) GetArray(prefName string) ([]string, error) { return value.([]string), nil } +// GetDate returns the value of a preference as a date +func (p *Pref) GetDate(prefName string) (time.Time, error) { + value, err := p.Get(prefName) + if err != nil { + return time.Time{}, errors.Wrapf(err, "failed to get preference %s", prefName) + } + if value == nil { + return time.Time{}, nil + } + return value.(time.Time), nil +} + // SetString sets the value of a preference as a string func (p *Pref) SetString(prefName string, value string) error { return p.Set(prefName, value) @@ -106,3 +122,8 @@ func (p *Pref) SetInt(prefName string, value int) error { func (p *Pref) SetArray(prefName string, prefValue []string) error { return p.Set(prefName, prefValue) } + +// SetDate sets the value of a preference as a date +func (p *Pref) SetDate(prefName string, value time.Time) error { + return p.Set(prefName, value) +} diff --git a/pkg/utils/BUILD.bazel b/pkg/utils/BUILD.bazel index 3ff1c97..66ffa1d 100644 --- a/pkg/utils/BUILD.bazel +++ b/pkg/utils/BUILD.bazel @@ -11,14 +11,26 @@ go_library( "get_computer_name.go", "os_version.go", "string_in_slice.go", + "keychain.go", + "serial.go", ], cgo = True, clinkopts = select({ "@io_bazel_rules_go//go/platform:darwin": [ "-framework SystemConfiguration", + "-framework CoreFoundation", + "-framework Security", ], "@io_bazel_rules_go//go/platform:ios": [ "-framework SystemConfiguration", + "-framework CoreFoundation", + "-framework Security", + ], + "//conditions:default": [], + }), + copts = select({ + "@io_bazel_rules_go//go/platform:darwin": [ + "-mmacosx-version-min=13.0", ], "//conditions:default": [], }), @@ -33,6 +45,7 @@ go_test( "curl_test.go", "exec_test.go", "get_computer_name_test.go", + "keychain_test.go", "os_version_test.go", "string_in_slice_test.go", ], diff --git a/pkg/utils/curl_test.go b/pkg/utils/curl_test.go index d20b1ee..cf1c616 100644 --- a/pkg/utils/curl_test.go +++ b/pkg/utils/curl_test.go @@ -1,6 +1,7 @@ package utils import ( + "strings" "testing" "github.com/stretchr/testify/assert" @@ -26,9 +27,16 @@ func TestBuildCurlConfigFile(t *testing.T) { "key1": `value1`, "key2": `value"2`, } - want := `key1 = "value1" -key2 = "value\"2"` + expectedLines := []string{ + `key1 = "value1"`, + `key2 = "value\"2"`, + } got := BuildCurlConfigFile(d) - assert.Equal(t, want, got) + gotLines := strings.Split(got, "\n") + + assert.Equal(t, len(expectedLines), len(gotLines), "Number of lines should match") + for _, line := range expectedLines { + assert.Contains(t, gotLines, line, "Output should contain the expected line") + } } diff --git a/pkg/utils/keychain.go b/pkg/utils/keychain.go new file mode 100644 index 0000000..77a7485 --- /dev/null +++ b/pkg/utils/keychain.go @@ -0,0 +1,133 @@ +//go:build darwin +// +build darwin + +package utils + +/* +#cgo LDFLAGS: -framework CoreFoundation -framework Security +#include +#include +#include +#include +*/ +import "C" +import ( + "errors" + "fmt" + "strings" + "sync" + "unsafe" +) + +const service = "com.grahamgilbert.crypt.recovery" + +var serviceStringRef = stringToCFString(service) +var mu sync.Mutex + +// AddSecret will add a secret to the keychain. This secret can be retrieved by this application without any user authorization. +func AddSecret(secret string) error { + secret = strings.TrimSpace(secret) + if secret == "" { + return errors.New("secret cannot be empty") + } + + mu.Lock() + defer mu.Unlock() + + query := C.CFDictionaryCreateMutable( + C.kCFAllocatorDefault, + 0, + &C.kCFTypeDictionaryKeyCallBacks, + &C.kCFTypeDictionaryValueCallBacks, //nolint:gocritic // dubSubExpr false positive + ) + defer C.CFRelease(C.CFTypeRef(query)) + + data := C.CFDataCreate(C.kCFAllocatorDefault, (*C.UInt8)(&[]byte(secret)[0]), C.CFIndex(len(secret))) + defer C.CFRelease(C.CFTypeRef(data)) + + C.CFDictionaryAddValue(query, unsafe.Pointer(C.kSecClass), unsafe.Pointer(C.kSecClassGenericPassword)) + C.CFDictionaryAddValue(query, unsafe.Pointer(C.kSecAttrService), unsafe.Pointer(serviceStringRef)) + C.CFDictionaryAddValue(query, unsafe.Pointer(C.kSecValueData), unsafe.Pointer(data)) + + status := C.SecItemAdd(C.CFDictionaryRef(query), nil) + if status != C.errSecSuccess { + return fmt.Errorf("failed to add %v to keychain: %v", service, status) + } + return nil +} + +// GetSecret retrieves a secret from the macOS keychain. +// It creates a query dictionary to search for a generic password item with a specific label. +// If the item is found, it returns the secret as a string. +// If the item is not found, it returns an empty string. +// If an error occurs during the retrieval, it returns an error. +// +// Returns: +// - string: The secret retrieved from the keychain, or an empty string if not found. +// - error: An error if the retrieval fails, or nil if successful. +func GetSecret() (string, error) { + mu.Lock() + defer mu.Unlock() + + query := C.CFDictionaryCreateMutable( + C.kCFAllocatorDefault, + 0, + &C.kCFTypeDictionaryKeyCallBacks, + &C.kCFTypeDictionaryValueCallBacks, //nolint:gocritic // dubSubExpr false positive + ) + defer C.CFRelease(C.CFTypeRef(query)) + + C.CFDictionaryAddValue(query, unsafe.Pointer(C.kSecClass), unsafe.Pointer(C.kSecClassGenericPassword)) + C.CFDictionaryAddValue(query, unsafe.Pointer(C.kSecReturnData), unsafe.Pointer(C.kCFBooleanTrue)) + C.CFDictionaryAddValue(query, unsafe.Pointer(C.kSecMatchLimit), unsafe.Pointer(C.kSecMatchLimitOne)) + C.CFDictionaryAddValue(query, unsafe.Pointer(C.kSecAttrLabel), unsafe.Pointer(serviceStringRef)) + + var data C.CFTypeRef + status := C.SecItemCopyMatching(C.CFDictionaryRef(query), &data) //nolint:gocritic // dubSubExpr false positive + if status != C.errSecSuccess { + if status == C.errSecItemNotFound { + return "", fmt.Errorf("could not find %v in keychain", service) + } + return "", fmt.Errorf("failed to retrieve %v from keychain: %v", service, status) + } + defer C.CFRelease(data) + + secret := C.CFDataGetBytePtr(C.CFDataRef(data)) + return C.GoString((*C.char)(unsafe.Pointer(secret))), nil +} + +// deleteSecret will delete a secret from the keychain. +func DeleteSecret() error { + mu.Lock() + defer mu.Unlock() + + query := C.CFDictionaryCreateMutable( + C.kCFAllocatorDefault, + 0, + &C.kCFTypeDictionaryKeyCallBacks, + &C.kCFTypeDictionaryValueCallBacks, //nolint:gocritic // dubSubExpr false positive + ) + defer C.CFRelease(C.CFTypeRef(query)) + + C.CFDictionaryAddValue(query, unsafe.Pointer(C.kSecClass), unsafe.Pointer(C.kSecClassGenericPassword)) + C.CFDictionaryAddValue(query, unsafe.Pointer(C.kSecMatchLimit), unsafe.Pointer(C.kSecMatchLimitOne)) + C.CFDictionaryAddValue(query, unsafe.Pointer(C.kSecAttrLabel), unsafe.Pointer(serviceStringRef)) + + status := C.SecItemDelete(C.CFDictionaryRef(query)) + if status != C.errSecSuccess { + return fmt.Errorf("failed to delete %v from keychain: %v", service, status) + } + return nil +} + +// stringToCFString will return a CFStringRef +func stringToCFString(s string) C.CFStringRef { + bytes := []byte(s) + ptr := (*C.UInt8)(&bytes[0]) + return C.CFStringCreateWithBytes(C.kCFAllocatorDefault, ptr, C.CFIndex(len(bytes)), C.kCFStringEncodingUTF8, C.false) +} + +// releaseCFString will release memory allocated for a CFStringRef +func releaseCFString(s C.CFStringRef) { + C.CFRelease(C.CFTypeRef(s)) +} diff --git a/pkg/utils/keychain_test.go b/pkg/utils/keychain_test.go new file mode 100644 index 0000000..74b775c --- /dev/null +++ b/pkg/utils/keychain_test.go @@ -0,0 +1,124 @@ +//go:build darwin +// +build darwin + +package utils + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestKeychainFunctionality(t *testing.T) { + // Note: These tests interact with the actual macOS keychain + // They may fail in CI environments that don't have keychain access + + const testSecret = "test-recovery-key-12345" + + t.Run("add and retrieve secret", func(t *testing.T) { + // Clean up any existing test secret first + _ = DeleteSecret() // Ignore error if nothing exists + + // Add a secret to keychain + err := AddSecret(testSecret) + if err != nil { + t.Skipf("Skipping keychain test - keychain not available: %v", err) + } + + // Retrieve the secret + retrievedSecret, err := GetSecret() + assert.NoError(t, err) + assert.Equal(t, testSecret, retrievedSecret) + + // Clean up + err = DeleteSecret() + assert.NoError(t, err) + }) + + t.Run("get nonexistent secret", func(t *testing.T) { + // Clean up any existing secret first + _ = DeleteSecret() // Ignore error if nothing exists + + // Try to get a secret that doesn't exist + _, err := GetSecret() + assert.Error(t, err) + assert.Contains(t, err.Error(), "could not find") + }) + + t.Run("add empty secret", func(t *testing.T) { + // Try to add an empty secret + err := AddSecret("") + assert.Error(t, err) + assert.Contains(t, err.Error(), "secret cannot be empty") + }) + + t.Run("add whitespace-only secret", func(t *testing.T) { + // Try to add a whitespace-only secret + err := AddSecret(" \n\t ") + assert.Error(t, err) + assert.Contains(t, err.Error(), "secret cannot be empty") + }) + + t.Run("delete nonexistent secret", func(t *testing.T) { + // Clean up any existing secret first + _ = DeleteSecret() // Ignore error if nothing exists + + // Try to delete a secret that doesn't exist + err := DeleteSecret() + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to delete") + }) + + t.Run("update existing secret", func(t *testing.T) { + // Clean up any existing test secret first + _ = DeleteSecret() // Ignore error if nothing exists + + const firstSecret = "first-test-secret" + const secondSecret = "second-test-secret" + + // Add first secret + err := AddSecret(firstSecret) + if err != nil { + t.Skipf("Skipping keychain test - keychain not available: %v", err) + } + + // Try to add second secret (should fail because item already exists) + err = AddSecret(secondSecret) + assert.Error(t, err) + + // Verify first secret is still there + retrievedSecret, err := GetSecret() + assert.NoError(t, err) + assert.Equal(t, firstSecret, retrievedSecret) + + // Clean up + err = DeleteSecret() + assert.NoError(t, err) + }) +} + +func TestKeychainWithTrimming(t *testing.T) { + // Test that secrets are properly trimmed before storage + const secretWithWhitespace = " test-secret-with-whitespace \n" + const expectedSecret = "test-secret-with-whitespace" + + t.Run("secret trimming", func(t *testing.T) { + // Clean up any existing test secret first + _ = DeleteSecret() // Ignore error if nothing exists + + // Add secret with whitespace + err := AddSecret(secretWithWhitespace) + if err != nil { + t.Skipf("Skipping keychain test - keychain not available: %v", err) + } + + // Retrieve and verify it was trimmed + retrievedSecret, err := GetSecret() + assert.NoError(t, err) + assert.Equal(t, expectedSecret, retrievedSecret) + + // Clean up + err = DeleteSecret() + assert.NoError(t, err) + }) +} diff --git a/pkg/utils/serial.go b/pkg/utils/serial.go new file mode 100644 index 0000000..651d641 --- /dev/null +++ b/pkg/utils/serial.go @@ -0,0 +1,51 @@ +package utils + +/* +#cgo LDFLAGS: -framework CoreFoundation -framework IOKit +#include +#include + +void getSerialNumber(char *serialNumberBuf, int serialNumberBufLen) +{ + CFMutableDictionaryRef matching = IOServiceMatching("IOPlatformExpertDevice"); + io_service_t service = IOServiceGetMatchingService(kIOMainPortDefault, matching); + + CFStringRef serialNumber = IORegistryEntryCreateCFProperty(service, + CFSTR("IOPlatformSerialNumber"), kCFAllocatorDefault, 0); + + if (serialNumber) { + CFStringGetCString(serialNumber, serialNumberBuf, serialNumberBufLen, kCFStringEncodingUTF8); + } + + IOObjectRelease(service); +} +*/ +import "C" +import ( + "strings" + "sync" + "unsafe" + + "golang.org/x/sys/unix" +) + +var ( + value string + once sync.Once +) + +// Get returns the serial number of the machine. The value is cached after the first call. +func GetSerial() string { + once.Do(func() { + // Serial numbers are between 8 and 14 characters long, leave some room just in case + var serialBuf [65]byte + + C.getSerialNumber( + (*C.char)(unsafe.Pointer(&serialBuf[0])), + C.int(len(serialBuf)), + ) + + value = strings.ToValidUTF8(unix.ByteSliceToString(serialBuf[:]), "") + }) + return value +}