Skip to content

fix(locuskit): upstream FileEstateIdentityKeyStore (DEBUG-only, fixes ad-hoc-signing keychain hang)#22

Open
bob-codedaptive wants to merge 2 commits into
develop/1.1.xfrom
fix/file-estate-identity-keystore
Open

fix(locuskit): upstream FileEstateIdentityKeyStore (DEBUG-only, fixes ad-hoc-signing keychain hang)#22
bob-codedaptive wants to merge 2 commits into
develop/1.1.xfrom
fix/file-estate-identity-keystore

Conversation

@bob-codedaptive

Copy link
Copy Markdown
Member

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
KeychainEstateIdentityKeyStore trigger a blocking "allow keychain
access" 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-backed
EstateIdentityKeyStore conformer, entirely behind #if DEBUG. One raw
key 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 NOT
touched by this PR — durable backends still resolve to
KeychainEstateIdentityKeyStore by default. The new type is reachable
only by a caller that explicitly injects it, and the entire file
compiles out of any non-DEBUG build (verified via nm on the Debug vs.
Release .o — the symbol is present in Debug, absent in Release, not
just 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 products
consume, that's now a caller-supplied parameter:
init(appSupportSubdirectory: String) throws. The key filename is
already 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 String reopened a path-traversal surface the
hardcoded literal had made structurally unreachable:
appSupportSubdirectory is joined 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, which
matters more once this is a shared-kit surface with more than one
consumer. init now rejects empty, /-containing, and ./..
values at construction — before any FileManager call — throwing the
new EstateError.invalidIdentityKeyStoreSubdirectory(String) case
(additive; verified no exhaustive switch over EstateError exists
anywhere 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. EstateIdentityKeyStore and its two existing conformers
(KeychainEstateIdentityKeyStore, InMemoryEstateIdentityKeyStore) are
already Swift-only — there is no Rust EstateIdentityKeyStore seam to
conform 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 a
fresh store instance reading the same on-disk directory (the core
property this type exists for), unknown estate ID loads nil, two
estates don't collide, file/directory permissions are 0600/0700, a
second store overwrites the first, every unsafe subdirectory shape
("", "..", ".", "../../etc", "a/b", "/etc") throws
invalidIdentityKeyStoreSubdirectory with 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 nm rather than asserted at runtime (there's nothing to
run in a release build).

swift test: 834/834 passed (827 baseline + 7 new), exit 0.
swift build -c release: succeeds; symbol confirmed absent from the
Release .o.

Downstream

Fulcrum will delete its own copy
(FulcrumKit/Sources/MootEstate/FileEstateIdentityKeyStore.swift) and
repoint 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.

Newton and others added 2 commits July 15, 2026 12:59
…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
@github-actions

Copy link
Copy Markdown
Contributor

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.
You can retrigger this bot by commenting recheck in this Pull Request. Posted by the CLA Assistant Lite bot.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment on lines +145 to +150
try FileManager.default.createDirectory(
at: directory,
withIntermediateDirectories: true,
// 0700 on the directory: owner-only traversal, matching the
// 0600 files inside.
attributes: [.posixPermissions: 0o700]

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 👍 / 👎.

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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant