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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions NightscoutService.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
objects = {

/* Begin PBXBuildFile section */
657F99DE2F0BC17B00F732BD /* RemoteNotificationResponseManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 657F99DD2F0BC17B00F732BD /* RemoteNotificationResponseManager.swift */; };
657F99DF2F0BC17B00F732BD /* APNSJWTManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 657F99DB2F0BC17B00F732BD /* APNSJWTManager.swift */; };
657F99E02F0BC17B00F732BD /* OTPSecureMessenger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 657F99DC2F0BC17B00F732BD /* OTPSecureMessenger.swift */; };
A90E399E22BC76DB0016DFE8 /* TimeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = A90E399D22BC76DB0016DFE8 /* TimeInterval.swift */; };
A90E39A122BC773A0016DFE8 /* NightscoutUploader.swift in Sources */ = {isa = PBXBuildFile; fileRef = A90E39A022BC773A0016DFE8 /* NightscoutUploader.swift */; };
A90E39A322BC782C0016DFE8 /* SyncCarbObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = A90E39A222BC782C0016DFE8 /* SyncCarbObject.swift */; };
Expand Down Expand Up @@ -69,6 +72,7 @@
C1C1349D27DD38000097B5AD /* NightscoutServiceKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A91BAC3A22BC69B500ABF1BB /* NightscoutServiceKitUI.framework */; };
C1C1349E27DD38000097B5AD /* NightscoutServiceKitUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = A91BAC3A22BC69B500ABF1BB /* NightscoutServiceKitUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
C1CEBFF429BCC104007FD8A3 /* NightscoutKit in Frameworks */ = {isa = PBXBuildFile; productRef = C1CEBFF329BCC104007FD8A3 /* NightscoutKit */; };
657F99E32F0BC17B00F732BD /* SwiftJWT in Frameworks */ = {isa = PBXBuildFile; productRef = 657F99E22F0BC17B00F732BD /* SwiftJWT */; };
C1E10F012ABF3B190036A416 /* PersistedPumpEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1E10F002ABF3B190036A416 /* PersistedPumpEvent.swift */; };
C1E703642506BE9400DAB534 /* ObjectIdCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1E703632506BE9400DAB534 /* ObjectIdCache.swift */; };
C1E7036625070A0E00DAB534 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C1E7036525070A0E00DAB534 /* Assets.xcassets */; };
Expand Down Expand Up @@ -134,6 +138,9 @@
/* End PBXCopyFilesBuildPhase section */

