Skip to content
Open
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
18 changes: 18 additions & 0 deletions packages/kits/LocusKit/Sources/LocusKit/EstateTypes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
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]
Comment on lines +145 to +150

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enforce directory permissions after creation

When the estate-identity-keys directory 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; apply setAttributes to directory after creation as well.

Useful? React with 👍 / 👎.

)
// 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don't ignore backup-exclusion failures

If setting isExcludedFromBackup fails, this currently swallows the error and still writes the plaintext private key immediately afterward, so the store can silently violate the stated requirement that key material never enters Time Machine or other backups. Since storePrivateKey already throws, propagate this failure or avoid writing the key when the backup exclusion cannot be applied.

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
Loading