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
2 changes: 2 additions & 0 deletions Resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
<integer>1</integer>
<key>InsomniaSessionCommandVersion</key>
<integer>1</integer>
<key>InsomniaBackstopCommand</key>
<string>--backstop</string>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2026 Krish Garg. MIT License.</string>
</dict>
Expand Down
18 changes: 16 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,22 @@ a team id. Releases are ad-hoc signed, so both limits stay.

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).
signed app bundle, after strict signature and resource validation against the
code requirement pinned in its plist (the build's cdhash for an ad-hoc
signature). The native command uses the Security framework and directly
executes `/bin/bash` with the sealed script path; the external fallback uses
`codesign --verify --strict`.
For current ad-hoc builds, launchd first enforces a `SpawnConstraint` with
those cdhashes before starting `Insomnia --backstop`. A different binary,
even one re-signed ad-hoc that skips the script verifier, is rejected before
its entry point. This uses macOS launch-constraint enforcement, with System
Integrity Protection enabled. The installer and every `arm()` require an
affirmative `csrutil status` result before selecting native execution;
disabled, custom, unreadable or timed-out status uses the external
system-shell verifier, as do older bundles and requirements that cannot
be represented entirely as cdhashes. After changing SIP, a previously
registered native job depends on the new OS enforcement state until the
app next arms it; changing SIP does not itself rewrite the stored plist.
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
disk is still that code and passes the agent's check), so a bundle edited or
Expand Down
31 changes: 31 additions & 0 deletions Sources/Insomnia/Core/BackstopCommand.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import Darwin
import Foundation