/* Begin PBXFileReference section */
657F99DB2F0BC17B00F732BD /* APNSJWTManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNSJWTManager.swift; sourceTree = "<group>"; };
657F99DC2F0BC17B00F732BD /* OTPSecureMessenger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPSecureMessenger.swift; sourceTree = "<group>"; };
657F99DD2F0BC17B00F732BD /* RemoteNotificationResponseManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteNotificationResponseManager.swift; sourceTree = "<group>"; };
A90E399D22BC76DB0016DFE8 /* TimeInterval.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeInterval.swift; sourceTree = "<group>"; };
A90E39A022BC773A0016DFE8 /* NightscoutUploader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutUploader.swift; sourceTree = "<group>"; };
A90E39A222BC782C0016DFE8 /* SyncCarbObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncCarbObject.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -217,6 +224,7 @@
C1C1349627DD35060097B5AD /* LoopKit.framework in Frameworks */,
C1CEBFF429BCC104007FD8A3 /* NightscoutKit in Frameworks */,
C1B9ACF827DE987400857532 /* OneTimePassword in Frameworks */,
657F99E32F0BC17B00F732BD /* SwiftJWT in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -372,6 +380,9 @@
A99A115C29AA2870007919CE /* RemoteCommands */ = {
isa = PBXGroup;
children = (
657F99DB2F0BC17B00F732BD /* APNSJWTManager.swift */,
657F99DC2F0BC17B00F732BD /* OTPSecureMessenger.swift */,
657F99DD2F0BC17B00F732BD /* RemoteNotificationResponseManager.swift */,
A9AA6E5729EB06AA008FFA78 /* Actions */,
A941B08629BCB87500F91340 /* RemoteCommandSource.swift */,
A98A0C1F29C71E8F0067DE0F /* V1 */,
Expand Down Expand Up @@ -516,6 +527,7 @@
packageProductDependencies = (
C1B9ACF727DE987400857532 /* OneTimePassword */,
C1CEBFF329BCC104007FD8A3 /* NightscoutKit */,
657F99E22F0BC17B00F732BD /* SwiftJWT */,
);
productName = NightscoutService;
productReference = A91BAC1B22BC691A00ABF1BB /* NightscoutServiceKit.framework */;
Expand Down Expand Up @@ -646,6 +658,7 @@
packageReferences = (
C1B9ACF627DE987400857532 /* XCRemoteSwiftPackageReference "OneTimePassword" */,
C1CEBFF229BCC104007FD8A3 /* XCRemoteSwiftPackageReference "NightscoutKit" */,
657F99E12F0BC17B00F732BD /* XCRemoteSwiftPackageReference "Swift-JWT" */,
);
productRefGroup = A91BAC1C22BC691A00ABF1BB /* Products */;
projectDirPath = "";
Expand Down Expand Up @@ -726,6 +739,9 @@
A9B007B322BD6FF7000131DE /* LocalizedString.swift in Sources */,
A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */,
A941B09229BD4A9F00F91340 /* OverrideCancelRemoteNotification.swift in Sources */,
657F99DE2F0BC17B00F732BD /* RemoteNotificationResponseManager.swift in Sources */,
657F99DF2F0BC17B00F732BD /* APNSJWTManager.swift in Sources */,
657F99E02F0BC17B00F732BD /* OTPSecureMessenger.swift in Sources */,
C1398D2227C434FB00416AD6 /* Data.swift in Sources */,
A90E39A522BC791E0016DFE8 /* DoseEntry.swift in Sources */,
A934B6182358F49300949C8B /* StoredSettings.swift in Sources */,
Expand Down Expand Up @@ -1304,6 +1320,14 @@
kind = branch;
};
};
657F99E12F0BC17B00F732BD /* XCRemoteSwiftPackageReference "Swift-JWT" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/Kitura/Swift-JWT.git";
requirement = {
kind = exactVersion;
version = 4.0.1;
};
};
/* End XCRemoteSwiftPackageReference section */

/* Begin XCSwiftPackageProductDependency section */
Expand All @@ -1317,6 +1341,11 @@
package = C1CEBFF229BCC104007FD8A3 /* XCRemoteSwiftPackageReference "NightscoutKit" */;
productName = NightscoutKit;
};
657F99E22F0BC17B00F732BD /* SwiftJWT */ = {
isa = XCSwiftPackageProductDependency;
package = 657F99E12F0BC17B00F732BD /* XCRemoteSwiftPackageReference "Swift-JWT" */;
productName = SwiftJWT;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = A91BAC1222BC691A00ABF1BB /* Project object */;
Expand Down
86 changes: 68 additions & 18 deletions NightscoutServiceKit/NightscoutService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -411,25 +411,75 @@ extension NightscoutService: RemoteDataService {

extension NightscoutService: RemoteCommandSourceV1Delegate {

func commandSourceV1(_: RemoteCommandSourceV1, handleAction action: Action) async throws {
func commandSourceV1(_: RemoteCommandSourceV1, handleAction action: Action, remoteNotification: RemoteNotification) async throws {

switch action {
case .temporaryScheduleOverride(let overrideCommand):
try await self.serviceDelegate?.enactRemoteOverride(
name: overrideCommand.name,
durationTime: overrideCommand.durationTime,
remoteAddress: overrideCommand.remoteAddress
)
case .cancelTemporaryOverride:
try await self.serviceDelegate?.cancelRemoteOverride()
case .bolusEntry(let bolusCommand):
try await self.serviceDelegate?.deliverRemoteBolus(amountInUnits: bolusCommand.amountInUnits)
case .carbsEntry(let carbCommand):
try await self.serviceDelegate?.deliverRemoteCarbs(
amountInGrams: carbCommand.amountInGrams,
absorptionTime: carbCommand.absorptionTime,
foodType: carbCommand.foodType,
startDate: carbCommand.startDate
let returnInfo = remoteNotification.getReturnNotificationInfo()
if returnInfo == nil {
os_log("No return notification info available, response will not be sent", log: .default, type: .info)
} else {
os_log("Return notification info available, will send response after command processing", log: .default, type: .info)
}

var commandType: RemoteNotificationResponseManager.CommandType = .bolus // Default, will be set in switch
var success = false
var message = ""

do {
switch action {
case .temporaryScheduleOverride(let overrideCommand):
commandType = .override
try await self.serviceDelegate?.enactRemoteOverride(
name: overrideCommand.name,
durationTime: overrideCommand.durationTime,
remoteAddress: overrideCommand.remoteAddress
)
success = true
message = "Override '\(overrideCommand.name)' enacted successfully"

case .cancelTemporaryOverride:
commandType = .cancelOverride
try await self.serviceDelegate?.cancelRemoteOverride()
success = true
message = "Override cancelled successfully"

case .bolusEntry(let bolusCommand):
commandType = .bolus
try await self.serviceDelegate?.deliverRemoteBolus(amountInUnits: bolusCommand.amountInUnits)
success = true
message = String(format: "Bolus of %.2f units delivered successfully", bolusCommand.amountInUnits)

case .carbsEntry(let carbCommand):
commandType = .carbs
try await self.serviceDelegate?.deliverRemoteCarbs(
amountInGrams: carbCommand.amountInGrams,
absorptionTime: carbCommand.absorptionTime,
foodType: carbCommand.foodType,
startDate: carbCommand.startDate
)
success = true
message = String(format: "Carbs entry of %.1f g delivered successfully", carbCommand.amountInGrams)
}
} catch {
message = "Command failed: \(error.localizedDescription)"
// Send failure response before rethrowing
if let returnInfo = returnInfo {
await RemoteNotificationResponseManager.shared.sendResponseNotification(
to: returnInfo,
commandType: commandType,
success: false,
message: message
)
}
throw error
}

// Send success response
if let returnInfo = returnInfo {
await RemoteNotificationResponseManager.shared.sendResponseNotification(
to: returnInfo,
commandType: commandType,
success: success,
message: message
)
}
}
Expand Down
91 changes: 91 additions & 0 deletions NightscoutServiceKit/RemoteCommands/APNSJWTManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
//
// APNSJWTManager.swift
// NightscoutServiceKit
//
// Created for Loop APNS Response feature.
// Manages JWT tokens for APNS authentication.
//

import Foundation
import OSLog
import SwiftJWT

class APNSJWTManager {
static let shared = APNSJWTManager()

private init() {}

private struct JWTCacheKey: Hashable {
let keyId: String
let teamId: String
}

private struct CachedJWT {
let token: String
let expirationDate: Date
}

private var jwtCache: [JWTCacheKey: CachedJWT] = [:]
private let cacheQueue = DispatchQueue(label: "com.loop.apnsjwtmanager.cache", attributes: .concurrent)

func getOrGenerateJWT(keyId: String, teamId: String, apnsKey: String) -> String? {
let cacheKey = JWTCacheKey(keyId: keyId, teamId: teamId)

if let cachedJWT = getCachedJWT(for: cacheKey) {
return cachedJWT
}

do {
let signedJWT = try generateJWT(keyId: keyId, teamId: teamId, apnsKey: apnsKey)

let expirationDate = Date().addingTimeInterval(3300)
cacheJWT(signedJWT, for: cacheKey, expirationDate: expirationDate)

return signedJWT
} catch {
os_log("Failed to sign JWT: %{public}@", log: .default, type: .error, error.localizedDescription)
return nil
}
}

private func generateJWT(keyId: String, teamId: String, apnsKey: String) throws -> String {
let header = Header(kid: keyId)
let claims = APNSJWTClaims(iss: teamId, iat: Date())
var jwt = JWT(header: header, claims: claims)

let privateKey = Data(apnsKey.utf8)
let jwtSigner = JWTSigner.es256(privateKey: privateKey)
let signedJWT = try jwt.sign(using: jwtSigner)

return signedJWT
}

private struct APNSJWTClaims: Claims {
let iss: String
let iat: Date
}

private func getCachedJWT(for key: JWTCacheKey) -> String? {
cacheQueue.sync {
guard let cached = jwtCache[key],
Date() < cached.expirationDate
else {
return nil
}
return cached.token
}
}

private func cacheJWT(_ token: String, for key: JWTCacheKey, expirationDate: Date) {
cacheQueue.async(flags: .barrier) {
self.jwtCache[key] = CachedJWT(token: token, expirationDate: expirationDate)
}
}

func invalidateCache() {
cacheQueue.async(flags: .barrier) {
self.jwtCache.removeAll()
}
}
}

88 changes: 88 additions & 0 deletions NightscoutServiceKit/RemoteCommands/OTPSecureMessenger.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
//
// OTPSecureMessenger.swift
// NightscoutServiceKit
//
// Created for Loop APNS Response feature.
// Uses OTP code for encryption/decryption instead of shared secret.
//

import Foundation
import CryptoKit

struct OTPSecureMessenger {
private let encryptionKey: SymmetricKey

init?(otpCode: String) {
guard let otpData = otpCode.data(using: .utf8) else {
return nil
}
let hashed = SHA256.hash(data: otpData)
encryptionKey = SymmetricKey(data: hashed)
}

func encrypt<T: Encodable>(_ object: T) throws -> String {
let dataToEncrypt = try JSONEncoder().encode(object)

let nonce = AES.GCM.Nonce()

let sealedBox = try AES.GCM.seal(dataToEncrypt, using: encryptionKey, nonce: nonce)

let nonceData = Data(nonce)
let ciphertext = sealedBox.ciphertext
let tag = sealedBox.tag
let combinedData = nonceData + ciphertext + tag

return combinedData.base64EncodedString()
}

func decrypt(base64EncodedString: String) throws -> ReturnNotificationInfo {
guard let combinedData = Data(base64Encoded: base64EncodedString) else {
throw NSError(
domain: "OTPSecureMessenger",
code: 100,
userInfo: [NSLocalizedDescriptionKey: "Invalid Base64 string"]
)
}

let nonceSize = 12
let tagSize = 16
guard combinedData.count > nonceSize + tagSize else {
throw NSError(
domain: "OTPSecureMessenger",
code: 101,
userInfo: [NSLocalizedDescriptionKey: "Encrypted data is too short"]
)
}

let nonceData = combinedData.prefix(nonceSize)
let tag = combinedData.suffix(tagSize)
let ciphertext = combinedData.dropFirst(nonceSize).dropLast(tagSize)

let nonce = try AES.GCM.Nonce(data: nonceData)
let sealedBox = try AES.GCM.SealedBox(nonce: nonce, ciphertext: ciphertext, tag: tag)

let decryptedData = try AES.GCM.open(sealedBox, using: encryptionKey)
let returnInfo = try JSONDecoder().decode(ReturnNotificationInfo.self, from: decryptedData)

return returnInfo
}
}

struct ReturnNotificationInfo: Codable {
let productionEnvironment: Bool
let deviceToken: String
let bundleId: String
let teamId: String
let keyId: String
let apnsKey: String

enum CodingKeys: String, CodingKey {
case productionEnvironment = "production_environment"
case deviceToken = "device_token"
case bundleId = "bundle_id"
case teamId = "team_id"
case keyId = "key_id"
case apnsKey = "apns_key"
}
}

Loading