iOS port M2: iOS app skeleton — onboarding, Citadel wrapper, Keychain, Dashboard

First iOS phase. Delivers all the code needed to build + TestFlight a
functional v1 iOS app (onboarding with SSH-key generate / import +
real Citadel-backed connection test; persistent Keychain key +
UserDefaults server config; placeholder Dashboard) — but NOT the
scarf-ios.xcodeproj. Creating that from scratch by hand is too risky
without an iOS SDK to build against, so Alan creates it in Xcode's UI
following scarf/scarf-ios/SETUP.md (~5 minutes, one-time).

## ScarfCore additions (all Linux-testable)

Packages/ScarfCore/Sources/ScarfCore/Security/:
  - SSHKey.swift         — SSHKeyBundle + SSHKeyStore protocol
                            + InMemorySSHKeyStore test actor
  - IOSServerConfig.swift — IOSServerConfig + store protocol + mock;
                            toServerContext(id:) bridges to the
                            existing ServerContext so all ScarfCore
                            services work against an iOS config
  - OnboardingState.swift — OnboardingStep enum + pure validators
                            (host, port, PEM shape, public-key parse)
  - SSHConnectionTester.swift — protocol + error enum + mock
  - OnboardingViewModel.swift — @Observable @MainActor state machine,
                            fully dependency-injected (key store /
                            config store / tester / generator closure)

## New Packages/ScarfIOS local SPM package

Depends on ScarfCore + Citadel (from: "0.7.0").

  - KeychainSSHKeyStore.swift    — real iOS Keychain storage
    (kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, no iCloud
     sync). Gated on canImport(Security) for Linux skip.
  - UserDefaultsIOSServerConfigStore.swift — JSON-encoded single-key
    persistence of IOSServerConfig.
  - Ed25519KeyGenerator.swift    — CryptoKit-backed Ed25519 minting.
    Emits standard OpenSSH public-key lines (authorized_keys-ready).
    Stores the private half in a compact SCARF ED25519 PRIVATE KEY
    PEM shape that CitadelSSHService decodes back into a
    Curve25519.Signing.PrivateKey. Non-interop with OpenSSH's
    `BEGIN OPENSSH PRIVATE KEY` envelope — export flow for sharing
    keys is deferred to a later phase.
  - CitadelSSHService.swift      — SSHConnectionTester conformance +
    key-generation wrapper. Runs `echo scarf-ok` over a one-shot
    Citadel exec for the onboarding connection test. One FIXME on
    buildClientSettings because Citadel 0.7→0.9 shifted the
    `.ed25519(...)` authentication-method variant name; every other
    line is Citadel-version-independent. Gated on
    canImport(Citadel) && canImport(CryptoKit).

## scarf/scarf-ios/ app source tree

  - App/ScarfIOSApp.swift         — @main, RootModel routes to
                                    onboarding or dashboard based on
                                    stored state.
  - Onboarding/OnboardingRootView.swift — 8 sub-views, one per
                                    OnboardingStep. Validated
                                    server-details form, key-source
                                    picker, generate / show-public
                                    / import / test / retry /
                                    connected.
  - Dashboard/DashboardView.swift — M2 placeholder: connected host
                                    details + Disconnect button.
                                    M3 replaces with real data.

## scarf/scarf-ios/SETUP.md

Step-by-step Xcode project creation:
  - iOS 18 / iPhone-only / team 3Q6X2L86C4 / Bundle ID
    com.scarf.scarf-ios / Swift 5 language mode.
  - Wire Packages/ScarfCore + Packages/ScarfIOS (Citadel resolves
    transitively).
  - Replace Xcode's default scaffolded files with this source tree.
  - Smoke-test procedure (simulator → physical iPhone).
  - TestFlight upload steps.
  - Troubleshooting for the known Citadel-variant-name drift.

## Test coverage (Linux, `swift test`)