/// Launchd starts our signed executable so Login Items can attribute the
/// recovery agent to Insomnia, then we replace it with the verified script.
enum BackstopCommand {
static let argument = "--backstop"

static func run(_ arguments: [String], bundle: URL = Bundle.main.bundleURL) -> Int32? {
guard arguments.first == argument else { return nil }
guard arguments.count == 2, !arguments[1].isEmpty, bundle.pathExtension == "app" else {
fputs("usage: Insomnia --backstop <code-requirement> (from Insomnia.app)\n", stderr)
return 64
}
do {
try CodeRequirement.verify(codeAt: bundle, satisfies: arguments[1])
} catch {
let reason = error.localizedDescription.replacingOccurrences(of: "\n", with: " ")
Log.error("backstop agent: \(bundle.path) does not satisfy the pinned code requirement; backstop.sh not run. Reinstall Insomnia (scripts/install.sh). \(reason)")
return 1
}
let command = ["/bin/bash", Paths.backstopScript(inBundle: bundle).path]
var argv = command.map { strdup($0) } + [nil]
defer { for argument in argv { free(argument) } }
return argv.withUnsafeMutableBufferPointer {
execv("/bin/bash", $0.baseAddress!)
perror("Insomnia backstop: exec /bin/bash")
return 1
}
}
}
63 changes: 55 additions & 8 deletions Sources/Insomnia/Core/LaunchdBackstop.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import Foundation
/// signature against the requirement pinned in the plist and only then
/// execs the script, so a backstop.sh edited on disk is never run by
/// launchd. Nothing executable lives in a writable support directory.
/// Before launching the native entry point, launchd enforces SpawnConstraint
/// against the pinned cdhashes. A replacement binary cannot bypass the check
/// by declining to verify the script. Older bundles use the external shell.
///
/// What the app pins is the requirement of the code it is itself running
/// (CodeRequirement.pin), after checking that the bundle on disk still is
Expand All @@ -34,6 +37,7 @@ struct BackstopTarget: Sendable, Equatable {
/// Designated requirement of the bundle's signature, in requirement
/// language, as `codesign -d -r-` prints it (see CodeRequirement).
let requirement: String
var nativeExecutable: URL? = nil

var script: URL { Paths.backstopScript(inBundle: bundle) }
}
Expand All @@ -45,11 +49,12 @@ struct LaunchdBackstop: BackstopScheduling {
typealias BundlePinner = @Sendable (_ bundle: URL) throws -> String

static let launchctl = "/bin/launchctl"
static let csrutil = "/usr/bin/csrutil"
/// Seconds between backstop.sh runs while loaded. install.sh writes the same value.
static let pollInterval = 60
static let commandTimeout: TimeInterval = 15

/// What launchd runs: `/bin/sh -c <agentProgram> sh <requirement> <bundle>`.
/// The external verifier for bundles without a constrained native command.
/// The program verifies the bundle ($2) against the requirement ($1) with
/// codesign and execs the sealed backstop.sh only when that passes; the
/// resource seal covers the script, so an edited copy fails here. On
Expand Down Expand Up @@ -111,10 +116,28 @@ struct LaunchdBackstop: BackstopScheduling {
} catch {
throw BackstopError(message: "the recovery agent cannot pin \(bundle.path): \(error.localizedDescription). Reinstall with scripts/install.sh")
}
let desired = Self.plistDictionary(label: label, target: BackstopTarget(bundle: bundle, requirement: requirement))
var executable = Self.nativeExecutable(in: bundle)
if executable != nil, CodeRequirement.launchHashes(in: requirement) != nil {
// Recheck each arm: launch constraints rely on SIP enforcement.
do {
let status = try await run(Self.csrutil, ["status"])
let output = status.stdout.hasSuffix("\n") ? String(status.stdout.dropLast()) : status.stdout
if !status.succeeded || !status.stderr.isEmpty || output != "System Integrity Protection status: enabled." {
executable = nil
}
} catch is CancellationError {
throw CancellationError()
} catch {
executable = nil
}
}
let desired = Self.plistDictionary(label: label, target: BackstopTarget(
bundle: bundle, requirement: requirement, nativeExecutable: executable
))
// Armed when the plist the next login loads is this build's and the
// loaded job is the one it describes: the same command line, started
// every `pollInterval` seconds. A loaded label alone may be another
// every `pollInterval` seconds, with the native spawn constraint
// present. A loaded label alone may be another
// build's job, pinning a bundle or requirement this one does not
// satisfy (install.sh can leave one loaded when it stops between its
// bootstrap and publishing the plist), or a job loaded from a plist
Expand All @@ -137,15 +160,30 @@ struct LaunchdBackstop: BackstopScheduling {

// MARK: Plist

static func nativeExecutable(in bundle: URL) -> URL? {
guard let data = try? Data(contentsOf: bundle.appendingPathComponent("Contents/Info.plist")),
let info = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any],
info["InsomniaBackstopCommand"] as? String == BackstopCommand.argument,
let name = info["CFBundleExecutable"] as? String,
!name.isEmpty, name != ".", name != "..", !name.contains("/") else { return nil }
return bundle.appendingPathComponent("Contents/MacOS").appendingPathComponent(name)
}

/// Pure builder, testable without launchd. Must produce exactly what
/// install.sh writes, or every arm() reloads the agent.
static func plistDictionary(label: String, target: BackstopTarget) -> [String: Any] {
[
var plist: [String: Any] = [
"Label": label,
"ProgramArguments": ["/bin/sh", "-c", agentProgram, "sh", target.requirement, target.bundle.path],
"RunAtLoad": true,
"StartInterval": pollInterval,
]
if let executable = target.nativeExecutable, let hashes = CodeRequirement.launchHashes(in: target.requirement) {
plist["ProgramArguments"] = [executable.path, BackstopCommand.argument, target.requirement]
plist["SpawnConstraint"] = ["cdhash": ["$in": hashes]]
plist["AssociatedBundleIdentifiers"] = [Paths.bundleIdentifier]
}
return plist
}

func plistOnDiskMatches(_ desired: [String: Any]) -> Bool {
Expand Down Expand Up @@ -225,15 +263,19 @@ struct LaunchdBackstop: BackstopScheduling {
/// StartInterval in seconds; nil when the job has none, so launchd
/// never starts it again by itself.
var runInterval: Int?
/// Older native agents ran the same command without enforcing its pin.
var hasSpawnConstraint: Bool

init(arguments: [String], runInterval: Int?) {
init(arguments: [String], runInterval: Int?, hasSpawnConstraint: Bool = false) {
self.arguments = arguments
self.runInterval = runInterval
self.hasSpawnConstraint = hasSpawnConstraint
}

/// The job a plist from `plistDictionary` loads as.
init(plist: [String: Any]) {
self.init(arguments: plist["ProgramArguments"] as? [String] ?? [], runInterval: plist["StartInterval"] as? Int)
self.init(arguments: plist["ProgramArguments"] as? [String] ?? [], runInterval: plist["StartInterval"] as? Int,
hasSpawnConstraint: plist["SpawnConstraint"] != nil)
}
}

Expand All @@ -250,12 +292,14 @@ struct LaunchdBackstop: BackstopScheduling {
/// seconds` is there only for a job with a StartInterval. nil when the
/// arguments block is missing, repeated, unclosed or holds a line this
/// does not know; a run interval in another form reads as none. Either
/// way the job does not match a plist, and arm() reloads it.
/// way the job does not match a plist, and arm() reloads it. The top-level
/// properties line reports whether the native spawn constraint is loaded.
static func loadedJob(fromPrint output: String) -> LoadedJob? {
let intervalPrefix = "\trun interval = ", intervalSuffix = " seconds"
var arguments: [String]?
var inArguments = false
var runInterval: Int?
var hasSpawnConstraint = false
for line in output.split(separator: "\n", omittingEmptySubsequences: false) {
if inArguments {
if line == "\t}" {
Expand All @@ -271,10 +315,13 @@ struct LaunchdBackstop: BackstopScheduling {
inArguments = true
} else if line.hasPrefix(intervalPrefix), line.hasSuffix(intervalSuffix) {
runInterval = Int(line.dropFirst(intervalPrefix.count).dropLast(intervalSuffix.count))
} else if line.hasPrefix("\tproperties = ") {
hasSpawnConstraint = line.dropFirst("\tproperties = ".count)
.components(separatedBy: " | ").contains("has spawn constraint")
}
}
guard let arguments, !inArguments else { return nil }
return LoadedJob(arguments: arguments, runInterval: runInterval)
return LoadedJob(arguments: arguments, runInterval: runInterval, hasSpawnConstraint: hasSpawnConstraint)
}

/// bootout by service target (ignored if not loaded: the trusted path may
Expand Down
20 changes: 20 additions & 0 deletions Sources/Insomnia/System/CodeRequirement.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,26 @@ import Security
/// which install.sh reads; the two must agree for the app to recognise the
/// plist install.sh wrote (PackagingTests checks this on a scratch bundle).
enum CodeRequirement {
/// Only a requirement made entirely of cdhash clauses can be enforced
/// before executing ad-hoc code. Keep the external shell verifier for
/// any other requirement instead of dropping a clause we do not know.
static func launchHashes(in requirement: String) -> [Data]? {
var hashes: [Data] = []
for clause in requirement.components(separatedBy: " or ") {
guard clause.hasPrefix("cdhash H\""), clause.hasSuffix("\"") else { return nil }
let hex = Array(clause.dropFirst(9).dropLast().utf8)
guard hex.count == 40, hex.allSatisfy({ "0123456789abcdefABCDEF".utf8.contains($0) }) else { return nil }
var hash = Data()
for offset in stride(from: 0, to: hex.count, by: 2) {
let pair = String(decoding: hex[offset..<(offset + 2)], as: UTF8.self)
guard let byte = UInt8(pair, radix: 16) else { return nil }
hash.append(byte)
}
hashes.append(hash)
}
return hashes
}

struct ReadError: LocalizedError {
let path: String
let step: String
Expand Down
15 changes: 10 additions & 5 deletions Sources/Insomnia/main.swift
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import Foundation

// Entry point. A one-shot command-line mode is answered here, before AppKit
// or SwiftUI start: backstop.sh borrows the app's kernel-level identity
// check (see ResumeFrozenCommand), which ends itself when its lifetime is
// up, and scripts drive the running app's session (see SessionCommandLine).
// Anything else starts the menu bar app.
// Entry point. One-shot command-line modes are answered here, before AppKit
// or SwiftUI start: the recovery agent's backstop mode execs the verified
// script (see BackstopCommand), backstop.sh borrows the app's kernel-level
// identity check (see ResumeFrozenCommand), which ends itself when its
// lifetime is up, and scripts drive the running app's session (see
// SessionCommandLine). Anything else starts the menu bar app.
if let status = BackstopCommand.run(Array(CommandLine.arguments.dropFirst())) {
exit(status)
}

if let output = ResumeFrozenCommand.run(Array(CommandLine.arguments.dropFirst()), endAfter: ResumeFrozenCommand.endProcess) {
for line in output.lines { print(line) }
exit(output.status)
Expand Down
Loading
Loading