diff --git a/packages/kits/LocusKit/Sources/LocusKit/EstateTypes.swift b/packages/kits/LocusKit/Sources/LocusKit/EstateTypes.swift index 874c1e83..10eaf6aa 100644 --- a/packages/kits/LocusKit/Sources/LocusKit/EstateTypes.swift +++ b/packages/kits/LocusKit/Sources/LocusKit/EstateTypes.swift @@ -139,4 +139,22 @@ public enum EstateError: Error, Sendable, Equatable { /// `status` is the raw `OSStatus` value (Int32) from the failing call, /// readable as a human-readable string via `SecCopyErrorMessageString`. case keychainError(status: Int32) + + /// `FileEstateIdentityKeyStore.init(appSupportSubdirectory:)` (DEBUG + /// builds only) received a subdirectory name that is empty, contains a + /// path separator, or is a `.`/`..` traversal segment. The associated + /// string is the rejected value, verbatim, for caller diagnostics. + /// + /// Rejected at construction, before any `FileManager` call, because + /// the subdirectory is joined onto the Application Support root via + /// `URL.appendingPathComponent` — a value like `".."` or + /// `"../../Library"` resolves at the filesystem layer and would let a + /// caller-supplied string steer key-material reads/writes outside + /// Application Support entirely. The original fulcrum-local + /// implementation this type was upstreamed from made this + /// structurally unreachable by hardcoding the subdirectory literal; + /// generalizing it to a caller-supplied parameter for multi-consumer + /// use (see `FileEstateIdentityKeyStore`'s doc comment) reopened that + /// path unless validated here. + case invalidIdentityKeyStoreSubdirectory(String) } diff --git a/packages/kits/LocusKit/Sources/LocusKit/FileEstateIdentityKeyStore.swift b/packages/kits/LocusKit/Sources/LocusKit/FileEstateIdentityKeyStore.swift new file mode 100644 index 00000000..b6e86635 --- /dev/null +++ b/packages/kits/LocusKit/Sources/LocusKit/FileEstateIdentityKeyStore.swift @@ -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//estate-identity-keys/`. +public struct FileEstateIdentityKeyStore: EstateIdentityKeyStore { + + /// Directory holding one `.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) + let url = keyURL(forEstateID: estateID) + try keyData.write(to: url, options: [.atomic]) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: url.path + ) + } +} + +#endif diff --git a/packages/kits/LocusKit/Tests/LocusKitTests/FileEstateIdentityKeyStoreTests.swift b/packages/kits/LocusKit/Tests/LocusKitTests/FileEstateIdentityKeyStoreTests.swift new file mode 100644 index 00000000..e3b83f4c --- /dev/null +++ b/packages/kits/LocusKit/Tests/LocusKitTests/FileEstateIdentityKeyStoreTests.swift @@ -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