Skip to content
Merged
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
81 changes: 81 additions & 0 deletions Flowlight/App/CodeSignatureCheck.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import Foundation
import Security

/// Whether a bundle about to replace this one was signed by whoever signed this one.
///
/// The update path checked that the downloaded app called itself Flowlight and carried the expected version —
/// both of which are strings inside the bundle, and neither of which anything signs. A disk image that got
/// past the checksum would have been installed on the strength of its own `Info.plist`, and the installer then
/// removed the quarantine flag, which is the attribute that would have made macOS check the signature on first
/// launch. So the one check that mattered was being skipped and the one macOS would have done was being
/// deleted.
///
/// The team is read from the running app rather than written down here. "Signed by the same team as the copy
/// asking for the update" is the property that actually matters, it needs no constant to fall out of date, and
/// on an unsigned local build — where `teamIdentifier` is nil — it declines to pretend it verified anything.
enum CodeSignatureCheck {
enum Failure: LocalizedError, Equatable {
case unreadable(OSStatus)
case notSigned
case wrongTeam(found: String?, expected: String)
case invalid(OSStatus)
case selfUnknown

var errorDescription: String? {
switch self {
case .unreadable(let status):
return "The downloaded app's signature couldn't be read (OSStatus \(status))."
case .notSigned:
return "The downloaded app isn't signed."
case .wrongTeam(let found, let expected):
return "The downloaded app is signed by team \(found ?? "none"), not \(expected)."
case .invalid(let status):
return "The downloaded app's signature didn't verify (OSStatus \(status))."
case .selfUnknown:
return "This copy of Flowlight isn't signed with a Developer ID, so it can't tell whether an update is."
}
}
}

/// The Team ID of the running process, or nil when it has no Developer ID — an ad-hoc local build.
static func runningTeamIdentifier() -> String? {
var code: SecCode?
guard SecCodeCopySelf([], &code) == errSecSuccess, let code else { return nil }
var staticCode: SecStaticCode?
guard SecCodeCopyStaticCode(code, [], &staticCode) == errSecSuccess, let staticCode else { return nil }
return teamIdentifier(of: staticCode)
}

private static func teamIdentifier(of code: SecStaticCode) -> String? {
var info: CFDictionary?
guard SecCodeCopySigningInformation(code, SecCSFlags(rawValue: kSecCSSigningInformation), &info) == errSecSuccess,
let dictionary = info as? [String: Any] else { return nil }
return dictionary[kSecCodeInfoTeamIdentifier as String] as? String
}

/// Throws unless `url` is a valid signature from `expectedTeam`, checked with the same strictness Gatekeeper
/// uses: the whole bundle, nested code included, against Apple's anchor.
static func verify(_ url: URL, expectedTeam: String) throws {
var staticCode: SecStaticCode?
let created = SecStaticCodeCreateWithPath(url as CFURL, [], &staticCode)
guard created == errSecSuccess, let staticCode else { throw Failure.unreadable(created) }

// Signed by Apple's Developer ID anchor, by this team, and every nested binary along with it. The
// requirement is what makes this more than "has a signature": an attacker's own valid signature fails.
let requirement = "anchor apple generic and certificate leaf[subject.OU] = \"\(expectedTeam)\""
var securityRequirement: SecRequirement?
guard SecRequirementCreateWithString(requirement as CFString, [], &securityRequirement) == errSecSuccess,
let securityRequirement else { throw Failure.unreadable(errSecParam) }

let flags = SecCSFlags(rawValue: kSecCSCheckAllArchitectures | kSecCSCheckNestedCode | kSecCSStrictValidate)
let status = SecStaticCodeCheckValidity(staticCode, flags, securityRequirement)
guard status == errSecSuccess else {
// Say which of the two it was, because "signed by someone else" and "signature damaged" mean very
// different things to whoever reads the error.
let found = teamIdentifier(of: staticCode)
if let found, found != expectedTeam { throw Failure.wrongTeam(found: found, expected: expectedTeam) }
if found == nil { throw Failure.notSigned }
throw Failure.invalid(status)
}
}
}
23 changes: 16 additions & 7 deletions Flowlight/App/UpdateChecker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,15 @@ struct AppRelease: Equatable, Sendable {
}

