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
4 changes: 4 additions & 0 deletions LoopFollow.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@
DDD10F0B2C54192A00D76A8E /* TemporaryTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD10F0A2C54192A00D76A8E /* TemporaryTarget.swift */; };
DDDB86F12DF7223C00AADDAC /* DeleteAlarmSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDB86F02DF7223C00AADDAC /* DeleteAlarmSection.swift */; };
DDDC01DD2E244B3100D9975C /* JWTManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDC01DC2E244B3100D9975C /* JWTManager.swift */; };
A1A1A10002000000A0CFEED1 /* LogRedactor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1A1A10002000000A0CFEED2 /* LogRedactor.swift */; };
DDDC31CC2E13A7DF009EA0F3 /* AddAlarmSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDC31CB2E13A7DF009EA0F3 /* AddAlarmSheet.swift */; };
DDDC31CE2E13A811009EA0F3 /* AlarmTile.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDC31CD2E13A811009EA0F3 /* AlarmTile.swift */; };
DDDF6F492D479AF000884336 /* NoRemoteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDF6F482D479AEF00884336 /* NoRemoteView.swift */; };
Expand Down Expand Up @@ -700,6 +701,7 @@
DDD10F0A2C54192A00D76A8E /* TemporaryTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryTarget.swift; sourceTree = "<group>"; };
DDDB86F02DF7223C00AADDAC /* DeleteAlarmSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteAlarmSection.swift; sourceTree = "<group>"; };
DDDC01DC2E244B3100D9975C /* JWTManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JWTManager.swift; sourceTree = "<group>"; };
A1A1A10002000000A0CFEED2 /* LogRedactor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogRedactor.swift; sourceTree = "<group>"; };
DDDC31CB2E13A7DF009EA0F3 /* AddAlarmSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddAlarmSheet.swift; sourceTree = "<group>"; };
DDDC31CD2E13A811009EA0F3 /* AlarmTile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmTile.swift; sourceTree = "<group>"; };
DDDF6F482D479AEF00884336 /* NoRemoteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoRemoteView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1697,6 +1699,7 @@
DDF699952C5582290058A8D9 /* TextFieldWithToolBar.swift */,
DDC7E5372DBD887400EB1127 /* isOnPhoneCall.swift */,
DDDC01DC2E244B3100D9975C /* JWTManager.swift */,
A1A1A10002000000A0CFEED2 /* LogRedactor.swift */,
6541341B2E1DC28000BDBE08 /* DateExtensions.swift */,
);
path = Helpers;
Expand Down Expand Up @@ -2284,6 +2287,7 @@
DD4A407E2E6AFEE6007B318B /* AuthService.swift in Sources */,
654134182E1DC09700BDBE08 /* OverridePresetsView.swift in Sources */,
DDDC01DD2E244B3100D9975C /* JWTManager.swift in Sources */,
A1A1A10002000000A0CFEED1 /* LogRedactor.swift in Sources */,
DDD10F072C529DE800D76A8E /* Observable.swift in Sources */,
DDFF3D852D14279B00BF9D9E /* BackgroundRefreshSettingsView.swift in Sources */,
DDCF9A882D85FD33004DF4DD /* AlarmData.swift in Sources */,
Expand Down
8 changes: 5 additions & 3 deletions LoopFollow/Application/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {

Observable.shared.loopFollowDeviceToken.value = tokenString

LogManager.shared.log(category: .apns, message: "Successfully registered for remote notifications with token: \(tokenString)")
LogManager.shared.log(category: .apns, message: "Successfully registered for remote notifications with token: \(LogRedactor.tail(tokenString))")
}

