-
Notifications
You must be signed in to change notification settings - Fork 0
fix(locuskit): upstream FileEstateIdentityKeyStore (DEBUG-only, fixes ad-hoc-signing keychain hang) #22
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop/1.1.x
Are you sure you want to change the base?
fix(locuskit): upstream FileEstateIdentityKeyStore (DEBUG-only, fixes ad-hoc-signing keychain hang) #22
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,169 @@ | ||
| // FileEstateIdentityKeyStore.swift | ||
| // | ||
| // DEBUG-ONLY estate identity key store backed by plain files. | ||
| // | ||
| // WHY THIS EXISTS (2026-07-11, upstreamed from the fulcrum consumer — | ||
| // dev runs were hanging): development builds are re-signed on every | ||
| // rebuild (ad-hoc identity on hosts without a team certificate), and | ||
| // the legacy macOS keychain binds item ACLs to the code signature. | ||
| // Every rebuild therefore made `KeychainEstateIdentityKeyStore` trigger | ||
| // a blocking "allow keychain access" prompt during estate-host | ||
| // construction — a hang at app bootstrap on every single dev run, and | ||
| // every consumer app hosting a durable on-disk estate hit it. | ||
| // | ||
| // SECURITY BOUNDARY (Perkins posture, carried verbatim from the | ||
| // consumer-side review that ratified this bypass): the Keychain store | ||
| // remains the production default (`Estate.defaultIdentityKeyStore(for:)` | ||
| // is UNCHANGED by this file — it still resolves durable backends to | ||
| // `KeychainEstateIdentityKeyStore`), and the release binary CANNOT | ||
| // reach this type — the entire file is compiled out of non-DEBUG | ||
| // builds. This preserves the ratified "production must never be | ||
| // wireable to a non-Keychain key store" posture while making DEBUG | ||
| // runs promptless. Key material in DEBUG lands in a 0600 file under | ||
| // Application Support; acceptable for development machines, never for | ||
| // release (which never compiles this path). | ||
| // | ||
| // Unlike `InMemoryEstateIdentityKeyStore` (whose keys vanish per-process | ||
| // and would re-mint the estate's federation identity on every launch), | ||
| // this store is durable across runs — the dev estate keeps ONE stable | ||
| // identity, matching the Keychain store's observable behavior minus the | ||
| // prompts. | ||
| // | ||
| // SWIFT/RUST PARITY RULING: this type intentionally has NO Rust | ||
| // counterpart. It is a host-side developer-experience workaround for a | ||
| // macOS/iOS code-signing artifact (ad-hoc re-signing invalidating | ||
| // Keychain ACLs), not a substrate algorithm or on-disk format — nothing | ||
| // a Rust-side consumer of LocusKit's data files would ever need to read | ||
| // or produce. The four-way conformance gate (Swift scalar, Swift Metal, | ||
| // Rust scalar, Rust BLAS/NEON) applies to algorithms operating on | ||
| // bitmap/vector data; it does not apply to key-store plumbing, and | ||
| // `EstateIdentityKeyStore` itself (the protocol this type conforms to) | ||
| // is a Swift-only seam — `KeychainEstateIdentityKeyStore` and | ||
| // `InMemoryEstateIdentityKeyStore` are likewise Swift-only. Parity is | ||
| // therefore N/A by construction, not waived. | ||
| // | ||
| // CALLER RESPONSIBILITY: this type is not wired into | ||
| // `Estate.defaultIdentityKeyStore(for:)` — that resolver's Keychain | ||
| // default for durable backends is untouched by this file. A DEBUG-only | ||
| // consumer that wants the promptless dev path must inject it | ||
| // explicitly: | ||
| // | ||
| // #if DEBUG | ||
| // let identityKeyStore = try FileEstateIdentityKeyStore(appSupportSubdirectory: "Fulcrum") | ||
| // #else | ||
| // let identityKeyStore: (any EstateIdentityKeyStore)? = nil // Keychain default | ||
| // #endif | ||
| // let estate = try await Estate.open(storage: storage, owner: owner, | ||
| // identityKeyStore: identityKeyStore) | ||
| // | ||
| // ADAPTATION NOTE (upstreaming deviation from the fulcrum original): the | ||
| // consumer-side type hardcoded its Application Support subdirectory to | ||
| // the literal "Fulcrum". Landing in a shared kit consumed by more than | ||
| // one product, that literal is parameterized here | ||
| // (`appSupportSubdirectory`) so a second consumer app doesn't inherit a | ||
| // misleading path. This does not change the Perkins-reviewed security | ||
| // posture (DEBUG-only gate, 0600 file, 0700 directory, backup-excluded, | ||
| // per-estate-UUID filename) — only which folder name the caller chooses. | ||
| // | ||
| // PATH-TRAVERSAL FINDING (Perkins, PR review, fixed same PR): the | ||
| // fulcrum original's hardcoded "Fulcrum" literal made the subdirectory | ||
| // structurally unreachable to a caller — generalizing it to a | ||
| // caller-supplied `String` reopened a path-traversal surface, because | ||
| // `appSupportSubdirectory` is joined onto the Application Support root | ||
| // with `URL.appendingPathComponent`, which does not sanitize `..` | ||
| // segments (they resolve at the filesystem layer, not the URL layer). A | ||
| // value like `"../../Library/LaunchAgents"` would write key material | ||
| // outside Application Support entirely — in a shared-kit surface now | ||
| // consumed by 4+ apps, that's a real arbitrary-path-write risk in DEBUG | ||
| // builds, not a theoretical one. `init(appSupportSubdirectory:)` | ||
| // rejects empty, `/`-containing, `.`, and `..` values at construction | ||
| // (throws `EstateError.invalidIdentityKeyStoreSubdirectory`) before any | ||
| // `FileManager` call — see the initializer's own doc comment. | ||
|
|
||
| #if DEBUG | ||
|
|
||
| import Foundation | ||
|
|
||
| /// File-backed `EstateIdentityKeyStore` for DEBUG builds: one raw key file | ||
| /// per estate UUID, 0600 permissions, under | ||
| /// `Application Support/<AppSupportSubdirectory>/estate-identity-keys/`. | ||
| public struct FileEstateIdentityKeyStore: EstateIdentityKeyStore { | ||
|
|
||
| /// Directory holding one `<estate-uuid>.key` file per estate. | ||
| private let directory: URL | ||
|
|
||
| /// Creates the store rooted at the default Application Support | ||
| /// location. The directory is created lazily on first write. | ||
| /// | ||
| /// - Parameter appSupportSubdirectory: the consumer-owned folder name | ||
| /// under Application Support (e.g. an app's product name). Callers | ||
| /// from different apps sharing this DEBUG path on the same machine | ||
| /// must pass distinct values to avoid colliding on the same | ||
| /// `estate-identity-keys/` directory. | ||
| /// - Throws: `EstateError.invalidIdentityKeyStoreSubdirectory(_:)` if | ||
| /// `appSupportSubdirectory` is empty, contains a path separator | ||
| /// (`/`), or is a `.`/`..` traversal segment. This value is joined | ||
| /// directly onto the Application Support root via | ||
| /// `URL.appendingPathComponent`, which does not sanitize `..` | ||
| /// segments — the filesystem resolves them — so an unvalidated | ||
| /// caller string could steer key-material reads/writes outside | ||
| /// Application Support entirely (Perkins finding, upstream PR | ||
| /// review). Validated here, at construction, before any | ||
| /// `FileManager` call, rather than deferred to `storePrivateKey`/ | ||
| /// `loadPrivateKey`, so a bad value fails fast and can never | ||
| /// partially write. | ||
| public init(appSupportSubdirectory: String) throws { | ||
| guard !appSupportSubdirectory.isEmpty, | ||
| !appSupportSubdirectory.contains("/"), | ||
| appSupportSubdirectory != ".", | ||
| appSupportSubdirectory != ".." | ||
| else { | ||
| throw EstateError.invalidIdentityKeyStoreSubdirectory(appSupportSubdirectory) | ||
| } | ||
| let base = try FileManager.default.url( | ||
| for: .applicationSupportDirectory, | ||
| in: .userDomainMask, | ||
| appropriateFor: nil, | ||
| create: true | ||
| ) | ||
| self.directory = base | ||
| .appendingPathComponent(appSupportSubdirectory, isDirectory: true) | ||
| .appendingPathComponent("estate-identity-keys", isDirectory: true) | ||
| } | ||
|
|
||
| private func keyURL(forEstateID estateID: UUID) -> URL { | ||
| directory.appendingPathComponent("\(estateID.uuidString).key") | ||
| } | ||
|
|
||
| public func loadPrivateKey(forEstateID estateID: UUID) throws -> Data? { | ||
| let url = keyURL(forEstateID: estateID) | ||
| guard FileManager.default.fileExists(atPath: url.path) else { return nil } | ||
| return try Data(contentsOf: url) | ||
| } | ||
|
|
||
| public func storePrivateKey(_ keyData: Data, forEstateID estateID: UUID) throws { | ||
| try FileManager.default.createDirectory( | ||
| at: directory, | ||
| withIntermediateDirectories: true, | ||
| // 0700 on the directory: owner-only traversal, matching the | ||
| // 0600 files inside. | ||
| attributes: [.posixPermissions: 0o700] | ||
| ) | ||
| // Backup exclusion (Perkins hardening condition 1, carried forward | ||
| // from the consumer-side review): key material must never ride | ||
| // into Time Machine / backup tools — unlike Keychain items, plain | ||
| // files carry no backup encryption of their own. | ||
| var backupExcluded = directory | ||
| var values = URLResourceValues() | ||
| values.isExcludedFromBackup = true | ||
| try? backupExcluded.setResourceValues(values) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If setting Useful? React with 👍 / 👎. |
||
| let url = keyURL(forEstateID: estateID) | ||
| try keyData.write(to: url, options: [.atomic]) | ||
| try FileManager.default.setAttributes( | ||
| [.posixPermissions: 0o600], | ||
| ofItemAtPath: url.path | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| #endif | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,206 @@ | ||
| // FileEstateIdentityKeyStoreTests.swift | ||
| // | ||
| // Tests for `FileEstateIdentityKeyStore` (DEBUG-only; see the doc | ||
| // comment on the type for the Perkins posture and the ad-hoc-signing | ||
| // keychain-hang story this exists to fix). | ||
| // | ||
| // The whole file is gated `#if DEBUG` because the type under test does | ||
| // not exist outside DEBUG builds — there is nothing to compile, let | ||
| // alone run, in a release configuration. `swift test` builds Debug by | ||
| // default (SwiftPM convention), so this suite runs on every normal | ||
| // `swift test` invocation; a release-configuration build never sees | ||
| // this file's contents at all, which is itself the compile-time proof | ||
| // of release-unreachability (see the type's own doc comment) — that | ||
| // property is a compile-config assertion, not something a runtime test | ||
| // can additionally demonstrate, so no test attempts to. | ||
| #if DEBUG | ||
|
|
||
| import Foundation | ||
| import Testing | ||
| @testable import LocusKit | ||
|
|
||
| @Suite("FileEstateIdentityKeyStoreTests") | ||
| struct FileEstateIdentityKeyStoreTests { | ||
|
|
||
| // Distinct from any real product's Application Support subdirectory | ||
| // so this suite can never collide with a developer's actual dev-run | ||
| // key files, and cleans up after itself in each test. | ||
| private static let testSubdirectory = "LocusKitTests-FileEstateIdentityKeyStore" | ||
|
|
||
| private func makeStore() throws -> FileEstateIdentityKeyStore { | ||
| try FileEstateIdentityKeyStore(appSupportSubdirectory: Self.testSubdirectory) | ||
| } | ||
|
|
||
| private func keyFileURL(forEstateID estateID: UUID) throws -> URL { | ||
| let base = try FileManager.default.url( | ||
| for: .applicationSupportDirectory, | ||
| in: .userDomainMask, | ||
| appropriateFor: nil, | ||
| create: true | ||
| ) | ||
| return base | ||
| .appendingPathComponent(Self.testSubdirectory, isDirectory: true) | ||
| .appendingPathComponent("estate-identity-keys", isDirectory: true) | ||
| .appendingPathComponent("\(estateID.uuidString).key") | ||
| } | ||
|
|
||
| // MARK: - 1. Identity persists across reopen from file | ||
|
|
||
| /// Storing a key then constructing a FRESH `FileEstateIdentityKeyStore` | ||
| /// (simulating a process relaunch reading the same on-disk directory) | ||
| /// must load back the identical bytes — this is the whole point of the | ||
| /// type: a stable dev-run identity across rebuilds, unlike | ||
| /// `InMemoryEstateIdentityKeyStore` which re-mints per process. | ||
| @Test("private key persists across a fresh store instance reading the same directory") | ||
| func identityPersistsAcrossReopenFromFile() throws { | ||
| let estateID = UUID() | ||
| let keyURL = try keyFileURL(forEstateID: estateID) | ||
| defer { try? FileManager.default.removeItem(at: keyURL) } | ||
|
|
||
| let original = Data((0..<32).map { UInt8($0) }) // 32-byte stand-in key | ||
| let writer = try makeStore() | ||
| try writer.storePrivateKey(original, forEstateID: estateID) | ||
|
|
||
| // Fresh instance — no shared in-memory state with `writer`. Only | ||
| // the file on disk connects the two. | ||
| let reader = try makeStore() | ||
| let reloaded = try reader.loadPrivateKey(forEstateID: estateID) | ||
|
|
||
| #expect(reloaded == original) | ||
| } | ||
|
|
||
| // MARK: - 2. Unknown estate ID returns nil, not an error | ||
|
|
||
| /// A fresh estate UUID with no prior `storePrivateKey` call must load | ||
| /// as `nil`, matching `EstateIdentityKeyStore`'s documented contract | ||
| /// (nil means "no key stored yet", not a thrown error) — the same | ||
| /// contract `KeychainEstateIdentityKeyStore` honors for | ||
| /// `errSecItemNotFound`. | ||
| @Test("loadPrivateKey returns nil for an estate ID with no stored key") | ||
| func loadReturnsNilForUnknownEstateID() throws { | ||
| let store = try makeStore() | ||
| let neverStored = UUID() | ||
| let result = try store.loadPrivateKey(forEstateID: neverStored) | ||
| #expect(result == nil) | ||
| } | ||
|
|
||
| // MARK: - 3. Independent estates do not collide | ||
|
|
||
| /// Two distinct estate UUIDs must resolve to two distinct key files | ||
| /// and never overwrite one another's key material — the filename is | ||
| /// keyed by estate UUID specifically so this holds. | ||
| @Test("two distinct estate IDs store and load independent key material") | ||
| func distinctEstatesDoNotCollide() throws { | ||
| let estateA = UUID() | ||
| let estateB = UUID() | ||
| let urlA = try keyFileURL(forEstateID: estateA) | ||
| let urlB = try keyFileURL(forEstateID: estateB) | ||
| defer { | ||
| try? FileManager.default.removeItem(at: urlA) | ||
| try? FileManager.default.removeItem(at: urlB) | ||
| } | ||
|
|
||
| let keyA = Data((0..<32).map { UInt8($0) }) | ||
| let keyB = Data((0..<32).map { UInt8(255 - $0) }) | ||
| let store = try makeStore() | ||
| try store.storePrivateKey(keyA, forEstateID: estateA) | ||
| try store.storePrivateKey(keyB, forEstateID: estateB) | ||
|
|
||
| #expect(try store.loadPrivateKey(forEstateID: estateA) == keyA) | ||
| #expect(try store.loadPrivateKey(forEstateID: estateB) == keyB) | ||
| } | ||
|
|
||
| // MARK: - 4. File and directory permissions | ||
|
|
||
| /// The key file is written 0600 (owner read/write only) and the | ||
| /// containing directory 0700 (owner traversal only) — the Perkins | ||
| /// posture this type exists under depends on both, since a | ||
| /// world-readable directory or file would defeat the "developer-local, | ||
| /// low-value" threat-model reasoning the security review relied on. | ||
| @Test("stored key file is 0600 and its directory is 0700") | ||
| func filePermissionsAreOwnerOnly() throws { | ||
| let estateID = UUID() | ||
| let keyURL = try keyFileURL(forEstateID: estateID) | ||
| defer { try? FileManager.default.removeItem(at: keyURL) } | ||
|
|
||
| let store = try makeStore() | ||
| try store.storePrivateKey(Data([0x01]), forEstateID: estateID) | ||
|
|
||
| let fileAttrs = try FileManager.default.attributesOfItem(atPath: keyURL.path) | ||
| let filePerms = (fileAttrs[.posixPermissions] as? NSNumber)?.uint16Value | ||
| #expect(filePerms == 0o600) | ||
|
|
||
| let dirAttrs = try FileManager.default.attributesOfItem( | ||
| atPath: keyURL.deletingLastPathComponent().path | ||
| ) | ||
| let dirPerms = (dirAttrs[.posixPermissions] as? NSNumber)?.uint16Value | ||
| #expect(dirPerms == 0o700) | ||
| } | ||
|
|
||
| // MARK: - 5. Overwrite replaces rather than appends | ||
|
|
||
| /// A second `storePrivateKey` call for the same estate UUID overwrites | ||
| /// the previous value in place — matching the protocol's documented | ||
| /// contract ("a second call for the same UUID overwrites the previous | ||
| /// value") and `KeychainEstateIdentityKeyStore`'s update-in-place | ||
| /// behavior on `errSecDuplicateItem`. | ||
| @Test("storing a second key for the same estate ID overwrites the first") | ||
| func secondStoreOverwritesFirst() throws { | ||
| let estateID = UUID() | ||
| let keyURL = try keyFileURL(forEstateID: estateID) | ||
| defer { try? FileManager.default.removeItem(at: keyURL) } | ||
|
|
||
| let store = try makeStore() | ||
| try store.storePrivateKey(Data([0x01, 0x02]), forEstateID: estateID) | ||
| try store.storePrivateKey(Data([0x03, 0x04, 0x05]), forEstateID: estateID) | ||
|
|
||
| let reloaded = try store.loadPrivateKey(forEstateID: estateID) | ||
| #expect(reloaded == Data([0x03, 0x04, 0x05])) | ||
| } | ||
|
|
||
| // MARK: - 6. Subdirectory validation (Perkins path-traversal finding) | ||
|
|
||
| /// `appSupportSubdirectory` is joined directly onto the Application | ||
| /// Support root via `URL.appendingPathComponent`, which does not | ||
| /// sanitize `..` segments — the filesystem resolves them. Every shape | ||
| /// that could steer key-material reads/writes outside Application | ||
| /// Support must be rejected at construction, before any | ||
| /// `FileManager` call. | ||
| @Test( | ||
| "init throws invalidIdentityKeyStoreSubdirectory for each unsafe subdirectory shape", | ||
| arguments: [ | ||
| "", // empty | ||
| "..", // parent-directory traversal | ||
| ".", // current-directory no-op, still not a real name | ||
| "../../etc", // multi-segment traversal | ||
| "a/b", // embedded path separator — appendingPathComponent | ||
| // would create an extra directory level, not | ||
| // traverse, but still isn't a single component | ||
| "/etc", // leading separator | ||
| ] | ||
| ) | ||
| func rejectsUnsafeSubdirectoryShapes(_ unsafeValue: String) throws { | ||
| do { | ||
| _ = try FileEstateIdentityKeyStore(appSupportSubdirectory: unsafeValue) | ||
| Issue.record("expected invalidIdentityKeyStoreSubdirectory for \(unsafeValue.debugDescription)") | ||
| } catch let EstateError.invalidIdentityKeyStoreSubdirectory(rejected) { | ||
| #expect(rejected == unsafeValue) | ||
| } | ||
| } | ||
|
|
||
| /// A clean, single-component subdirectory name (no separators, not a | ||
| /// `.`/`..` traversal segment) must still construct successfully — | ||
| /// the guard must reject only the unsafe shapes, not everything. | ||
| @Test("init succeeds for a clean single-component subdirectory name") | ||
| func acceptsCleanSubdirectoryName() throws { | ||
| let store = try FileEstateIdentityKeyStore(appSupportSubdirectory: Self.testSubdirectory) | ||
| let estateID = UUID() | ||
| let keyURL = try keyFileURL(forEstateID: estateID) | ||
| defer { try? FileManager.default.removeItem(at: keyURL) } | ||
|
|
||
| try store.storePrivateKey(Data([0xAA]), forEstateID: estateID) | ||
| #expect(try store.loadPrivateKey(forEstateID: estateID) == Data([0xAA])) | ||
| } | ||
| } | ||
|
|
||
| #endif |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the
estate-identity-keysdirectory already exists, such as from a prior dev run or a pre-created Application Support folder,createDirectory(...attributes:)does not reset the mode on that existing directory, so a 0777/0755 directory can remain writable or listable after this method succeeds. That breaks the 0700 contract documented here and can let another local user on a shared dev machine tamper with or enumerate the DEBUG key files; applysetAttributestodirectoryafter creation as well.Useful? React with 👍 / 👎.