M2OnboardingTests, 26 new tests (ScarfCore):
  - SSHKeyBundle memberwise + display fingerprint
  - InMemorySSHKeyStore + InMemoryIOSServerConfigStore round-trips
  - IOSServerConfig.toServerContext bridging (with + without
    remoteHome override)
  - All OnboardingLogic validators (empty / whitespace / port range /
    legacy-RSA rejection / public-key line parser)
  - MockSSHConnectionTester scripting (success + failure)
  - 10 OnboardingViewModel end-to-end paths: happy-path
    save-and-test, invalid-host blocks advance, connection-failure
    routes to .testFailed (and crucially does NOT save config),
    retry-after-failure-works, import-happy, import-rejects-bad-PEM,
    reset clears all state

ScarfIOSSmokeTests, 3 tests (Apple-only, won't run on Linux):
  - Ed25519KeyGenerator bundle shape + base64 wire format
  - OpenSSH public-key line byte-length pinned at 51 bytes
  - Corrupted PEM rejection on round-trip decode

Running
  docker run --rm -v $PWD/scarf/Packages/ScarfCore:/work -w /work swift:6.0 swift test
reports **88 / 88 passing** (62 pre-M2 + 26 new).

## Real bug caught in development

First pass of OnboardingViewModel had `confirmPublicKeyAdded()` set
`isWorking=true`, then call `runConnectionTest()` which bailed on
`!isWorking` — meaning the connection probe never ran and the config
was never saved. Caught by the end-to-end test. Fixed by extracting
the shared probe body into `performConnectionTest()` and letting
both entry points own their own `isWorking` transition.

## Manual validation still needed on Mac

1. Xcode project creation per SETUP.md — confirm the resulting
   project builds cleanly.
2. Citadel 0.9.x authentication-method variant — verify the one
   FIXME line in buildClientSettings.
3. End-to-end onboarding: simulator against `localhost:22` (or a
   test host), then TestFlight → physical iPhone → real SSH host
   with the shown public key in authorized_keys.

Updated scarf/docs/IOS_PORT_PLAN.md with M2's shipped scope, the
scope decision about NOT generating the xcodeproj, and the list of
rules M3+ can rely on (Citadel transport dispatch, ChannelFactory
hook, single-server invariant).

https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
This commit is contained in:
Claude
2026-04-22 23:09:21 +00:00
parent bdf31d6781
commit ba368d2f6d
17 changed files with 2354 additions and 2 deletions
+100 -2
View File
@@ -454,8 +454,106 @@ Two real regressions caught by a pre-M1 audit, both silent:
- **The `ChannelFactory` closure is `@Sendable` and async.** Any per-context setup (env enrichment, SSH handshake) happens inside the factory — not inside `ACPClient.start()`. That keeps `start()` boring and portable.
- **`ACPClient` does not handle subprocess spontaneous exits via `terminationHandler`** anymore — it notices via channel-stream EOF. Pipe-EOF fires reliably when a Mac subprocess exits (OS closes the pipe). If a future phase sees "session hangs after crash" symptoms, add a `terminationHandler` inside `ProcessACPChannel` that explicitly finishes the `incoming` continuation.
### M2 — pending
### M2 — pending
### M2 — shipped (on `claude/ios-m2-skeleton` branch, separate PR from M0+M1)
**Scope note:** M2 delivers all the **code** needed for TestFlight
(onboarding, Keychain, Citadel, Dashboard placeholder, unit tests) but
**not** the `scarf-ios.xcodeproj`. Hand-editing ~600 lines of pbxproj
from scratch is too high-risk without an iOS SDK to build against, so
the Xcode target is created once in Xcode's UI following the written
instructions in `scarf/scarf-ios/SETUP.md`. Total setup time: ~5
minutes.
**Shipped — ScarfCore additions (testable on Linux):**
- `Security/SSHKey.swift``SSHKeyBundle` struct, `SSHKeyStore`
protocol, `InMemorySSHKeyStore` test actor.
- `Security/IOSServerConfig.swift``IOSServerConfig` struct
(single-server v1), `IOSServerConfigStore` protocol,
`InMemoryIOSServerConfigStore`. `toServerContext(id:)` bridges to
the existing `ServerContext` type so the rest of ScarfCore's
services work against an iOS-configured server unchanged.
- `Security/OnboardingState.swift``OnboardingStep` enum,
`OnboardingKeyChoice`, `OnboardingServerDetailsValidation`, pure
functions `OnboardingLogic.validateServerDetails` /
`authorizedKeysLine(for:)` / `isLikelyValidOpenSSHPrivateKey` /
`parseOpenSSHPublicKeyLine`.
- `Security/SSHConnectionTester.swift` — protocol +
`SSHConnectionTestError` enum + `MockSSHConnectionTester`.
- `Security/OnboardingViewModel.swift``@Observable @MainActor`
state machine. Dependency-injects `SSHKeyStore`,
`IOSServerConfigStore`, `SSHConnectionTester`, and a `KeyGenerator`
closure so every transition is testable with mocks.
**Shipped — new `Packages/ScarfIOS` local SPM package:**
- Depends on local ScarfCore + remote Citadel (`from: "0.7.0"`).
- `KeychainSSHKeyStore.swift` — real iOS Keychain impl
(`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`, no iCloud sync).
- `UserDefaultsIOSServerConfigStore.swift` — JSON in UserDefaults.
- `Ed25519KeyGenerator.swift` — mints fresh Ed25519 keypairs via
CryptoKit, emits standard OpenSSH public-key lines, stores the
private half in a compact custom PEM that
`CitadelSSHService` decodes back into
`Curve25519.Signing.PrivateKey`.
- `CitadelSSHService.swift``SSHConnectionTester` conformance +
key-generation wrapper. Runs a one-shot SSH exec (`echo scarf-ok`)
for the onboarding probe. Clearly-marked FIXME on the Citadel
authentication-method call site because 0.7→0.9 has shifted the
variant name; other than that one line, everything is
Citadel-version-independent.
**Shipped — `scarf/scarf-ios/` iOS app source tree:**
- `App/ScarfIOSApp.swift``@main` + `RootModel` routing to
onboarding / dashboard based on stored state.
- `Onboarding/OnboardingRootView.swift` — 8 sub-views, one per
`OnboardingStep`. Validated server-details form, key-source
picker, generate / show / import / test / retry / connected.
- `Dashboard/DashboardView.swift` — M2 placeholder: connected
server details + Disconnect. M3 replaces with real data.
**Shipped — `scarf/scarf-ios/SETUP.md`:**
Step-by-step Xcode project creation + troubleshooting. Alan runs
this once on a Mac (~5 minutes).
**Test coverage:**
- **ScarfCore (Linux):** 26 new tests covering key-bundle memberwise,
both in-memory stores, config-to-ServerContext bridging, all
`OnboardingLogic` validators (empty / whitespace / port range /
legacy-RSA rejection), mock tester, and 10 end-to-end
`OnboardingViewModel` paths (happy, bad import,
connection-failure → retry-success, reset).
- **ScarfIOS (Apple-only):** 3 smoke tests for the Ed25519 generator,
OpenSSH public-key wire format (byte-length pinned at 51), and
corrupted-PEM rejection on round-trip decode.
Total: **88 passing on Linux** (62 pre-M2 + 26 new). Apple CI adds
the 3 ScarfIOS tests.
**Manual validation needed on Mac:**
1. Xcode project creation per SETUP.md.
2. Citadel 0.9.x `SSHAuthenticationMethod.ed25519(...)` variant name
— verify and fix if it's been renamed.
3. Onboarding end-to-end: simulator → physical iPhone via TestFlight
→ real SSH host with the public key added to `authorized_keys`.
**Rules next phases can rely on:**
- **M3** adds a Citadel-backed `ServerTransport` in ScarfIOS; iOS
`IOSServerConfig.toServerContext(...).makeTransport()` dispatches to
it automatically.
- **M4** adds `SSHExecACPChannel` in ScarfIOS; iOS wires the
`ACPClient.ChannelFactory` hook (from M1) to produce it — sibling
to Mac's `ACPClient+Mac.swift`.
- iOS is single-server in v1 — don't prematurely generalize the
onboarding flow.
- Source tree stays **pure SwiftUI + Foundation + ScarfCore + ScarfIOS**;
`#if canImport(UIKit)` fine for pasteboard but keep it minimal.
### M3 — pending
### M4 — pending
### M5 — pending