fix(locuskit): upstream FileEstateIdentityKeyStore (DEBUG-only, fixes ad-hoc-signing keychain hang)#22
Conversation
…s ad-hoc-signing keychain hang) Fulcrum's dev builds are re-signed on every rebuild (ad-hoc identity on hosts without a team certificate). The legacy macOS keychain binds item ACLs to the code signature, so every rebuild made KeychainEstateIdentityKeyStore trigger a blocking "allow keychain access" prompt during estate-host construction — a hang at app bootstrap on every single dev run, hit by every consumer of a durable on-disk estate, not just Fulcrum. Moves FileEstateIdentityKeyStore from the fulcrum consumer into LocusKit beside EstateIdentityKeyStore.swift, so any moot consumer carrying the same dev-signing problem can reach the fix without duplicating it. Entire type is behind #if DEBUG (verified: present in the Debug .o, absent from the Release .o via nm) — the production default (Estate.defaultIdentityKeyStore(for:) → KeychainEstateIdentityKeyStore for durable backends) is untouched by this file; production is not wireable to this type. Doc comments carry the Perkins-reviewed security posture verbatim (0600 key file, 0700 directory, backup-excluded, per-estate-UUID filename, dev-machine-local low-value threat model). One adaptation from the fulcrum original: the Application Support subdirectory is now a caller-supplied parameter (appSupportSubdirectory:) instead of a hardcoded "Fulcrum" literal, since this type now lives in a kit multiple products can consume. Parity ruling: no Rust counterpart, and none needed. This is a host-side developer-experience workaround for a macOS/iOS code-signing artifact, not a substrate algorithm or on-disk data format — the EstateIdentityKeyStore protocol itself, and both its existing conformers (Keychain, InMemory), are Swift-only. The four-way conformance gate applies to bitmap/vector algorithms; it does not apply here. Tests (5, DEBUG-gated, Swift Testing): identity persists across a fresh store instance reading the same directory (the "reopen from file" property this type exists for), unknown estate ID loads nil, two estates don't collide, file/directory permissions are 0600/0700, second store overwrites first. Release-unreachability is a compile-config property (confirmed via nm on the .o), not something a runtime test can additionally assert, so no test attempts to. swift test: 832/832 passed (827 baseline + 5 new), exit 0. swift build -c release: succeeds, symbol confirmed absent via nm. Fulcrum-side follow-up (M-R3.3 Parts 2-3, gated on merge + a future pin advance per D23): delete FulcrumKit/Sources/MootEstate/ FileEstateIdentityKeyStore.swift and repoint GLKEstateSession.swift's explicit injection at LocusKit's copy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hr1uCLfesRfCHxTwyYgBiu
…values (Perkins CHANGES-REQUESTED) init(appSupportSubdirectory:) joined the caller-supplied string directly onto the Application Support root via URL.appendingPathComponent, which does not sanitize ".." segments — they resolve at the filesystem layer. An unvalidated value (e.g. "../../Library/LaunchAgents") could steer DEBUG-only key-material reads/writes outside Application Support entirely. The fulcrum original 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 (this PR's own prior commit) reopened the path unless validated. Adds EstateError.invalidIdentityKeyStoreSubdirectory(String), thrown at construction (before any FileManager call) when the subdirectory is empty, contains "/", or is a "."/".." traversal segment. Additive enum case only — grepped EstateError across the whole worktree, every existing use is single-case `catch let EstateError.foo(...)` pattern matching, no exhaustive switch to update. Tests: 2 new (one parameterized over 6 unsafe shapes: "", "..", ".", "../../etc", "a/b", "/etc" — each must throw invalidIdentityKeyStoreSubdirectory with the rejected value attached; one confirming a clean single-component name still constructs and round-trips a key normally). swift test: 834/834 passed (827 original baseline + 7 new across both commits), exit 0. swift build -c release: still succeeds, DEBUG gate unaffected by this change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hr1uCLfesRfCHxTwyYgBiu
|
Thank you for your contribution to MOOTx01. Before it can be merged we need you to sign our Contributor License Agreement. You can read it here: CLA.md. To sign, post a comment on this pull request with exactly the following text: I have read the CLA Document and I hereby sign the CLA Newton seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 17f0788ff9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| try FileManager.default.createDirectory( | ||
| at: directory, | ||
| withIntermediateDirectories: true, | ||
| // 0700 on the directory: owner-only traversal, matching the | ||
| // 0600 files inside. | ||
| attributes: [.posixPermissions: 0o700] |
There was a problem hiding this comment.
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 👍 / 👎.
| var backupExcluded = directory | ||
| var values = URLResourceValues() | ||
| values.isExcludedFromBackup = true | ||
| try? backupExcluded.setResourceValues(values) |
There was a problem hiding this comment.
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 👍 / 👎.
Motivation
Development builds get re-signed on every rebuild (ad-hoc identity, on
hosts without a team certificate). The legacy macOS keychain binds item
ACLs to the code signature, so every rebuild made
KeychainEstateIdentityKeyStoretrigger a blocking "allow keychainaccess" prompt during estate-host construction — a hang at app
bootstrap on every single dev run. This was hit by every consumer
app hosting a durable on-disk estate, not just the one that first
diagnosed and fixed it (Fulcrum, 2026-07-11) — hence upstreaming it
here instead of leaving it as a fulcrum-local workaround.
What this adds
FileEstateIdentityKeyStore.swift— a file-backedEstateIdentityKeyStoreconformer, entirely behind#if DEBUG. One rawkey file per estate UUID, 0600 permissions, under
Application Support/<caller-chosen subdirectory>/estate-identity-keys/,directory 0700, backup-excluded. Unlike
InMemoryEstateIdentityKeyStore(keys vanish per-process, re-minting the estate's federation identity on
every launch), this store is durable across dev runs — one stable
identity, matching Keychain's observable behavior minus the prompts.
Security boundary (unchanged, verbatim from the originating
security review):
Estate.defaultIdentityKeyStore(for:)is NOTtouched by this PR — durable backends still resolve to
KeychainEstateIdentityKeyStoreby default. The new type is reachableonly by a caller that explicitly injects it, and the entire file
compiles out of any non-DEBUG build (verified via
nmon the Debug vs.Release
.o— the symbol is present in Debug, absent in Release, notjust source-inspected). Production is not wireable to this type by this
change.
Deviation from the original (fulcrum-local) implementation
The original hardcoded its Application Support subdirectory to the
literal
"Fulcrum". Since this now lives in a kit multiple productsconsume, that's now a caller-supplied parameter:
init(appSupportSubdirectory: String) throws. The key filename isalready keyed by estate UUID, so this isn't a collision fix — it's
removing a single product's name from shared-kit library code. The
security posture (permissions, backup exclusion, DEBUG-only gate) is
unchanged.
Validation on the new parameter. Generalizing the subdirectory to a
caller-supplied
Stringreopened a path-traversal surface thehardcoded literal had made structurally unreachable:
appSupportSubdirectoryis joined onto the Application Support rootvia
URL.appendingPathComponent, which does not sanitize..segments — they resolve at the filesystem layer. An unvalidated value
(e.g.
"../../Library/LaunchAgents") could steer DEBUG-onlykey-material reads/writes outside Application Support entirely, which
matters more once this is a shared-kit surface with more than one
consumer.
initnow rejects empty,/-containing, and./..values at construction — before any
FileManagercall — throwing thenew
EstateError.invalidIdentityKeyStoreSubdirectory(String)case(additive; verified no exhaustive
switchoverEstateErrorexistsanywhere in the repo that a new case could break).
Rust parity: not applicable, by design
This is a host-side developer-experience workaround for a macOS/iOS
code-signing artifact, not a substrate algorithm or on-disk data
format.
EstateIdentityKeyStoreand its two existing conformers(
KeychainEstateIdentityKeyStore,InMemoryEstateIdentityKeyStore) arealready Swift-only — there is no Rust
EstateIdentityKeyStoreseam toconform to. The four-way conformance gate governs bitmap/vector
algorithms; it doesn't apply to key-store plumbing.
Tests
7 new DEBUG-gated Swift Testing tests in
FileEstateIdentityKeyStoreTests.swift: identity persists across afresh store instance reading the same on-disk directory (the core
property this type exists for), unknown estate ID loads
nil, twoestates don't collide, file/directory permissions are 0600/0700, a
second store overwrites the first, every unsafe subdirectory shape
(
"","..",".","../../etc","a/b","/etc") throwsinvalidIdentityKeyStoreSubdirectorywith the rejected value attached,and a clean single-component name still constructs and round-trips a
key normally. Release-unreachability is a compile-config property,
confirmed via
nmrather than asserted at runtime (there's nothing torun in a release build).
swift test: 834/834 passed (827 baseline + 7 new), exit 0.swift build -c release: succeeds; symbol confirmed absent from theRelease
.o.Downstream
Fulcrum will delete its own copy
(
FulcrumKit/Sources/MootEstate/FileEstateIdentityKeyStore.swift) andrepoint its explicit injection at this type on its next pin advance
past this PR's merge commit, per its D23 dependency doctrine
(exact-SHA pinning, advanced deliberately). Tracked as M-R3.3 Parts 2-3
in fulcrum's own mission log — not part of this PR.
Note for maintainers: I understand contributions require a signed CLA
per CONTRIBUTING.md/CLA.md before merge; flagging here since I'm not
able to complete that step from this automated session.