Capture the current unreleased K1 connection, recovery, lifecycle, viewer, and test work as a single known-bad baseline for subsequent fixes.
884 lines
30 KiB
Swift
884 lines
30 KiB
Swift
import CoreWLAN
|
|
import CryptoKit
|
|
import Foundation
|
|
import LocalAuthentication
|
|
import Security
|
|
|
|
private let keychainService = "NODEDC Mission Core Host Wi-Fi Profiles"
|
|
private let credentialMaterialKeychainService =
|
|
"NODEDC Mission Core Device Credential Materials"
|
|
|
|
private struct HostWifiRequest: Decodable {
|
|
let action: String
|
|
let profileID: String
|
|
let ssid: String?
|
|
let password: String?
|
|
let scanTimeoutSeconds: Double?
|
|
let credentialSourceID: String?
|
|
let interfaceName: String?
|
|
let continuityKeyHex: String?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case action
|
|
case profileID = "profile_id"
|
|
case ssid
|
|
case password
|
|
case scanTimeoutSeconds = "scan_timeout_seconds"
|
|
case credentialSourceID = "credential_source_id"
|
|
case interfaceName = "interface_name"
|
|
case continuityKeyHex = "continuity_key_hex"
|
|
}
|
|
}
|
|
|
|
private struct StoredProfile: Codable {
|
|
let schemaVersion: Int
|
|
let ssid: String
|
|
let password: String
|
|
let credentialSource: String?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case schemaVersion = "schema_version"
|
|
case ssid
|
|
case password
|
|
case credentialSource = "credential_source"
|
|
}
|
|
}
|
|
|
|
private struct StoredCredentialMaterial: Codable {
|
|
let schemaVersion: Int
|
|
let password: String
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case schemaVersion = "schema_version"
|
|
case password
|
|
}
|
|
}
|
|
|
|
private struct HostWifiResponse: Encodable {
|
|
let ok: Bool
|
|
let adapter: String?
|
|
let alreadyAssociated: Bool?
|
|
let stored: Bool?
|
|
let found: Bool?
|
|
let profileAvailable: Bool?
|
|
let profileEnrolled: Bool?
|
|
let scanAttemptCount: Int?
|
|
let scanElapsedMilliseconds: Int?
|
|
let credentialSource: String?
|
|
let wifiInterface: Bool?
|
|
let associationIdentity: String?
|
|
let associationEvidence: String?
|
|
let reasonCode: String?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case ok
|
|
case adapter
|
|
case alreadyAssociated = "already_associated"
|
|
case stored
|
|
case found
|
|
case profileAvailable = "profile_available"
|
|
case profileEnrolled = "profile_enrolled"
|
|
case scanAttemptCount = "scan_attempt_count"
|
|
case scanElapsedMilliseconds = "scan_elapsed_ms"
|
|
case credentialSource = "credential_source"
|
|
case wifiInterface = "wifi_interface"
|
|
case associationIdentity = "association_identity"
|
|
case associationEvidence = "association_evidence"
|
|
case reasonCode = "reason_code"
|
|
}
|
|
}
|
|
|
|
private func emit(
|
|
ok: Bool,
|
|
adapter: String? = nil,
|
|
alreadyAssociated: Bool? = nil,
|
|
stored: Bool? = nil,
|
|
found: Bool? = nil,
|
|
profileAvailable: Bool? = nil,
|
|
profileEnrolled: Bool? = nil,
|
|
scanAttemptCount: Int? = nil,
|
|
scanElapsedMilliseconds: Int? = nil,
|
|
credentialSource: String? = nil,
|
|
wifiInterface: Bool? = nil,
|
|
associationIdentity: String? = nil,
|
|
associationEvidence: String? = nil,
|
|
reasonCode: String? = nil,
|
|
exitCode: Int32
|
|
) -> Never {
|
|
let response = HostWifiResponse(
|
|
ok: ok,
|
|
adapter: adapter,
|
|
alreadyAssociated: alreadyAssociated,
|
|
stored: stored,
|
|
found: found,
|
|
profileAvailable: profileAvailable,
|
|
profileEnrolled: profileEnrolled,
|
|
scanAttemptCount: scanAttemptCount,
|
|
scanElapsedMilliseconds: scanElapsedMilliseconds,
|
|
credentialSource: credentialSource,
|
|
wifiInterface: wifiInterface,
|
|
associationIdentity: associationIdentity,
|
|
associationEvidence: associationEvidence,
|
|
reasonCode: reasonCode
|
|
)
|
|
if let data = try? JSONEncoder().encode(response) {
|
|
FileHandle.standardOutput.write(data)
|
|
}
|
|
exit(exitCode)
|
|
}
|
|
|
|
private func profileIsValid(_ profile: StoredProfile) -> Bool {
|
|
guard profile.schemaVersion == 1,
|
|
let ssidData = profile.ssid.data(using: .utf8),
|
|
(1 ... 32).contains(ssidData.count),
|
|
let passwordData = profile.password.data(using: .utf8),
|
|
(1 ... 64).contains(passwordData.count)
|
|
else {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
private func credentialMaterialIsValid(_ material: StoredCredentialMaterial) -> Bool {
|
|
guard material.schemaVersion == 1,
|
|
let passwordData = material.password.data(using: .utf8),
|
|
(8 ... 63).contains(passwordData.count)
|
|
else {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
private func keychainQuery(service: String, account: String) -> [String: Any] {
|
|
return [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: service,
|
|
kSecAttrAccount as String: account,
|
|
]
|
|
}
|
|
|
|
private func profileKeychainQuery(profileID: String) -> [String: Any] {
|
|
return keychainQuery(service: keychainService, account: profileID)
|
|
}
|
|
|
|
private func materialKeychainQuery(sourceID: String) -> [String: Any] {
|
|
return keychainQuery(service: credentialMaterialKeychainService, account: sourceID)
|
|
}
|
|
|
|
private func nonInteractiveAuthenticationContext() -> LAContext {
|
|
let context = LAContext()
|
|
context.interactionNotAllowed = true
|
|
return context
|
|
}
|
|
|
|
private func keychainItemExists(service: String, account: String) throws -> Bool {
|
|
var query = keychainQuery(service: service, account: account)
|
|
query[kSecReturnAttributes as String] = true
|
|
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
|
// Preflight is deliberately non-interactive. Authorization prompts belong
|
|
// only to an explicit enrollment/migration step, never to a K1 network
|
|
// mutation that has already been admitted by the browser.
|
|
query[kSecUseAuthenticationContext as String] = nonInteractiveAuthenticationContext()
|
|
|
|
var item: CFTypeRef?
|
|
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
|
if status == errSecSuccess {
|
|
return true
|
|
}
|
|
if status == errSecItemNotFound {
|
|
return false
|
|
}
|
|
throw NSError(domain: "HostWifiKeychainMetadata", code: Int(status))
|
|
}
|
|
|
|
private func keychainReasonCode(_ error: Error, missing: String) -> String {
|
|
let status = OSStatus((error as NSError).code)
|
|
switch status {
|
|
case errSecItemNotFound:
|
|
return missing
|
|
case errSecInteractionNotAllowed:
|
|
return "keychain-authorization-required"
|
|
case errSecAuthFailed:
|
|
return "keychain-authorization-denied"
|
|
case errSecUserCanceled:
|
|
return "keychain-authorization-cancelled"
|
|
default:
|
|
return "keychain-access-failed"
|
|
}
|
|
}
|
|
|
|
private func coreWLANReasonCode(_ error: Error) -> String {
|
|
let nsError = error as NSError
|
|
guard nsError.domain == CWErrorDomain else {
|
|
return "corewlan-error"
|
|
}
|
|
// Stable CWErr values from Apple's CoreWLANTypes contract. Export only a
|
|
// reviewed failure class; NSError descriptions may contain host details.
|
|
switch nsError.code {
|
|
case -3930: // kCWOperationNotPermittedErr
|
|
return "corewlan-authorization-denied"
|
|
case -3905, -3925: // kCWTimeoutErr, kCWSupplicantTimeoutErr
|
|
return "host-wifi-operation-timeout"
|
|
default:
|
|
return "corewlan-error"
|
|
}
|
|
}
|
|
|
|
private func loadProfile(
|
|
profileID: String,
|
|
interactionAllowed: Bool = true
|
|
) throws -> StoredProfile {
|
|
var query = profileKeychainQuery(profileID: profileID)
|
|
query[kSecReturnData as String] = true
|
|
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
|
if !interactionAllowed {
|
|
query[kSecUseAuthenticationContext as String] = nonInteractiveAuthenticationContext()
|
|
}
|
|
|
|
var item: CFTypeRef?
|
|
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
|
guard status == errSecSuccess, let data = item as? Data else {
|
|
throw NSError(domain: "HostWifiProfile", code: Int(status))
|
|
}
|
|
let profile = try JSONDecoder().decode(StoredProfile.self, from: data)
|
|
guard profileIsValid(profile) else {
|
|
throw NSError(domain: "HostWifiProfile", code: Int(errSecDecode))
|
|
}
|
|
return profile
|
|
}
|
|
|
|
private func storeProfile(profileID: String, profile: StoredProfile) throws {
|
|
guard profileIsValid(profile) else {
|
|
throw NSError(domain: "HostWifiProfile", code: Int(errSecParam))
|
|
}
|
|
let data = try JSONEncoder().encode(profile)
|
|
let query = profileKeychainQuery(profileID: profileID)
|
|
let attributes = [
|
|
kSecValueData as String: data,
|
|
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
|
] as [String: Any]
|
|
|
|
let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
|
|
if updateStatus == errSecSuccess {
|
|
return
|
|
}
|
|
guard updateStatus == errSecItemNotFound else {
|
|
throw NSError(domain: "HostWifiProfile", code: Int(updateStatus))
|
|
}
|
|
var newItem = query
|
|
attributes.forEach { key, value in newItem[key] = value }
|
|
let addStatus = SecItemAdd(newItem as CFDictionary, nil)
|
|
guard addStatus == errSecSuccess else {
|
|
throw NSError(domain: "HostWifiProfile", code: Int(addStatus))
|
|
}
|
|
}
|
|
|
|
private func loadCredentialMaterial(
|
|
sourceID: String,
|
|
interactionAllowed: Bool = true
|
|
) throws -> StoredCredentialMaterial {
|
|
var query = materialKeychainQuery(sourceID: sourceID)
|
|
query[kSecReturnData as String] = true
|
|
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
|
if !interactionAllowed {
|
|
query[kSecUseAuthenticationContext as String] = nonInteractiveAuthenticationContext()
|
|
}
|
|
|
|
var item: CFTypeRef?
|
|
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
|
guard status == errSecSuccess, let data = item as? Data else {
|
|
throw NSError(domain: "HostWifiCredentialMaterial", code: Int(status))
|
|
}
|
|
let material = try JSONDecoder().decode(StoredCredentialMaterial.self, from: data)
|
|
guard credentialMaterialIsValid(material) else {
|
|
throw NSError(domain: "HostWifiCredentialMaterial", code: Int(errSecDecode))
|
|
}
|
|
return material
|
|
}
|
|
|
|
private func storeCredentialMaterial(
|
|
sourceID: String,
|
|
material: StoredCredentialMaterial
|
|
) throws {
|
|
guard credentialMaterialIsValid(material) else {
|
|
throw NSError(domain: "HostWifiCredentialMaterial", code: Int(errSecParam))
|
|
}
|
|
let data = try JSONEncoder().encode(material)
|
|
let query = materialKeychainQuery(sourceID: sourceID)
|
|
let attributes = [
|
|
kSecValueData as String: data,
|
|
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
|
] as [String: Any]
|
|
|
|
let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
|
|
if updateStatus == errSecSuccess {
|
|
return
|
|
}
|
|
guard updateStatus == errSecItemNotFound else {
|
|
throw NSError(domain: "HostWifiCredentialMaterial", code: Int(updateStatus))
|
|
}
|
|
var newItem = query
|
|
attributes.forEach { key, value in newItem[key] = value }
|
|
let addStatus = SecItemAdd(newItem as CFDictionary, nil)
|
|
guard addStatus == errSecSuccess else {
|
|
throw NSError(domain: "HostWifiCredentialMaterial", code: Int(addStatus))
|
|
}
|
|
}
|
|
|
|
private struct TargetedScanResult {
|
|
let network: CWNetwork?
|
|
let attemptCount: Int
|
|
let elapsedMilliseconds: Int
|
|
}
|
|
|
|
private func scanForExpectedNetwork(
|
|
interface: CWInterface,
|
|
ssid: String,
|
|
ssidData: Data,
|
|
timeoutSeconds: Double
|
|
) throws -> TargetedScanResult {
|
|
let started = ProcessInfo.processInfo.systemUptime
|
|
var attemptCount = 0
|
|
|
|
while true {
|
|
attemptCount += 1
|
|
let networks = try interface.scanForNetworks(withSSID: ssidData)
|
|
if let network = networks.first(where: { $0.ssid == ssid }) {
|
|
return TargetedScanResult(
|
|
network: network,
|
|
attemptCount: attemptCount,
|
|
elapsedMilliseconds: max(
|
|
0,
|
|
Int((ProcessInfo.processInfo.systemUptime - started) * 1_000)
|
|
)
|
|
)
|
|
}
|
|
|
|
let elapsed = ProcessInfo.processInfo.systemUptime - started
|
|
let remaining = timeoutSeconds - elapsed
|
|
if remaining <= 0 {
|
|
return TargetedScanResult(
|
|
network: nil,
|
|
attemptCount: attemptCount,
|
|
elapsedMilliseconds: max(0, Int(elapsed * 1_000))
|
|
)
|
|
}
|
|
Thread.sleep(forTimeInterval: min(0.75, remaining))
|
|
}
|
|
}
|
|
|
|
private func decodeContinuityKey(_ value: String) -> Data? {
|
|
let bytes = Array(value.utf8)
|
|
guard bytes.count == 64 else {
|
|
return nil
|
|
}
|
|
func nibble(_ byte: UInt8) -> UInt8? {
|
|
switch byte {
|
|
case 48 ... 57:
|
|
return byte - 48
|
|
case 97 ... 102:
|
|
return byte - 87
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
var decoded = Data(capacity: 32)
|
|
for offset in stride(from: 0, to: bytes.count, by: 2) {
|
|
guard let high = nibble(bytes[offset]), let low = nibble(bytes[offset + 1]) else {
|
|
return nil
|
|
}
|
|
decoded.append((high << 4) | low)
|
|
}
|
|
return decoded
|
|
}
|
|
|
|
private func appendLengthPrefixed(_ value: String, to material: inout Data) {
|
|
let data = Data(value.utf8)
|
|
var length = UInt32(data.count).bigEndian
|
|
withUnsafeBytes(of: &length) { bytes in
|
|
material.append(contentsOf: bytes)
|
|
}
|
|
material.append(data)
|
|
}
|
|
|
|
private func associationIdentity(
|
|
continuityKey: Data,
|
|
interfaceName: String,
|
|
bssid: String
|
|
) -> String {
|
|
// BSSID is the association identity. SSID visibility is permission- and
|
|
// timing-dependent on macOS, so folding it into this token would rotate a
|
|
// healthy binding when the same AP alternates between `ssid+bssid` and
|
|
// `bssid-only` evidence.
|
|
var material = Data("mission-core/host-wifi-association/v2".utf8)
|
|
appendLengthPrefixed(interfaceName, to: &material)
|
|
appendLengthPrefixed(bssid.lowercased(), to: &material)
|
|
let digest = HMAC<SHA256>.authenticationCode(
|
|
for: material,
|
|
using: SymmetricKey(data: continuityKey)
|
|
)
|
|
return digest.map { String(format: "%02x", $0) }.joined()
|
|
}
|
|
|
|
private let input = FileHandle.standardInput.readDataToEndOfFile()
|
|
guard input.count <= 2048 else {
|
|
emit(ok: false, reasonCode: "request-too-large", exitCode: 1)
|
|
}
|
|
|
|
do {
|
|
let request = try JSONDecoder().decode(HostWifiRequest.self, from: input)
|
|
guard (1 ... 128).contains(request.profileID.count),
|
|
request.profileID.allSatisfy({
|
|
$0.isASCII && ($0.isLetter || $0.isNumber || ".-_".contains($0))
|
|
})
|
|
else {
|
|
emit(ok: false, reasonCode: "profile-id-invalid", exitCode: 1)
|
|
}
|
|
|
|
if request.action == "inspect-association" {
|
|
guard let interfaceName = request.interfaceName,
|
|
(1 ... 32).contains(interfaceName.count),
|
|
interfaceName.allSatisfy({
|
|
$0.isASCII && ($0.isLetter || $0.isNumber || ".-_".contains($0))
|
|
}),
|
|
let continuityKeyHex = request.continuityKeyHex,
|
|
let continuityKey = decodeContinuityKey(continuityKeyHex)
|
|
else {
|
|
emit(ok: false, reasonCode: "association-inspection-invalid", exitCode: 1)
|
|
}
|
|
|
|
guard let interface = CWWiFiClient.shared().interface(withName: interfaceName) else {
|
|
emit(
|
|
ok: true,
|
|
adapter: "CoreWLAN",
|
|
wifiInterface: false,
|
|
associationIdentity: associationIdentity(
|
|
continuityKey: continuityKey,
|
|
interfaceName: interfaceName,
|
|
bssid: "not-wifi-interface"
|
|
),
|
|
associationEvidence: "not-wifi",
|
|
exitCode: 0
|
|
)
|
|
}
|
|
guard interface.powerOn(), interface.serviceActive() else {
|
|
emit(
|
|
ok: true,
|
|
adapter: "CoreWLAN",
|
|
wifiInterface: true,
|
|
associationEvidence: "unavailable",
|
|
reasonCode: "wifi-interface-inactive",
|
|
exitCode: 0
|
|
)
|
|
}
|
|
let currentSSID = interface.ssid()?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let currentBSSID = interface.bssid()?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard let currentBSSID, !currentBSSID.isEmpty else {
|
|
// SSID alone is not an exact association identity: two APs may use
|
|
// the same network name. Returning no digest forces the Python
|
|
// caller to rotate its fail-closed continuity token.
|
|
emit(
|
|
ok: true,
|
|
adapter: "CoreWLAN",
|
|
wifiInterface: true,
|
|
associationEvidence: "unavailable",
|
|
reasonCode: "association-identity-unavailable",
|
|
exitCode: 0
|
|
)
|
|
}
|
|
emit(
|
|
ok: true,
|
|
adapter: "CoreWLAN",
|
|
wifiInterface: true,
|
|
associationIdentity: associationIdentity(
|
|
continuityKey: continuityKey,
|
|
interfaceName: interfaceName,
|
|
bssid: currentBSSID
|
|
),
|
|
associationEvidence: (
|
|
currentSSID == nil || currentSSID?.isEmpty == true
|
|
? "bssid-only"
|
|
: "ssid+bssid"
|
|
),
|
|
exitCode: 0
|
|
)
|
|
}
|
|
|
|
if request.action == "store-profile" {
|
|
guard let ssid = request.ssid, let password = request.password else {
|
|
emit(ok: false, reasonCode: "credential-missing", exitCode: 1)
|
|
}
|
|
try storeProfile(
|
|
profileID: request.profileID,
|
|
profile: StoredProfile(
|
|
schemaVersion: 1,
|
|
ssid: ssid,
|
|
password: password,
|
|
credentialSource: nil
|
|
)
|
|
)
|
|
emit(ok: true, adapter: "macOS Keychain", stored: true, exitCode: 0)
|
|
}
|
|
|
|
if request.action == "store-credential-material" {
|
|
guard let password = request.password else {
|
|
emit(ok: false, reasonCode: "credential-missing", exitCode: 1)
|
|
}
|
|
try storeCredentialMaterial(
|
|
sourceID: request.profileID,
|
|
material: StoredCredentialMaterial(schemaVersion: 1, password: password)
|
|
)
|
|
emit(ok: true, adapter: "macOS Keychain", stored: true, exitCode: 0)
|
|
}
|
|
|
|
if request.action == "check-credential-material" {
|
|
do {
|
|
let available = try keychainItemExists(
|
|
service: credentialMaterialKeychainService,
|
|
account: request.profileID
|
|
)
|
|
emit(
|
|
ok: true,
|
|
adapter: "macOS Keychain",
|
|
profileAvailable: available,
|
|
credentialSource: available ? "exact-firmware-profile" : nil,
|
|
exitCode: 0
|
|
)
|
|
} catch {
|
|
emit(
|
|
ok: false,
|
|
adapter: "macOS Keychain",
|
|
profileAvailable: false,
|
|
reasonCode: keychainReasonCode(
|
|
error,
|
|
missing: "credential-source-unavailable"
|
|
),
|
|
exitCode: 1
|
|
)
|
|
}
|
|
}
|
|
|
|
if request.action == "ensure-profile" {
|
|
guard let ssid = request.ssid,
|
|
let ssidData = ssid.data(using: .utf8),
|
|
(1 ... 32).contains(ssidData.count),
|
|
let sourceID = request.credentialSourceID,
|
|
(1 ... 128).contains(sourceID.count),
|
|
sourceID.allSatisfy({
|
|
$0.isASCII && ($0.isLetter || $0.isNumber || ".-_".contains($0))
|
|
})
|
|
else {
|
|
emit(ok: false, reasonCode: "credential-source-invalid", exitCode: 1)
|
|
}
|
|
|
|
do {
|
|
let profileAvailable = try keychainItemExists(
|
|
service: keychainService,
|
|
account: request.profileID
|
|
)
|
|
if profileAvailable {
|
|
let profile = try loadProfile(
|
|
profileID: request.profileID,
|
|
interactionAllowed: false
|
|
)
|
|
guard profile.ssid == ssid else {
|
|
emit(
|
|
ok: false,
|
|
adapter: "macOS Keychain",
|
|
profileAvailable: false,
|
|
profileEnrolled: false,
|
|
reasonCode: "profile-ssid-mismatch",
|
|
exitCode: 1
|
|
)
|
|
}
|
|
guard profile.credentialSource == "exact-firmware-profile" else {
|
|
emit(
|
|
ok: false,
|
|
adapter: "macOS Keychain",
|
|
profileAvailable: false,
|
|
profileEnrolled: false,
|
|
reasonCode: "profile-credential-source-mismatch",
|
|
exitCode: 1
|
|
)
|
|
}
|
|
emit(
|
|
ok: true,
|
|
adapter: "macOS Keychain",
|
|
profileAvailable: true,
|
|
profileEnrolled: false,
|
|
credentialSource: "exact-firmware-profile",
|
|
exitCode: 0
|
|
)
|
|
}
|
|
} catch {
|
|
emit(
|
|
ok: false,
|
|
adapter: "macOS Keychain",
|
|
profileAvailable: false,
|
|
profileEnrolled: false,
|
|
reasonCode: keychainReasonCode(error, missing: "profile-unavailable"),
|
|
exitCode: 1
|
|
)
|
|
}
|
|
|
|
let material: StoredCredentialMaterial
|
|
do {
|
|
material = try loadCredentialMaterial(
|
|
sourceID: sourceID,
|
|
interactionAllowed: false
|
|
)
|
|
} catch {
|
|
emit(
|
|
ok: false,
|
|
adapter: "macOS Keychain",
|
|
profileAvailable: false,
|
|
profileEnrolled: false,
|
|
reasonCode: keychainReasonCode(
|
|
error,
|
|
missing: "credential-source-unavailable"
|
|
),
|
|
exitCode: 1
|
|
)
|
|
}
|
|
|
|
do {
|
|
try storeProfile(
|
|
profileID: request.profileID,
|
|
profile: StoredProfile(
|
|
schemaVersion: 1,
|
|
ssid: ssid,
|
|
password: material.password,
|
|
credentialSource: "exact-firmware-profile"
|
|
)
|
|
)
|
|
emit(
|
|
ok: true,
|
|
adapter: "macOS Keychain",
|
|
profileAvailable: true,
|
|
profileEnrolled: true,
|
|
credentialSource: "exact-firmware-profile",
|
|
exitCode: 0
|
|
)
|
|
} catch {
|
|
emit(
|
|
ok: false,
|
|
adapter: "macOS Keychain",
|
|
profileAvailable: false,
|
|
profileEnrolled: false,
|
|
reasonCode: keychainReasonCode(error, missing: "profile-unavailable"),
|
|
exitCode: 1
|
|
)
|
|
}
|
|
}
|
|
|
|
if request.action == "check-profile" {
|
|
guard let expectedSSID = request.ssid,
|
|
let expectedSSIDData = expectedSSID.data(using: .utf8),
|
|
(1 ... 32).contains(expectedSSIDData.count)
|
|
else {
|
|
emit(ok: false, reasonCode: "ssid-invalid", exitCode: 1)
|
|
}
|
|
do {
|
|
let available = try keychainItemExists(
|
|
service: keychainService,
|
|
account: request.profileID
|
|
)
|
|
if !available {
|
|
emit(
|
|
ok: true,
|
|
adapter: "macOS Keychain",
|
|
profileAvailable: false,
|
|
exitCode: 0
|
|
)
|
|
}
|
|
let profile = try loadProfile(
|
|
profileID: request.profileID,
|
|
interactionAllowed: false
|
|
)
|
|
guard profile.ssid == expectedSSID else {
|
|
emit(
|
|
ok: false,
|
|
adapter: "macOS Keychain",
|
|
profileAvailable: false,
|
|
reasonCode: "profile-ssid-mismatch",
|
|
exitCode: 1
|
|
)
|
|
}
|
|
guard profile.credentialSource == "exact-firmware-profile" else {
|
|
emit(
|
|
ok: false,
|
|
adapter: "macOS Keychain",
|
|
profileAvailable: false,
|
|
reasonCode: "profile-credential-source-mismatch",
|
|
exitCode: 1
|
|
)
|
|
}
|
|
emit(
|
|
ok: true,
|
|
adapter: "macOS Keychain",
|
|
profileAvailable: true,
|
|
credentialSource: "exact-firmware-profile",
|
|
exitCode: 0
|
|
)
|
|
} catch {
|
|
emit(
|
|
ok: false,
|
|
adapter: "macOS Keychain",
|
|
profileAvailable: false,
|
|
reasonCode: keychainReasonCode(error, missing: "profile-unavailable"),
|
|
exitCode: 1
|
|
)
|
|
}
|
|
}
|
|
|
|
guard request.action == "associate"
|
|
|| request.action == "associate-ephemeral"
|
|
|| request.action == "scan-profile"
|
|
else {
|
|
emit(ok: false, reasonCode: "action-unsupported", exitCode: 1)
|
|
}
|
|
guard let interface = CWWiFiClient.shared().interface() else {
|
|
emit(ok: false, reasonCode: "wifi-interface-unavailable", exitCode: 1)
|
|
}
|
|
let scanTimeoutSeconds = request.scanTimeoutSeconds ?? 0
|
|
guard scanTimeoutSeconds >= 0, scanTimeoutSeconds <= 60 else {
|
|
emit(ok: false, reasonCode: "scan-timeout-invalid", exitCode: 1)
|
|
}
|
|
|
|
if request.action == "scan-profile" {
|
|
let profile: StoredProfile
|
|
do {
|
|
profile = try loadProfile(profileID: request.profileID)
|
|
} catch {
|
|
emit(ok: false, reasonCode: "profile-unavailable", exitCode: 1)
|
|
}
|
|
let ssidData = profile.ssid.data(using: .utf8)!
|
|
let scan = try scanForExpectedNetwork(
|
|
interface: interface,
|
|
ssid: profile.ssid,
|
|
ssidData: ssidData,
|
|
timeoutSeconds: scanTimeoutSeconds
|
|
)
|
|
emit(
|
|
ok: true,
|
|
adapter: "CoreWLAN",
|
|
found: scan.network != nil,
|
|
scanAttemptCount: scan.attemptCount,
|
|
scanElapsedMilliseconds: scan.elapsedMilliseconds,
|
|
exitCode: 0
|
|
)
|
|
}
|
|
|
|
guard request.action == "associate" || request.action == "associate-ephemeral" else {
|
|
emit(ok: false, reasonCode: "action-unsupported", exitCode: 1)
|
|
}
|
|
guard let expectedSSID = request.ssid,
|
|
let expectedSSIDData = expectedSSID.data(using: .utf8),
|
|
(1 ... 32).contains(expectedSSIDData.count)
|
|
else {
|
|
emit(ok: false, reasonCode: "ssid-invalid", exitCode: 1)
|
|
}
|
|
|
|
let scan = try scanForExpectedNetwork(
|
|
interface: interface,
|
|
ssid: expectedSSID,
|
|
ssidData: expectedSSIDData,
|
|
timeoutSeconds: scanTimeoutSeconds
|
|
)
|
|
guard let network = scan.network else {
|
|
emit(
|
|
ok: false,
|
|
scanAttemptCount: scan.attemptCount,
|
|
scanElapsedMilliseconds: scan.elapsedMilliseconds,
|
|
reasonCode: "network-not-found",
|
|
exitCode: 1
|
|
)
|
|
}
|
|
|
|
let credentialSource: String
|
|
let associationPassword: String
|
|
if request.action == "associate-ephemeral" {
|
|
guard let password = request.password,
|
|
let passwordData = password.data(using: .utf8),
|
|
(1 ... 64).contains(passwordData.count)
|
|
else {
|
|
emit(
|
|
ok: false,
|
|
scanAttemptCount: scan.attemptCount,
|
|
scanElapsedMilliseconds: scan.elapsedMilliseconds,
|
|
reasonCode: "credential-missing",
|
|
exitCode: 1
|
|
)
|
|
}
|
|
credentialSource = "operation-memory"
|
|
associationPassword = password
|
|
} else {
|
|
let profile: StoredProfile
|
|
do {
|
|
// The AP write has already happened. A prepared-host Quick action must
|
|
// never trigger a Keychain authorization sheet at this stage.
|
|
profile = try loadProfile(
|
|
profileID: request.profileID,
|
|
interactionAllowed: false
|
|
)
|
|
guard profile.credentialSource == "exact-firmware-profile" else {
|
|
emit(
|
|
ok: false,
|
|
scanAttemptCount: scan.attemptCount,
|
|
scanElapsedMilliseconds: scan.elapsedMilliseconds,
|
|
reasonCode: "profile-credential-source-mismatch",
|
|
exitCode: 1
|
|
)
|
|
}
|
|
credentialSource = "exact-firmware-profile"
|
|
} catch {
|
|
emit(
|
|
ok: false,
|
|
scanAttemptCount: scan.attemptCount,
|
|
scanElapsedMilliseconds: scan.elapsedMilliseconds,
|
|
reasonCode: keychainReasonCode(error, missing: "profile-unavailable"),
|
|
exitCode: 1
|
|
)
|
|
}
|
|
guard profile.ssid == expectedSSID else {
|
|
emit(
|
|
ok: false,
|
|
scanAttemptCount: scan.attemptCount,
|
|
scanElapsedMilliseconds: scan.elapsedMilliseconds,
|
|
reasonCode: "profile-ssid-mismatch",
|
|
exitCode: 1
|
|
)
|
|
}
|
|
associationPassword = profile.password
|
|
}
|
|
|
|
if interface.ssid() == expectedSSID {
|
|
emit(
|
|
ok: true,
|
|
adapter: "CoreWLAN",
|
|
alreadyAssociated: true,
|
|
profileEnrolled: false,
|
|
scanAttemptCount: scan.attemptCount,
|
|
scanElapsedMilliseconds: scan.elapsedMilliseconds,
|
|
credentialSource: credentialSource,
|
|
exitCode: 0
|
|
)
|
|
}
|
|
try interface.associate(to: network, password: associationPassword)
|
|
// CoreWLAN's synchronous association call throws on failure. Reading the
|
|
// current SSID again would require Location authorization on recent macOS
|
|
// versions and could turn a successful association into a false negative.
|
|
emit(
|
|
ok: true,
|
|
adapter: "CoreWLAN",
|
|
alreadyAssociated: false,
|
|
profileEnrolled: false,
|
|
scanAttemptCount: scan.attemptCount,
|
|
scanElapsedMilliseconds: scan.elapsedMilliseconds,
|
|
credentialSource: credentialSource,
|
|
exitCode: 0
|
|
)
|
|
} catch {
|
|
emit(ok: false, reasonCode: coreWLANReasonCode(error), exitCode: 1)
|
|
}
|