enum UpdateError: LocalizedError, Equatable {
case noRelease, badResponse(Int), checksumMismatch, noDownload
case noRelease, badResponse(Int), checksumMismatch, noDownload, noChecksum

var errorDescription: String? {
switch self {
case .noRelease: return "No published release was found."
case .badResponse(let code): return code == 403 ? "GitHub is rate-limiting update checks. Try again later." : "GitHub returned HTTP \(code)."
case .checksumMismatch: return "The download didn't match its published checksum, so it wasn't opened."
case .noDownload: return "This release has no disk image to download."
case .noChecksum: return "This release publishes no checksum for its disk image, so the download wasn't opened."
}
}
}
Expand Down Expand Up @@ -205,12 +206,20 @@ final class UpdateChecker: ObservableObject {
let (temp, response) = try await session.download(from: dmgURL, delegate: nil)
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
guard code == 200 else { throw UpdateError.badResponse(code) }
if let sumsURL = release.checksumsURL {
let (sums, _) = try await session.data(from: sumsURL)
let expected = VersionCompare.checksum(for: dmgURL.lastPathComponent, in: String(decoding: sums, as: UTF8.self))
let actual = SHA256.hash(data: try Data(contentsOf: temp)).map { String(format: "%02x", $0) }.joined()
guard expected == nil || expected == actual else { throw UpdateError.checksumMismatch }
}
// Fail closed. This used to accept the download when the release had no SHA256SUMS.txt, or had
// one with no line for this disk image, or one that didn't parse — every way of *not knowing*
// the checksum was treated as knowing it was right, which is the one outcome verification must
// never produce. Every release the workflow publishes carries the file; a release that doesn't
// is one to refuse rather than to trust.
guard let sumsURL = release.checksumsURL else { throw UpdateError.noChecksum }
let (sums, sumsResponse) = try await session.data(from: sumsURL)
let sumsCode = (sumsResponse as? HTTPURLResponse)?.statusCode ?? 0
guard sumsCode == 200 else { throw UpdateError.badResponse(sumsCode) }
guard let expected = VersionCompare.checksum(for: dmgURL.lastPathComponent,
in: String(decoding: sums, as: UTF8.self))
else { throw UpdateError.noChecksum }
let actual = SHA256.hash(data: try Data(contentsOf: temp)).map { String(format: "%02x", $0) }.joined()
guard expected.caseInsensitiveCompare(actual) == .orderedSame else { throw UpdateError.checksumMismatch }
let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask)[0]
let destination = downloads.appendingPathComponent("Flowlight-\(release.version).dmg")
try? FileManager.default.removeItem(at: destination)
Expand Down
24 changes: 24 additions & 0 deletions Flowlight/App/UpdateInstaller.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,25 @@ enum UpdateInstaller {
return bundleURL
}

/// Whether an update may be installed: signed by this app's own team, or — for a build that has no team,
/// which is an ad-hoc local one — refused, because a copy that cannot prove who signed it cannot judge
/// anyone else's signature either.
///
/// `FLSkipUpdateSignatureCheck` exists for the same local builds, which are unsigned and could otherwise
/// never test the update path at all. It is read from the defaults of the *running* app, so it cannot be
/// set by anything inside a downloaded disk image.
static func verifySignature(of app: URL) throws {
if UserDefaults.standard.bool(forKey: "FLSkipUpdateSignatureCheck") { return }
guard let team = CodeSignatureCheck.runningTeamIdentifier() else {
throw InstallError.wrongApp(CodeSignatureCheck.Failure.selfUnknown.errorDescription ?? "unsigned")
}
do {
try CodeSignatureCheck.verify(app, expectedTeam: team)
} catch {
throw InstallError.wrongApp(error.localizedDescription)
}
}

