Skip to content
Closed
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
246 changes: 223 additions & 23 deletions .github/workflows/release.yml

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions .greptile/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@
{
"id": "sudoers-rule-is-four-exact-lines",
"severity": "high",
"scope": ["scripts/install.sh", "scripts/uninstall.sh", "Sources/Insomnia/Core/SleepGuard.swift", "README.md", "SECURITY.md", "docs/spec.md"],
"rule": "The file install.sh writes to /etc/sudoers.d/insomnia grants NOPASSWD for exactly these four commands and nothing else: /usr/bin/pmset -a disablesleep 1, /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -b lowpowermode 1, /usr/bin/pmset -b lowpowermode 0. No wildcard, no ALL, no bare /usr/bin/pmset, no other command, and it is validated with visudo -cf before it is installed. SleepGuard.swift runs only those four argument lists, and README.md, SECURITY.md and docs/spec.md list the same four."
"scope": ["scripts/install.sh", "scripts/uninstall.sh", "Sources/Insomnia/Core/SleepGuard.swift", "README.md", "docs/install.md", "SECURITY.md", "docs/spec.md"],
"rule": "The file install.sh writes to /etc/sudoers.d/insomnia grants NOPASSWD for exactly these four commands and nothing else: /usr/bin/pmset -a disablesleep 1, /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -b lowpowermode 1, /usr/bin/pmset -b lowpowermode 0. No wildcard, no ALL, no bare /usr/bin/pmset, no other command, and it is validated with visudo -cf before it is installed. SleepGuard.swift runs only those four argument lists, and README.md, docs/install.md, SECURITY.md and docs/spec.md list the same four."
},
{
"id": "behavior-changes-come-with-regression-tests",
Expand Down
8 changes: 8 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,11 @@ certification. Real-machine validation requires a supervised, separately
approved run on a ventilated surface with recoverable work. Record the exact
revision, macOS/hardware versions, observed result, and remaining limitations
in the release validation record. Never stress or heat a machine in a bag.

The app icon is the menu bar's open eye on a charcoal tile.
[AppIconArtwork](Sources/Insomnia/UI/AppIconArtwork.swift) draws it from the
same [vector geometry](Sources/Insomnia/UI/EyeMarkGeometry.swift) as the menu
bar's closed eye, which opens while a session runs. After changing the
artwork, run `./scripts/generate-app-icon.sh`. It regenerates the packaged PNG
and ICNS assets and the README's SVG offline, with Xcode's swiftc and
iconutil.
642 changes: 132 additions & 510 deletions README.md

Large diffs are not rendered by default.

37 changes: 28 additions & 9 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,9 @@ ignores the data-protection accessibility classes (this device only, when
unlocked); using that Keychain needs an access-group entitlement, which needs
a team id. Releases are ad-hoc signed, so both limits stay.

The recovery LaunchAgent runs only the `backstop.sh` sealed inside the signed
app bundle, after `codesign --verify --strict` passes against the code
In releases built by the Release workflow and in builds of the current
source, the recovery LaunchAgent runs only the `backstop.sh` sealed inside the
signed app bundle, after `codesign --verify --strict` passes against the code
requirement pinned in its plist (the build's cdhash for an ad-hoc signature).
An edited bundle or script is refused and logged. The app pins the code it is
running (its own designated requirement, after confirming that the bundle on
Expand All @@ -59,20 +60,38 @@ tampered bundle; they are not a boundary against a process running as the
same user, which can edit the plist, load its own agent, replace and relaunch
the app, and invoke the four pmset commands directly.

Release zips are built by the Release workflow from the tagged commit and
published with a `SHA256SUMS` file and a GitHub build provenance attestation.
Verify both before installing (`shasum -a 256 -c SHA256SUMS`,
Release zips on both channels, stable releases (`v<version>` tags) and
nightly prereleases (`nightly-*` tags built from `main`), are built by the
Release workflow from the commit they name and published with a
`SHA256SUMS` file and a GitHub build provenance attestation. Verify both
before installing (`shasum -a 256 -c SHA256SUMS`,
`gh attestation verify <zip> -R krishhgg/Insomnia --signer-workflow
krishhgg/Insomnia/.github/workflows/release.yml --source-ref
refs/tags/v<version>`); `install.sh --app` then checks the signature,
identifier and version of a private copy of the bundle before asking for a
password, installs that copy, and refuses
krishhgg/Insomnia/.github/workflows/release.yml --source-ref <ref>
--source-digest <commit>`, with `refs/tags/v<version>` or `refs/heads/main`
as the ref and the full commit the release was built from as the digest; the
release notes carry the command filled in). `install.sh
--app` then checks the signature, identifier and version of a private copy
of the bundle before asking for a password, installs that copy, and refuses
it unless `--allow-unverified-origin` is given, because it cannot verify where
a bundle came from, whatever its signature names. The attestation shows which workflow run produced
the bytes, not that the code is free of defects. Releases are ad-hoc signed and
not notarized ([docs/releasing.md](docs/releasing.md)), so macOS blocks their
first launch.

The first stable release, `v0.1.0`, is the exception. It was packaged by hand
from the app installed on the maintainer's Mac, so it has no attestation: the
`SHA256SUMS` file and the per-file hashes in its release notes are the only
check on the download. Its `scripts/install.sh --app` runs
`codesign --verify --strict --deep` and checks the bundle identifier before
the password prompt, but it has no `--allow-unverified-origin` opt-in: it
installs any intact bundle it is given that has that identifier and the
0.1.0 layout. It does not check the version. Its recovery agent predates the
sealed script and the pinned requirement described above: the installer
copies `backstop.sh` into `~/Library/Application Support/Insomnia/`, and the
agent runs that copy with `/bin/bash` without checking any code signature, so
an edited copy runs as it is. The `README.md` in its zip describes that
layout and its limits.

Passing automated checks or a secret scan does not establish the absence of
vulnerabilities. Do not probe recovery by disrupting someone else's processes,
power settings, network, or data.
32 changes: 20 additions & 12 deletions Sources/Insomnia/Core/SleepGuard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ struct PmsetSleepGuard: SleepGuarding {
throw SleepGuardError(command: "pmset -g custom", status: r.status, stderr: r.stderr)
}
guard let on = Self.parseLowPowerMode(r.stdout) else {
throw SleepGuardError(command: "pmset -g custom", status: 0, stderr: "no lowpowermode line under Battery Power")
throw SleepGuardError(command: "pmset -g custom", status: 0, stderr: "no lowpowermode or powermode line under Battery Power")
}
return on
}
Expand All @@ -103,12 +103,19 @@ struct PmsetSleepGuard: SleepGuarding {
}

/// `lowpowermode` in the `Battery Power:` section of `pmset -g custom`
/// (the one `pmset -b` writes). nil when the section or key is absent,
/// as on a desktop, or when the value is anything but an explicit `0`
/// or `1`: an unreadable value is not proof that the mode is off, and
/// treating it as off would take over a preference the user may have set.
/// (the one `pmset -b` writes). macOS 27 no longer prints that key and
/// reports the mode as `powermode` instead (`0` automatic, `1` Low Power,
/// `2` High Power), while `pmset -b lowpowermode 0|1` still sets it, so
/// `powermode` is read when `lowpowermode` is absent. A `lowpowermode`
/// line always wins over `powermode`. nil when the section or both keys
/// are absent, as on a desktop, or when the value is anything but an
/// explicit `0` or `1`: an unreadable value is not proof that the mode is
/// off, and treating it as off would take over a preference the user may
/// have set. High Power (`powermode 2`) is such a preference.
static func parseLowPowerMode(_ output: String) -> Bool? {
var inBattery = false
var lowPowerValue: Substring?
var powerModeValue: Substring?
for rawLine in output.split(whereSeparator: \.isNewline) {
let line = rawLine.trimmingCharacters(in: .whitespaces)
if line.hasSuffix(":") {
Expand All @@ -117,14 +124,15 @@ struct PmsetSleepGuard: SleepGuarding {
}
guard inBattery else { continue }
let parts = line.split(whereSeparator: { $0 == " " || $0 == "\t" })
guard parts.count >= 2, parts[0] == "lowpowermode" else { continue }
switch parts[1] {
case "1": return true
case "0": return false
default: return nil
}
guard parts.count >= 2 else { continue }
if parts[0] == "lowpowermode", lowPowerValue == nil { lowPowerValue = parts[1] }
if parts[0] == "powermode", powerModeValue == nil { powerModeValue = parts[1] }
}
switch lowPowerValue ?? powerModeValue {
case "1"?: return true
case "0"?: return false
default: return nil
}
return nil
}

/// The one path for every `sudo pmset` the app runs, including a check
Expand Down
4 changes: 2 additions & 2 deletions Sources/Insomnia/System/DisplayPower.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ enum DisplayPower {
/// type information, so on any other major the calls are refused until
/// someone measures them again, rather than passing a display id and a
/// float pointer into a function whose signature may have changed.
static let measuredDisplayServicesMajors: Set<Int> = [26]
static let measuredDisplayServicesMajors: Set<Int> = [26, 27]

/// nil when `major` was measured; otherwise why DisplayServices is refused.
static func displayServicesRefusal(osMajorVersion major: Int) -> String? {
Expand Down Expand Up @@ -160,7 +160,7 @@ struct NoopKeyboardBacklight: KeyboardBacklighting {
func isSuppressedOrDimmed() -> Bool { false }
}

/// Private DisplayServices.framework, measured on macOS 26 (see
/// Private DisplayServices.framework, measured on macOS 26 and 27 (see
/// docs/release-validation.md): Get/SetBrightness take effect at once
/// whether the display is awake, asleep or held by a display assertion.
/// Symbols are resolved once, lazily, under a lock, and only on a macOS
Expand Down
36 changes: 36 additions & 0 deletions Tests/InsomniaTests/PmsetParsingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,4 +102,40 @@ final class PmsetParsingTests: XCTestCase {
XCTAssertNil(PmsetSleepGuard.parseLowPowerMode("Battery Power:\n lowpowermode 2\n"))
XCTAssertNil(PmsetSleepGuard.parseLowPowerMode("Battery Power:\n lowpowermode (unknown)\n"), "an unreadable value was taken as proof the mode is off")
}

// `pmset -g custom` on macOS 27.0.1 (MacBook Pro, M5 Pro): no
// `lowpowermode` key, the mode is reported as `powermode`.
let custom27 = """
Battery Power:
lidwake 1
powermode 1
sleep 1
AC Power:
lidwake 1
powermode 0
sleep 1
"""

func testLowPowerModeIsReadFromPowermodeWhenLowpowermodeIsAbsent() {
XCTAssertEqual(PmsetSleepGuard.parseLowPowerMode(custom27), true)
XCTAssertEqual(PmsetSleepGuard.parseLowPowerMode("Battery Power:\n powermode 0\n"), false)
XCTAssertNil(PmsetSleepGuard.parseLowPowerMode("AC Power:\n powermode 1\n"), "read the AC value instead of the battery value")
let swapped = custom27.replacingOccurrences(of: "powermode 1", with: "powermode X")
.replacingOccurrences(of: "powermode 0", with: "powermode 1")
.replacingOccurrences(of: "powermode X", with: "powermode 0")
XCTAssertEqual(PmsetSleepGuard.parseLowPowerMode(swapped), false, "read the AC value instead of the battery value")
}

/// High Power is a preference the user set: it must read as unknown, not
/// as "off", or the session end's `lowpowermode 0` would replace it.
func testHighPowerModeIsUnknownNotOff() {
XCTAssertNil(PmsetSleepGuard.parseLowPowerMode("Battery Power:\n powermode 2\n"))
XCTAssertNil(PmsetSleepGuard.parseLowPowerMode("Battery Power:\n powermode (unknown)\n"))
}

func testLowpowermodeWinsOverPowermode() {
XCTAssertEqual(PmsetSleepGuard.parseLowPowerMode("Battery Power:\n powermode 0\n lowpowermode 1\n"), true)
XCTAssertEqual(PmsetSleepGuard.parseLowPowerMode("Battery Power:\n lowpowermode 0\n powermode 1\n"), false)
XCTAssertNil(PmsetSleepGuard.parseLowPowerMode("Battery Power:\n lowpowermode 2\n powermode 1\n"), "an unreadable lowpowermode fell back to powermode")
}
}
29 changes: 18 additions & 11 deletions Tests/InsomniaTests/PrivateDisplayGuardTests.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import XCTest
@testable import Insomnia

/// The measured DisplayServices majors as the refusal message names them, and a major past all of them. The
/// assertions below are built from `DisplayPower.measuredDisplayServicesMajors`, so measuring the next macOS is a
/// one-line change there rather than an edit to every expected message.
private let measuredMajorsText = "measured on macOS \(DisplayPower.measuredDisplayServicesMajors.sorted().map(String.init).joined(separator: ", ")) only"
private let unmeasuredMajor = (DisplayPower.measuredDisplayServicesMajors.max() ?? 0) + 1

// Fake KeyboardBrightnessClient classes. They carry the private class's
// selectors, and the Objective-C runtime reports their type encodings the
// same way it reports the real ones, so the guard is exercised for real
Expand Down Expand Up @@ -84,22 +90,23 @@ final class PrivateDisplayGuardTests: XCTestCase {

func testDisplayServicesIsAllowedOnMeasuredMajorsOnly() throws {
XCTAssertNil(DisplayPower.displayServicesRefusal(osMajorVersion: 26))
for major in [15, 25, 27, 30] {
XCTAssertNil(DisplayPower.displayServicesRefusal(osMajorVersion: 27))
for major in [15, 25, unmeasuredMajor, unmeasuredMajor + 3] {
let why = try XCTUnwrap(DisplayPower.displayServicesRefusal(osMajorVersion: major))
XCTAssertTrue(why.contains("this is macOS \(major)"), why)
XCTAssertTrue(why.contains("measured on macOS 26 only"), why)
XCTAssertTrue(why.contains(measuredMajorsText), why)
}
}

/// On a macOS the calls were not measured on, every brightness call
/// throws before the framework is opened, with the reason Settings
/// shows, and the refusal is logged once.
func testTheDimmerOnAnUnmeasuredMacOSRefusesEveryBrightnessCall() throws {
let dimmer = DisplayServicesDimmer(osMajorVersion: 27)
let dimmer = DisplayServicesDimmer(osMajorVersion: unmeasuredMajor)

let why = try XCTUnwrap(dimmer.refusal())

XCTAssertTrue(why.contains("this is macOS 27"), why)
XCTAssertTrue(why.contains("this is macOS \(unmeasuredMajor)"), why)
XCTAssertThrowsError(try dimmer.readBrightness()) { XCTAssertEqual($0.localizedDescription, why) }
XCTAssertThrowsError(try dimmer.setBrightness(0)) { XCTAssertEqual($0.localizedDescription, why) }
XCTAssertEqual(dimmer.refusal(), why)
Expand Down Expand Up @@ -234,7 +241,7 @@ final class RefusedDarkeningTests: XCTestCase {
}

private var refusedDisplay: BrightnessOnlyDimmer {
BrightnessOnlyDimmer(inner: DisplayServicesDimmer(osMajorVersion: 27), power: h.display)
BrightnessOnlyDimmer(inner: DisplayServicesDimmer(osMajorVersion: unmeasuredMajor), power: h.display)
}

private func logText() -> String {
Expand All @@ -258,8 +265,8 @@ final class RefusedDarkeningTests: XCTestCase {
XCTAssertEqual(h.keyboard.sets, [0])
XCTAssertEqual(h.display.sleepRequests, 1)
let log = logText()
XCTAssertTrue(log.contains("display darkening refused: DisplayServices brightness calls were measured on macOS 26 only; this is macOS 27"), log)
XCTAssertTrue(log.contains("display darken on lid close skipped: DisplayServices brightness calls were measured on macOS 26 only; this is macOS 27"), log)
XCTAssertTrue(log.contains("display darkening refused: DisplayServices brightness calls were \(measuredMajorsText); this is macOS \(unmeasuredMajor)"), log)
XCTAssertTrue(log.contains("display darken on lid close skipped: DisplayServices brightness calls were \(measuredMajorsText); this is macOS \(unmeasuredMajor)"), log)
XCTAssertTrue(log.contains("keyboard backlight off (was brightness 0.5)"), log)

await actions.onOpen()
Expand Down Expand Up @@ -357,7 +364,7 @@ final class RefusedDarkeningTests: XCTestCase {
XCTAssertFalse(after.hasLidActions, "the live session keeps the sleep guard journaled, but no lid action is left to undo")
let error = try XCTUnwrap(m.lastError)
XCTAssertTrue(error.hasPrefix("could not restore the brightness saved before the lid closed on this macOS build. "), error)
XCTAssertTrue(error.contains("Display brightness 0.8: DisplayServices brightness calls were measured on macOS 26 only; this is macOS 27"), error)
XCTAssertTrue(error.contains("Display brightness 0.8: DisplayServices brightness calls were \(measuredMajorsText); this is macOS \(unmeasuredMajor)"), error)
XCTAssertTrue(error.contains("Keyboard backlight 0.3: KeyboardBrightnessClient isKeyboardBuiltIn: has type encoding B@:i"), error)
XCTAssertTrue(error.contains("Set the levels with the brightness keys or Control Center"), error)
XCTAssertTrue(error.contains("the saved values stay in the journal"), error)
Expand Down Expand Up @@ -1120,7 +1127,7 @@ final class RefusedDarkeningTests: XCTestCase {

let incomplete = try XCTUnwrap(h.notifier.posts.first { $0.title == SessionManager.incompleteTitle }, "\(h.notifier.posts)")
XCTAssertTrue(incomplete.body.contains("could not restore keyboard backlight"), incomplete.body)
XCTAssertTrue(incomplete.body.contains("Display brightness 0.8: DisplayServices brightness calls were measured on macOS 26 only"), incomplete.body)
XCTAssertTrue(incomplete.body.contains("Display brightness 0.8: DisplayServices brightness calls were \(measuredMajorsText)"), incomplete.body)
let after = try XCTUnwrap(try h.store.loadState())
XCTAssertEqual(after.savedKeyboardBrightness, 0.3, "the failed write keeps its entry")
XCTAssertTrue(after.displayRestoreRefused)
Expand All @@ -1144,7 +1151,7 @@ final class RefusedDarkeningTests: XCTestCase {

let incomplete = try XCTUnwrap(h.notifier.posts.first { $0.title == SessionManager.incompleteTitle }, "\(h.notifier.posts)")
XCTAssertTrue(incomplete.body.contains("could not restore audio"), incomplete.body)
XCTAssertTrue(incomplete.body.contains("Display brightness 0.8: DisplayServices brightness calls were measured on macOS 26 only"), incomplete.body)
XCTAssertTrue(incomplete.body.contains("Display brightness 0.8: DisplayServices brightness calls were \(measuredMajorsText)"), incomplete.body)
XCTAssertEqual(m.lastError.map { incomplete.body.hasPrefix($0) }, true, "\(String(describing: m.lastError))")
}

Expand Down Expand Up @@ -2982,7 +2989,7 @@ final class RefusedDarkeningTests: XCTestCase {
let notes = m.darkenRefusals

XCTAssertEqual(notes.count, 2, "\(notes)")
XCTAssertTrue(notes.first?.hasPrefix("Display: DisplayServices brightness calls were measured on macOS 26 only; this is macOS 27") == true, "\(notes)")
XCTAssertTrue(notes.first?.hasPrefix("Display: DisplayServices brightness calls were \(measuredMajorsText); this is macOS \(unmeasuredMajor)") == true, "\(notes)")
XCTAssertTrue(notes.last?.hasPrefix("Keyboard backlight: KeyboardBrightnessClient isKeyboardBuiltIn: has type encoding B@:i") == true, "\(notes)")
XCTAssertEqual(h.makeManager().darkenRefusals, [], "the harness fakes refuse nothing")
}
Expand Down
Loading
Loading