/// Called when failed to register for remote notifications
Expand All @@ -82,7 +82,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate {

/// Called when a remote notification is received
func application(_: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
LogManager.shared.log(category: .apns, message: "Received remote notification: \(userInfo)")
let userInfoKeys = userInfo.keys.compactMap { $0 as? String }.sorted()
LogManager.shared.log(category: .apns, message: "Received remote notification: keys=\(userInfoKeys)")

// Check if this is a response notification from Loop or Trio
if let aps = userInfo["aps"] as? [String: Any] {
Expand Down Expand Up @@ -217,7 +218,8 @@ extension AppDelegate: UNUserNotificationCenterDelegate {
{
// Log the notification
let userInfo = notification.request.content.userInfo
LogManager.shared.log(category: .general, message: "Will present notification: \(userInfo)")
let userInfoKeys = userInfo.keys.compactMap { $0 as? String }.sorted()
LogManager.shared.log(category: .general, message: "Will present notification: keys=\(userInfoKeys)")

// Show the notification even when app is in foreground
completionHandler([.banner, .sound, .badge])
Expand Down
2 changes: 1 addition & 1 deletion LoopFollow/Helpers/JWTManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ class JWTManager {
let signedJWT = "\(signingInput).\(signatureBase64)"

cache[cacheKey] = CachedToken(jwt: signedJWT, expiresAt: Date().addingTimeInterval(ttl))
LogManager.shared.log(category: .apns, message: "JWT generated for key \(keyId) (TTL 55 min)")
LogManager.shared.log(category: .apns, message: "JWT generated for key \(LogRedactor.keyId(keyId)) (TTL 55 min)")
return signedJWT
} catch {
LogManager.shared.log(category: .apns, message: "Failed to sign JWT: \(error.localizedDescription)")
Expand Down
170 changes: 170 additions & 0 deletions LoopFollow/Helpers/LogRedactor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
// LoopFollow
// LogRedactor.swift

import CryptoKit
import Foundation

/// Helpers for masking secrets before they hit the log file. The "share logs"
/// feature exposes the on-disk log to the user, so anything sensitive that
/// flows through `LogManager.log` must be reduced to a non-recoverable form
/// while keeping enough signal (short suffix, host, fingerprint) to correlate
/// events during debugging.
enum LogRedactor {
/// Last `keep` characters of `secret`, prefixed with `…`. Matches the
/// existing `.suffix(8)` convention used in `LiveActivityManager`.
static func tail(_ secret: String, keep: Int = 8) -> String {
if secret.isEmpty { return "(empty)" }
if secret.count <= keep { return "(redacted)" }
return "…\(secret.suffix(keep))"
}

/// First `keep` characters of `secret`, suffixed with `…`. Matches the
/// existing `.prefix(8)` convention used in `LoopAPNSService`.
static func head(_ secret: String, keep: Int = 8) -> String {
if secret.isEmpty { return "(empty)" }
if secret.count <= keep { return "(redacted)" }
return "\(secret.prefix(keep))…"
}

/// Known managed-Nightscout host suffixes. When a URL's host ends in one
/// of these, the leading subdomain (which identifies the user) is masked
/// and the suffix is kept so engineers can tell which platform the user
/// is on. Anything else is treated as self-hosted and reduced to the TLD.
private static let knownHostSuffixes: [String] = [
"nightscoutpro.com",
"10be.de",
"herokuapp.com",
]

/// Keep scheme + a redacted host hint, drop path and query. The Nightscout
/// token rides in `?token=` and the host itself identifies the user when
/// they're on a managed platform, so we mask the subdomain and keep only
/// the platform suffix (or just the TLD for self-hosted setups).
static func url(_ raw: String) -> String {
if raw.isEmpty { return "(empty)" }
if let u = URL(string: raw), let host = u.host {
let scheme = u.scheme.map { "\($0)://" } ?? ""
return "\(scheme)\(maskHost(host))/…"
}
return "(redacted)"
}

private static func maskHost(_ host: String) -> String {
// IPv4 / IPv6 / bracketed — drop entirely.
if host.range(of: "^\\d+\\.\\d+\\.\\d+\\.\\d+$", options: .regularExpression) != nil { return "***" }
if host.contains(":") || host.hasPrefix("[") { return "***" }

let lower = host.lowercased()
for suffix in knownHostSuffixes {
if lower == suffix || lower.hasSuffix("." + suffix) {
return "***." + suffix
}
}

let parts = host.split(separator: ".", omittingEmptySubsequences: false)
if parts.count >= 2, let tld = parts.last, !tld.isEmpty {
return "***." + String(tld)
}
return "***"
}

/// Apple Developer Key ID — 10-char uppercase alphanumeric. Reveals
/// last 2 chars only.
static func keyId(_ keyId: String) -> String {
if keyId.isEmpty { return "(empty)" }
if keyId.count <= 2 { return "(redacted)" }
return "…\(keyId.suffix(2))"
}

/// Apple Team ID — 10-char uppercase alphanumeric. Reveals last 2 chars.
static func teamId(_ teamId: String) -> String {
keyId(teamId)
}

/// App bundle id ("com.example.MyApp"). Mask the middle component(s) but
/// keep the leading TLD and trailing app name so suffixes like
/// `.watchkitapp` or `.push-type.liveactivity` remain visible.
static func bundleId(_ id: String) -> String {
if id.isEmpty { return "(empty)" }
let parts = id.split(separator: ".", omittingEmptySubsequences: false)
guard parts.count >= 3 else { return "(redacted)" }
var masked = [String]()
masked.append(String(parts[0]))
for _ in 1 ..< parts.count - 1 {
masked.append("***")
}
masked.append(String(parts[parts.count - 1]))
return masked.joined(separator: ".")
}

/// Username (Dexcom Share, etc.). Preserves first character and any
/// `@domain` suffix shape so engineers can tell email-shaped from not.
static func username(_ name: String) -> String {
if name.isEmpty { return "(empty)" }
if name.contains("@") {
let parts = name.split(separator: "@", maxSplits: 1).map(String.init)
let local = parts[0]
let domain = parts.count > 1 ? parts[1] : ""
let firstLocal = local.first.map(String.init) ?? "?"
let firstDomain = domain.first.map(String.init) ?? "?"
return "\(firstLocal)***@\(firstDomain)***"
}
let first = name.first.map(String.init) ?? "?"
return "\(first)***"
}

/// Sweep an arbitrary message string for high-confidence secret shapes.
/// Idempotent. Run by `LogManager.log` on every line before write.
static func sweep(_ message: String) -> String {
var out = message
out = redactPEM(out)
out = redactTokenQuery(out)
out = redactJWT(out)
return out
}

/// Replace any `?token=…` or `&token=…` value with `***` (case-insensitive).
private static func redactTokenQuery(_ s: String) -> String {
guard let regex = try? NSRegularExpression(
pattern: "([?&]token=)[^&\\s\"'<>]+",
options: [.caseInsensitive]
) else { return s }
let range = NSRange(s.startIndex ..< s.endIndex, in: s)
return regex.stringByReplacingMatches(in: s, options: [], range: range, withTemplate: "$1***")
}

/// Collapse the body of a PEM PRIVATE KEY block to `(redacted)`.
private static func redactPEM(_ s: String) -> String {
guard let regex = try? NSRegularExpression(
pattern: "-----BEGIN [A-Z ]*PRIVATE KEY-----[\\s\\S]*?-----END [A-Z ]*PRIVATE KEY-----",
options: []
) else { return s }
let range = NSRange(s.startIndex ..< s.endIndex, in: s)
return regex.stringByReplacingMatches(
in: s, options: [], range: range,
withTemplate: "-----BEGIN PRIVATE KEY----- (redacted) -----END PRIVATE KEY-----"
)
}

/// Collapse the middle segment of a JWT (`ey…\.ey…\.…`).
private static func redactJWT(_ s: String) -> String {
guard let regex = try? NSRegularExpression(
pattern: "ey[A-Za-z0-9_-]{8,}\\.ey[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}",
options: []
) else { return s }
let range = NSRange(s.startIndex ..< s.endIndex, in: s)
return regex.stringByReplacingMatches(in: s, options: [], range: range, withTemplate: "ey…<jwt>…")
}

/// Non-reversible fingerprint for opaque blobs we can't safely log
/// (settings JSON, scanned QR code contents, etc.).
static func fingerprint(_ data: Data) -> String {
let digest = SHA256.hash(data: data)
let hex = digest.compactMap { String(format: "%02x", $0) }.joined()
return "\(data.count) bytes, sha256=\(hex.prefix(8))…"
}

static func fingerprint(_ string: String) -> String {
fingerprint(Data(string.utf8))
}
}
19 changes: 8 additions & 11 deletions LoopFollow/Helpers/NightscoutUtils.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,25 +86,22 @@ class NightscoutUtils {
completion(.success(decodedObject))
}
} catch let decodingError as DecodingError {
print("[ERROR] Failed to decode \(T.self):")
let typeName = String(describing: T.self)
switch decodingError {
case let .typeMismatch(type, context):
print("Type mismatch for type \(type), context: \(context.debugDescription)")
print("Coding path:", context.codingPath)
LogManager.shared.log(category: .nightscout, message: "Decode \(typeName) typeMismatch: \(type) at \(context.codingPath.map { $0.stringValue }.joined(separator: "."))", isDebug: true)
case let .valueNotFound(type, context):
print("Value not found for type \(type), context: \(context.debugDescription)")
print("Coding path:", context.codingPath)
LogManager.shared.log(category: .nightscout, message: "Decode \(typeName) valueNotFound: \(type) at \(context.codingPath.map { $0.stringValue }.joined(separator: "."))", isDebug: true)
case let .keyNotFound(key, context):
print("Key '\(key.stringValue)' not found, context: \(context.debugDescription)")
print("Coding path:", context.codingPath)
case let .dataCorrupted(context):
print("Data corrupted, context: \(context.debugDescription)")
LogManager.shared.log(category: .nightscout, message: "Decode \(typeName) keyNotFound: '\(key.stringValue)' at \(context.codingPath.map { $0.stringValue }.joined(separator: "."))", isDebug: true)
case .dataCorrupted:
LogManager.shared.log(category: .nightscout, message: "Decode \(typeName) dataCorrupted", isDebug: true)
@unknown default:
print("Unknown decoding error")
LogManager.shared.log(category: .nightscout, message: "Decode \(typeName) unknown error", isDebug: true)
}
completion(.failure(decodingError))
} catch {
print("[ERROR] General error:", error)
LogManager.shared.log(category: .nightscout, message: "Decode \(T.self) general error: \(String(describing: type(of: error)))", isDebug: true)
completion(.failure(error))
}
}
Expand Down
3 changes: 2 additions & 1 deletion LoopFollow/Log/LogManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ class LogManager {
/// - limitIdentifier: Optional key to rate-limit similar log messages.
/// - limitInterval: Time interval (in seconds) to wait before logging the same type again.
func log(category: Category, message: String, isDebug: Bool = false, limitIdentifier: String? = nil, limitInterval: TimeInterval = 300) {
let logMessage = formattedLogMessage(for: category, message: message)
let safeMessage = LogRedactor.sweep(message)
let logMessage = formattedLogMessage(for: category, message: safeMessage)

consoleQueue.async {
print(logMessage)
Expand Down
4 changes: 2 additions & 2 deletions LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ class LoopAPNSService {

// Encrypt and include return notification info using OTP
if let returnInfo = createReturnNotificationInfo() {
LogManager.shared.log(category: .apns, message: "Created return notification info for carbs - deviceToken: \(returnInfo.deviceToken.prefix(8))..., bundleId: \(returnInfo.bundleId)")
LogManager.shared.log(category: .apns, message: "Created return notification info for carbs - deviceToken: \(LogRedactor.head(returnInfo.deviceToken)), bundleId: \(LogRedactor.bundleId(returnInfo.bundleId))")
if let encryptedReturnInfo = encryptReturnNotificationInfo(returnInfo: returnInfo, otpCode: String(payload.otp)) {
finalPayload["encrypted_return_notification"] = encryptedReturnInfo
LogManager.shared.log(category: .apns, message: "Added encrypted_return_notification to carbs payload, length: \(encryptedReturnInfo.count)")
Expand Down Expand Up @@ -227,7 +227,7 @@ class LoopAPNSService {

// Encrypt and include return notification info using OTP
if let returnInfo = createReturnNotificationInfo() {
LogManager.shared.log(category: .apns, message: "Created return notification info for carbs - deviceToken: \(returnInfo.deviceToken.prefix(8))..., bundleId: \(returnInfo.bundleId)")
LogManager.shared.log(category: .apns, message: "Created return notification info for carbs - deviceToken: \(LogRedactor.head(returnInfo.deviceToken)), bundleId: \(LogRedactor.bundleId(returnInfo.bundleId))")
if let encryptedReturnInfo = encryptReturnNotificationInfo(returnInfo: returnInfo, otpCode: String(payload.otp)) {
finalPayload["encrypted_return_notification"] = encryptedReturnInfo
LogManager.shared.log(category: .apns, message: "Added encrypted_return_notification to carbs payload, length: \(encryptedReturnInfo.count)")
Expand Down
2 changes: 1 addition & 1 deletion LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ class RemoteSettingsViewModel: ObservableObject {
self.remoteType = .loopAPNS
self.isLoopDevice = true
self.isTrioDevice = false
LogManager.shared.log(category: .apns, message: "Loop APNS QR code scanned: \(code)")
LogManager.shared.log(category: .apns, message: "Loop APNS QR code scanned: \(LogRedactor.fingerprint(code))")
case let .failure(error):
self.loopAPNSErrorMessage = "Scanning failed: \(error.localizedDescription)"
}
Expand Down
5 changes: 2 additions & 3 deletions LoopFollow/Remote/TRC/PushNotificationManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -276,12 +276,11 @@ class PushNotificationManager {
}

if let httpResponse = response as? HTTPURLResponse {
print("Push notification sent.")
print("Status code: \(httpResponse.statusCode)")
LogManager.shared.log(category: .apns, message: "Push notification sent. Status code: \(httpResponse.statusCode)", isDebug: true)

var responseBodyMessage = ""
if let data = data, let responseBody = String(data: data, encoding: .utf8) {
print("Response body: \(responseBody)")
LogManager.shared.log(category: .apns, message: "Response body: \(responseBody)", isDebug: true)
if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
let reason = json["reason"] as? String
{
Expand Down
Loading
Loading