/// Mounts the disk image, copies Flowlight.app to a staging folder and checks it's the expected version.
static func stage(dmg: URL, expectedVersion: String, bundleID: String?) throws -> URL {
let mount = FileManager.default.temporaryDirectory.appendingPathComponent("flowlight-update-\(UUID().uuidString)")
Expand All @@ -49,6 +68,11 @@ enum UpdateInstaller {
if let bundleID, bundle.bundleIdentifier != bundleID {
throw InstallError.wrongApp("bundle \(bundle.bundleIdentifier ?? "?")")
}
// The version and the bundle id above are strings inside the disk image, which nothing signs. This is
// the check that can't be forged: the update has to be signed by the same team as the copy asking for
// it, with a valid Developer ID signature over every nested binary. It runs before anything is staged,
// and the quarantine flag is only dropped later because this passed.
try verifySignature(of: source)

let staging = FileManager.default.temporaryDirectory.appendingPathComponent("flowlight-staged-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true)
Expand Down
26 changes: 26 additions & 0 deletions Flowlight/Ask/RemoteAsk.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,37 @@ struct RemoteProvider: AskProviding {
}
}

/// HTTPS anywhere, or plain HTTP only where the traffic cannot leave the machine or the local network.
static func isSafeEndpoint(_ url: URL) -> Bool {
switch url.scheme?.lowercased() {
case "https": return true
case "http": break
default: return false
}
guard let host = url.host?.lowercased() else { return false }
if host == "localhost" || host == "::1" || host.hasSuffix(".local") { return true }
if host == "127.0.0.1" || host.hasPrefix("127.") { return true }
// The private ranges, for a model served from another machine on the same network.
if host.hasPrefix("10.") || host.hasPrefix("192.168.") { return true }
if host.hasPrefix("172.") {
let second = host.split(separator: ".").dropFirst().first.flatMap { Int($0) } ?? -1
return (16...31).contains(second)
}
return false
}

func answer(_ request: AskRequest, run: @escaping @Sendable (AskCall) async -> String,
sending: @escaping @Sendable (String) -> Void) async throws -> String {
guard let url = URL(string: endpoint), !endpoint.isEmpty else {
throw Failure.notConfigured(L("No endpoint is set for %@.", kind.title))
}
// The request carries an API key and a question about this Mac's own traffic. Over http:// both are
// readable by anything on the path — including, with some irony, Flowlight. A loopback or private
// address is the exception worth keeping: that is how someone points this at a model running on their
// own machine or LAN, where there is no network to eavesdrop on.
guard Self.isSafeEndpoint(url) else {
throw Failure.notConfigured(L("%@ is not an https:// address. A key and your question would cross the network in the clear, so Flowlight won't send them.", endpoint))
}
guard !kind.needsKey || !apiKey.isEmpty else {
throw Failure.notConfigured(L("%@ needs an API key. Add one in Settings — it goes to your login Keychain.", kind.title))
}
Expand Down
28 changes: 25 additions & 3 deletions Flowlight/Inspection/HTTPStream.swift
Original file line number Diff line number Diff line change
Expand Up @@ -247,12 +247,34 @@ enum HeaderRedaction {
"x-auth-token", "x-amz-security-token", "x-csrf-token", "x-xsrf-token", "openai-organization-key",
]

/// Words that make a header a credential whatever it is called. The named list above can only know the
/// headers someone thought of: `X-Access-Key`, `X-Client-Credential` and every vendor's own spelling went
/// straight into the database, under a promise that says API keys are redacted. Matching on the word
/// rather than the whole name is what closes that, at the cost of occasionally hiding a value that wasn't
/// secret — the right direction to err in for something written to disk.
static let secretWords = ["token", "secret", "api-key", "apikey", "key", "auth", "credential", "password",
"passwd", "session", "signature", "sig", "nonce", "bearer"]

/// Headers whose name contains one of those words but which carry no secret — without these, ordinary
/// request metadata would be redacted and the recorded exchange would be harder to read for no gain.
static let notSecret: Set<String> = [
"keep-alive", "x-request-id", "x-correlation-id", "x-session-duration", "content-signature-algorithm",
]

static func isSecret(_ name: String) -> Bool {
let lower = name.lowercased()
if notSecret.contains(lower) { return false }
if secretNames.contains(lower) { return true }
// Word-ish boundaries, so `x-api-key` and `x_auth_token` match while `monkey` and `authority` don't.
let parts = lower.split(whereSeparator: { $0 == "-" || $0 == "_" || $0 == "." }).map(String.init)
if parts.contains(where: { secretWords.contains($0) }) { return true }
return secretWords.contains { $0.contains("-") && lower.contains($0) }
}

static func redact(_ headers: [HTTPHeader]) -> [HTTPHeader] {
headers.map { header in
let name = header.name.lowercased()
guard secretNames.contains(name) || name.hasSuffix("-token") || name.hasSuffix("-secret") || name.contains("api-key") else {
return header
}
guard isSecret(name) else { return header }
// Keep the scheme ("Bearer") so the kind of credential is still visible.
let scheme = header.value.split(separator: " ").first.map(String.init)
let keepScheme = name.hasSuffix("authorization") && header.value.contains(" ") ? scheme.map { $0 + " " } ?? "" : ""
Expand Down
47 changes: 47 additions & 0 deletions FlowlightTests/UpdateVerificationTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import XCTest
@testable import Flowlight

final class UpdateVerificationTests: XCTestCase {
private let sums = """
a3f1c2d4e5b6a7980123456789abcdef0123456789abcdef0123456789abcdef Flowlight.dmg
0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff Flowlight-0.8.2.pkg
"""

func testAChecksumIsFoundByFileName() {
XCTAssertEqual(VersionCompare.checksum(for: "Flowlight.dmg", in: sums),
"a3f1c2d4e5b6a7980123456789abcdef0123456789abcdef0123456789abcdef")
}

/// The case that made the old check useless: a checksum file that says nothing about this file. It used to
/// read as "no expectation, so anything matches"; the download path now treats nil as a refusal.
func testAMissingEntryIsNilRatherThanAMatch() {
XCTAssertNil(VersionCompare.checksum(for: "Flowlight.dmg", in: "not a checksum file at all"))
XCTAssertNil(VersionCompare.checksum(for: "Something-Else.dmg", in: sums))
}

/// An update signed by somebody else is the attack this exists to stop, so the two failures have to be
/// distinguishable — "signed by another team" is a different event from "signature damaged".
func testTheTeamMismatchIsReportedAsItself() throws {
let failure = CodeSignatureCheck.Failure.wrongTeam(found: "ATTACKER99", expected: "ABCDE12345")
XCTAssertEqual(failure, .wrongTeam(found: "ATTACKER99", expected: "ABCDE12345"))
XCTAssertNotEqual(failure, .notSigned)
let description = try XCTUnwrap(failure.errorDescription)
XCTAssertTrue(description.contains("ATTACKER99"))
XCTAssertTrue(description.contains("ABCDE12345"))
}

/// Verification has to fail on a bundle that isn't signed at all, rather than passing it for lack of a
/// signature to disagree with. `/bin` is a real path that is not a signed app bundle.
func testAnUnsignedPathDoesNotVerify() {
XCTAssertThrowsError(try CodeSignatureCheck.verify(URL(fileURLWithPath: "/bin"), expectedTeam: "ABCDE12345"))
}

/// The running app is what decides which team an update must carry. On a signed build this is the Team ID;
/// on an ad-hoc local build it is nil, and the installer refuses rather than guessing.
func testTheExpectedTeamComesFromTheRunningApp() {
// Either answer is correct depending on how the tests were built; what matters is that asking is safe
// and that nil is an answer the caller has to handle rather than a crash.
let team = CodeSignatureCheck.runningTeamIdentifier()
if let team { XCTAssertFalse(team.isEmpty) }
}
}
2 changes: 1 addition & 1 deletion docs/404.html
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ <h1>That page isn't here</h1><p class="lede">Try the <a href="/">home page</a>,
<div class="legal">
<span>© 2026 The Flowlight contributors. Flowlight is free software, released under the
<a href="https://github.com/xinbetween/flowlight/blob/main/LICENSE">GNU General Public License v3.0</a>.</span>
<span>Version 0.8.1 · Not affiliated with Apple or any AI provider named on this site.</span>
<span>Version 0.8.2 · Not affiliated with Apple or any AI provider named on this site.</span>
</div>
</div>
</footer>
Expand Down
2 changes: 1 addition & 1 deletion docs/about/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ <h2 id="thanks">Thanks</h2>
<div class="legal">
<span>© 2026 The Flowlight contributors. Flowlight is free software, released under the
<a href="https://github.com/xinbetween/flowlight/blob/main/LICENSE">GNU General Public License v3.0</a>.</span>
<span>Version 0.8.1 · Not affiliated with Apple or any AI provider named on this site.</span>
<span>Version 0.8.2 · Not affiliated with Apple or any AI provider named on this site.</span>
</div>
</div>
</footer>
Expand Down
2 changes: 1 addition & 1 deletion docs/de/about/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ <h2 id="thanks">Dank</h2>
</div>
<div class="legal">
<span>© 2026 Die Flowlight-Mitwirkenden. Flowlight ist freie Software, veröffentlicht unter der <a href="https://github.com/xinbetween/flowlight/blob/main/LICENSE">GNU General Public License v3.0</a>.</span>
<span>Version 0.8.1 · Nicht verbunden mit Apple oder einem der hier genannten KI-Anbieter.</span>
<span>Version 0.8.2 · Nicht verbunden mit Apple oder einem der hier genannten KI-Anbieter.</span>
</div>
</div>
</footer>
Expand Down
Loading
Loading