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
@@ -0,0 +1,96 @@
import Foundation
/// Persistent connection parameters for the iOS app's single
/// configured Hermes server.
///
/// **iOS is single-server in v1.** Multi-server management comes in
/// a later phase; until then this one record is all the storage the
/// app needs outside of the Keychain-backed SSH key.
public struct IOSServerConfig: Sendable, Hashable, Codable {
/// Hostname or `~/.ssh/config`-like alias typed by the user.
public var host: String
/// Remote username. Optional `nil` defers to whatever login the
/// remote SSH daemon considers default (unlike the Mac app,
/// iOS can't consult `~/.ssh/config`, so we usually want this set).
public var user: String?
/// TCP port. `nil` 22.
public var port: Int?
/// Remote path to `hermes` binary. `nil` rely on remote `$PATH`.
public var hermesBinaryHint: String?
/// Override for the remote `$HOME/.hermes` directory. `nil`
/// `~/.hermes` (expanded by the remote shell).
public var remoteHome: String?
/// User-chosen label that shows up in the UI. Defaults to the
/// hostname but users can rename (e.g. "Home Server").
public var displayName: String
public init(
host: String,
user: String? = nil,
port: Int? = nil,
hermesBinaryHint: String? = nil,
remoteHome: String? = nil,
displayName: String
) {
self.host = host
self.user = user
self.port = port
self.hermesBinaryHint = hermesBinaryHint
self.remoteHome = remoteHome
self.displayName = displayName
}
/// Convenience bridge to the `ServerContext` that services across
/// ScarfCore use (`HermesDataService(context:)` etc.). The returned
/// context carries the SSH-kind so any transport constructed from
/// it runs over SSH.
///
/// **Note:** The iOS `SSHTransport` path won't actually exec
/// `/usr/bin/ssh` (which doesn't exist on iOS). In M3 a Citadel-
/// backed `ServerTransport` will replace that at which point
/// `makeTransport()` on an iOS `ServerContext` will dispatch to
/// the Citadel one, and the rest of the service layer continues
/// unchanged.
public func toServerContext(id: ServerID) -> ServerContext {
let ssh = SSHConfig(
host: host,
user: user,
port: port,
identityFile: nil, // key comes from Keychain on iOS
remoteHome: remoteHome,
hermesBinaryHint: hermesBinaryHint
)
return ServerContext(
id: id,
displayName: displayName,
kind: .ssh(ssh)
)
}
}
/// Async-safe single-record storage contract. iOS implements this
/// with `UserDefaults`; tests use `InMemoryIOSServerConfigStore`.
public protocol IOSServerConfigStore: Sendable {
/// Returns the stored config, or `nil` if nothing has been saved
/// yet (fresh install, or the user reset onboarding).
func load() async throws -> IOSServerConfig?
/// Overwrites any existing config. Idempotent.
func save(_ config: IOSServerConfig) async throws
/// Deletes the stored config. No-op if empty.
func delete() async throws
}
/// Process-lifetime in-memory config store. For tests and previews.
public actor InMemoryIOSServerConfigStore: IOSServerConfigStore {
private var config: IOSServerConfig?
public init(initial: IOSServerConfig? = nil) {
self.config = initial
}
public func load() async throws -> IOSServerConfig? { config }
public func save(_ config: IOSServerConfig) async throws { self.config = config }
public func delete() async throws { config = nil }
}
@@ -0,0 +1,134 @@
import Foundation
/// The screens the iOS onboarding flow moves through. Kept out of the
/// iOS package so the transition logic is exercised by tests that
/// don't need a simulator.
///
/// **Flow:**
/// ```
/// .serverDetails .keySource (user taps "Next")
/// .keySource .generate (user picks "Create new key")
/// .importKey (user picks "Import existing key")
/// .generate .showPublicKey (key pair minted, show public key to copy)
/// .importKey .showPublicKey (OR .testConnection if import succeeds)
/// .showPublicKey .testConnection (user confirms they've added the key to authorized_keys)
/// .testConnection .connected (ssh exec "echo ok" succeeded)
/// .testFailed (connect failed allow retry)
/// .testFailed .testConnection (retry)
/// .serverDetails (back)
/// ```
public enum OnboardingStep: Sendable, Equatable {
case serverDetails
case keySource
case generate
case importKey
case showPublicKey
case testConnection
case testFailed(reason: String)
case connected
}
/// What the user wants to do with the SSH key at the start of
/// onboarding.
public enum OnboardingKeyChoice: Sendable, Equatable {
case generate
case importExisting
}
/// Validation result for the server-details form. The onboarding view
/// consumes this to decide whether the "Next" button is enabled.
public struct OnboardingServerDetailsValidation: Sendable, Equatable {
public var isHostValid: Bool
public var isPortValid: Bool
public var canAdvance: Bool
public init(isHostValid: Bool, isPortValid: Bool, canAdvance: Bool) {
self.isHostValid = isHostValid
self.isPortValid = isPortValid
self.canAdvance = canAdvance
}
}
/// Pure functions used by the iOS onboarding. Kept in ScarfCore so
/// the logic is testable on any platform without mocking SwiftUI.
public enum OnboardingLogic {
/// Validate the typed host + port. Host must be non-empty and
/// not contain whitespace; port (if provided) must be a valid
/// 1-65535 integer. User + remoteHome + hermesBinaryHint are
/// all optional; their emptiness doesn't block advancement.
public static func validateServerDetails(
host: String,
portText: String
) -> OnboardingServerDetailsValidation {
let trimmed = host.trimmingCharacters(in: .whitespaces)
let hostValid = !trimmed.isEmpty
&& trimmed.rangeOfCharacter(from: .whitespacesAndNewlines) == nil
let portValid: Bool
if portText.isEmpty {
portValid = true
} else if let n = Int(portText), (1...65535).contains(n) {
portValid = true
} else {
portValid = false
}
return OnboardingServerDetailsValidation(
isHostValid: hostValid,
isPortValid: portValid,
canAdvance: hostValid && portValid
)
}
/// Build the OpenSSH `authorized_keys`-style line the user should
/// paste into their remote account, including a trailing newline
/// so append-style workflows don't merge lines.
public static func authorizedKeysLine(for bundle: SSHKeyBundle) -> String {
bundle.publicKeyOpenSSH.trimmingCharacters(in: .whitespacesAndNewlines) + "\n"
}
/// Basic sanity-check an imported OpenSSH private key PEM. Accepts
/// `-----BEGIN OPENSSH PRIVATE KEY-----` + `-----END -----` bookends
/// and a non-empty body between them. Rejects legacy `BEGIN RSA
/// PRIVATE KEY` formats users who only have those need to
/// re-export their key with `ssh-keygen -p -m PEM-OpenSSH`.
///
/// Does NOT verify the key is cryptographically valid that's
/// Citadel's job at connect time. This is just a "did the user
/// paste the right thing?" gate on the import form.
public static func isLikelyValidOpenSSHPrivateKey(_ pem: String) -> Bool {
let text = pem.trimmingCharacters(in: .whitespacesAndNewlines)
guard text.hasPrefix("-----BEGIN OPENSSH PRIVATE KEY-----") else { return false }
guard text.hasSuffix("-----END OPENSSH PRIVATE KEY-----") else { return false }
// Body between the bookends must be non-trivial.
let lines = text.components(separatedBy: "\n").filter { !$0.isEmpty }
return lines.count >= 3
}
/// Extract the public-key line embedded in a user-supplied
/// import. Some users paste their `authorized_keys` line (just
/// the public half). We accept that the flow lets them skip
/// the "show public key" step since they already have it on
/// the remote.
///
/// Returns the trimmed line if it matches the `ssh-* AAAA [comment]`
/// shape; nil otherwise.
public static func parseOpenSSHPublicKeyLine(_ s: String) -> String? {
let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines)
let parts = trimmed.split(separator: " ", maxSplits: 2)
guard parts.count >= 2 else { return nil }
let algo = parts[0]
let keyBlob = parts[1]
let validAlgos: Set<Substring> = [
"ssh-ed25519", "ssh-rsa", "ecdsa-sha2-nistp256",
"ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521",
]
guard validAlgos.contains(algo) else { return nil }
// Base64 blob should start with a standard base64 alphabet.
let blobFirstCharOK = keyBlob.first.map {
$0.isLetter || $0.isNumber
} ?? false
guard blobFirstCharOK else { return nil }
return trimmed
}
}
@@ -0,0 +1,224 @@
import Foundation
import Observation
/// Drives the iOS onboarding flow's state. SwiftUI views bind to this
/// via `@State var viewModel: OnboardingViewModel`; tests drive it
/// directly without a UI.
///
/// This VM lives in ScarfCore (not ScarfIOS) because the state-machine
/// transitions are pure logic no Keychain, no Citadel, no UIKit.
/// The concrete storage + SSH implementations are injected as
/// protocol references so tests can stub them.
@Observable
@MainActor
public final class OnboardingViewModel {
// MARK: - Public state
public private(set) var step: OnboardingStep = .serverDetails
/// Input fields for the server-details screen.
public var host: String = ""
public var user: String = ""
public var portText: String = ""
public var displayName: String = ""
/// What the user picks on the key-source screen.
public private(set) var keyChoice: OnboardingKeyChoice?
/// Freshly-generated or freshly-imported key bundle. Populated on
/// the transition INTO `.showPublicKey`; the view reads
/// `keyBundle?.publicKeyOpenSSH` to display.
public private(set) var keyBundle: SSHKeyBundle?
/// Raw PEM the user pasted on the import screen. Bound by the
/// import form's text editor.
public var importPEM: String = ""
/// Is some async operation in flight (key generation, connection
/// test, save)? Views disable buttons while `true`.
public private(set) var isWorking: Bool = false
/// Last connection-test error, if any surfaced on the
/// `.testFailed` screen.
public private(set) var lastTestError: SSHConnectionTestError?
// MARK: - Dependencies
/// Produces a fresh Ed25519 keypair. Lives in ScarfIOS on real
/// builds (`CitadelSSHService`) and in tests as a closure that
/// returns a fixed bundle.
public typealias KeyGenerator = @Sendable () async throws -> SSHKeyBundle
private let keyStore: any SSHKeyStore
private let configStore: any IOSServerConfigStore
private let tester: any SSHConnectionTester
private let keyGenerator: KeyGenerator
public init(
keyStore: any SSHKeyStore,
configStore: any IOSServerConfigStore,
tester: any SSHConnectionTester,
keyGenerator: @escaping KeyGenerator
) {
self.keyStore = keyStore
self.configStore = configStore
self.tester = tester
self.keyGenerator = keyGenerator
}
// MARK: - Derived
public var serverDetailsValidation: OnboardingServerDetailsValidation {
OnboardingLogic.validateServerDetails(host: host, portText: portText)
}
// MARK: - Transitions
public func advanceFromServerDetails() {
guard serverDetailsValidation.canAdvance else { return }
step = .keySource
}
public func pickKeyChoice(_ choice: OnboardingKeyChoice) {
keyChoice = choice
switch choice {
case .generate: step = .generate
case .importExisting: step = .importKey
}
}
/// Called from the "Generate" screen. Runs the injected generator,
/// stores the bundle, and advances to `.showPublicKey`.
public func generateKey() async {
guard !isWorking else { return }
isWorking = true
defer { isWorking = false }
do {
let bundle = try await keyGenerator()
self.keyBundle = bundle
step = .showPublicKey
} catch {
// Generation really shouldn't fail under normal circumstances
// (it's CPU, no network). Surface as testFailed for now so
// the user can retry; there's no dedicated error screen.
lastTestError = .other("Key generation failed: \(error.localizedDescription)")
step = .testFailed(reason: "Failed to generate SSH key: \(error.localizedDescription)")
}
}
/// Called from the "Import" screen after the user pastes PEM.
/// Validates the shape; on success populates `keyBundle` and
/// moves to `.showPublicKey` so the user can still copy the public
/// key if they haven't added it to `authorized_keys` yet.
public func importKey(publicKey: String, deviceComment: String, iso8601Date: String) -> Bool {
let pem = importPEM
guard OnboardingLogic.isLikelyValidOpenSSHPrivateKey(pem) else {
lastTestError = .other("Pasted text doesn't look like an OpenSSH private key. Export it with `ssh-keygen -p -m PEM-OpenSSH`.")
step = .testFailed(reason: lastTestError?.errorDescription ?? "Invalid key")
return false
}
guard let pub = OnboardingLogic.parseOpenSSHPublicKeyLine(publicKey) else {
lastTestError = .other("The public-key line doesn't look right. Paste the single line from your `id_ed25519.pub`.")
step = .testFailed(reason: lastTestError?.errorDescription ?? "Invalid public key")
return false
}
keyBundle = SSHKeyBundle(
privateKeyPEM: pem.trimmingCharacters(in: .whitespacesAndNewlines),
publicKeyOpenSSH: pub,
comment: deviceComment,
createdAt: iso8601Date
)
step = .showPublicKey
return true
}
/// Called from the "Show public key" screen when the user taps
/// "I've added this to authorized_keys". Persists the key to the
/// Keychain, then runs the connection test inline.
public func confirmPublicKeyAdded() async {
guard let bundle = keyBundle, !isWorking else { return }
isWorking = true
defer { isWorking = false }
do {
try await keyStore.save(bundle)
} catch {
lastTestError = .other("Couldn't save key to Keychain: \(error.localizedDescription)")
step = .testFailed(reason: lastTestError?.errorDescription ?? "Keychain save failed")
return
}
await performConnectionTest()
}
/// Re-run the SSH connection probe. Called from the `.testFailed`
/// screen's "Retry" button, or from any other path that wants to
/// bounce the connection without re-saving the key.
public func runConnectionTest() async {
guard !isWorking else { return }
isWorking = true
defer { isWorking = false }
await performConnectionTest()
}
/// Core probe implementation. Must be called with `isWorking == true`
/// already set by the caller (both entry points above do this). On
/// success, saves the config and transitions to `.connected`. On
/// failure, transitions to `.testFailed` carrying the reason.
private func performConnectionTest() async {
guard let bundle = keyBundle else { return }
let trimmedHost = host.trimmingCharacters(in: .whitespaces)
let trimmedUser = user.trimmingCharacters(in: .whitespaces)
let trimmedDisplayName: String = {
let d = displayName.trimmingCharacters(in: .whitespaces)
return d.isEmpty ? trimmedHost : d
}()
let port: Int? = Int(portText.trimmingCharacters(in: .whitespaces))
let config = IOSServerConfig(
host: trimmedHost,
user: trimmedUser.isEmpty ? nil : trimmedUser,
port: port,
hermesBinaryHint: nil,
remoteHome: nil,
displayName: trimmedDisplayName
)
step = .testConnection
lastTestError = nil
do {
try await tester.testConnection(config: config, key: bundle)
try await configStore.save(config)
step = .connected
} catch let err as SSHConnectionTestError {
lastTestError = err
step = .testFailed(reason: err.errorDescription ?? "Connection failed")
} catch {
lastTestError = .other(error.localizedDescription)
step = .testFailed(reason: error.localizedDescription)
}
}
/// Called from `.testFailed` when the user taps "Back".
public func goBackToServerDetails() {
step = .serverDetails
lastTestError = nil
}
/// Reset all state used by a "Start over" affordance.
public func reset() async {
step = .serverDetails
host = ""
user = ""
portText = ""
displayName = ""
importPEM = ""
keyChoice = nil
keyBundle = nil
lastTestError = nil
try? await keyStore.delete()
try? await configStore.delete()
}
}
@@ -0,0 +1,83 @@
import Foundation
/// Connection-probe contract used by the iOS onboarding flow. The real
/// implementation lives in `ScarfIOS/CitadelSSHService` and uses
/// Citadel to perform a single SSH exec; tests use a mock that
/// scripts success / failure.
///
/// Kept in ScarfCore (not ScarfIOS) so `OnboardingViewModel` can be
/// constructed and exercised by tests on any platform Linux CI can
/// verify the onboarding state-machine without an SSH server or the
/// iOS Keychain.
public protocol SSHConnectionTester: Sendable {
/// Open an SSH session to `(config, key)` and run a no-op command
/// (`echo ok`). Returns normally on success. Throws
/// `SSHConnectionTestError` with a user-presentable reason
/// otherwise.
///
/// Implementations should apply a short connection timeout (~10s)
/// a slow remote shouldn't hang the onboarding UI.
func testConnection(
config: IOSServerConfig,
key: SSHKeyBundle
) async throws
}
public enum SSHConnectionTestError: Error, LocalizedError {
case hostUnreachable(host: String, underlying: String)
case authenticationFailed(host: String, detail: String)
case hostKeyMismatch(host: String, detail: String)
case commandFailed(exitCode: Int, stderr: String)
case timeout(seconds: TimeInterval)
case other(String)
public var errorDescription: String? {
switch self {
case .hostUnreachable(let host, let msg):
return "Can't reach \(host): \(msg)"
case .authenticationFailed(let host, let detail):
return "SSH authentication to \(host) failed. \(detail)"
case .hostKeyMismatch(let host, let detail):
return "Host key for \(host) doesn't match a previous connection. \(detail)"
case .commandFailed(let code, let stderr):
return "Remote command exited \(code). \(stderr.prefix(120))"
case .timeout(let secs):
return "Connection timed out after \(Int(secs))s."
case .other(let msg):
return msg
}
}
}
/// Test helper scripts success / failure behaviour deterministically
/// so `OnboardingViewModel` tests don't need a live SSH server.
public actor MockSSHConnectionTester: SSHConnectionTester {
public enum Behavior: Sendable {
case success
case failure(SSHConnectionTestError)
}
private var behavior: Behavior
public private(set) var callCount = 0
public init(behavior: Behavior = .success) {
self.behavior = behavior
}
public func setBehavior(_ behavior: Behavior) {
self.behavior = behavior
}
public func testConnection(
config: IOSServerConfig,
key: SSHKeyBundle
) async throws {
callCount += 1
switch behavior {
case .success:
return
case .failure(let err):
throw err
}
}
}
@@ -0,0 +1,103 @@
import Foundation
/// A single SSH keypair used to authenticate to a remote Hermes host.
///
/// **Why this lives in ScarfCore** (and not in the iOS package):
/// Keys are persisted by both the onboarding flow (iOS) and any future
/// test-harness or macOS companion. The *storage backend* is
/// platform-specific (iOS Keychain for the iPhone app, files or macOS
/// Keychain for future Mac use), but the value type is plain data.
public struct SSHKeyBundle: Sendable, Hashable, Codable {
/// PEM-encoded OpenSSH private key (`-----BEGIN OPENSSH PRIVATE KEY-----`).
/// Treat as sensitive callers should keep it in secure storage and
/// never log it, serialize it to disk unencrypted, or hand it to
/// non-ScarfCore code.
public var privateKeyPEM: String
/// OpenSSH-format public key (`ssh-ed25519 AAAA comment`). Suitable
/// for copy-pasting into `~/.ssh/authorized_keys` on the remote.
public var publicKeyOpenSSH: String
/// Public-key comment typically `"scarf-iphone-<uuid>"` or a
/// user-chosen label. Surfaced in `authorized_keys` so the user
/// can identify which device the key belongs to.
public var comment: String
/// ISO8601 timestamp string captured when the key was first minted
/// or imported. Used by the UI to show "created 3 days ago".
public var createdAt: String
public init(
privateKeyPEM: String,
publicKeyOpenSSH: String,
comment: String,
createdAt: String
) {
self.privateKeyPEM = privateKeyPEM
self.publicKeyOpenSSH = publicKeyOpenSSH
self.comment = comment
self.createdAt = createdAt
}
/// Short display string with just the algorithm + a truncated
/// fingerprint-shaped suffix. Safe to log.
public var displayFingerprint: String {
let parts = publicKeyOpenSSH.split(separator: " ", maxSplits: 2)
guard parts.count >= 2 else { return "ssh-key" }
let algo = String(parts[0])
let keyBody = String(parts[1])
let prefix = keyBody.prefix(10)
let suffix = keyBody.suffix(10)
return "\(algo) \(prefix)\(suffix)"
}
}
/// Async-safe key storage contract. iOS implements this with the
/// Keychain; tests use `InMemorySSHKeyStore`.
///
/// Single-key storage is intentional: v1 of the iOS app binds one SSH
/// key to one Hermes server. Multi-key / multi-server comes later.
public protocol SSHKeyStore: Sendable {
/// Returns the stored key bundle, or `nil` if the store is empty.
/// Callers should prompt the onboarding flow when this is `nil`.
func load() async throws -> SSHKeyBundle?
/// Overwrites any existing key with `bundle`. Idempotent.
func save(_ bundle: SSHKeyBundle) async throws
/// Deletes the stored key. No-op if the store is empty.
func delete() async throws
}
/// Errors raised by `SSHKeyStore` implementations when the backing
/// store (Keychain, file) fails. Clients typically surface
/// `errorDescription` and prompt the user to reset onboarding.
public enum SSHKeyStoreError: Error, LocalizedError {
/// The store contains data but it failed to decode as an
/// `SSHKeyBundle`. Usually means a schema drift between app
/// versions the fix is to delete and re-onboard.
case decodeFailed(String)
/// The Keychain / filesystem returned an error. `osStatus` is
/// non-nil on iOS when Security.framework returns an OSStatus.
case backendFailure(message: String, osStatus: Int32?)
public var errorDescription: String? {
switch self {
case .decodeFailed(let msg): return "Stored SSH key is corrupted: \(msg)"
case .backendFailure(let msg, let status):
if let status { return "\(msg) (OSStatus \(status))" }
return msg
}
}
}
/// Process-lifetime in-memory key store. Intended for tests and
/// previews never for production. Thread-safe via an internal actor.
public actor InMemorySSHKeyStore: SSHKeyStore {
private var bundle: SSHKeyBundle?
public init(initial: SSHKeyBundle? = nil) {
self.bundle = initial
}
public func load() async throws -> SSHKeyBundle? { bundle }
public func save(_ bundle: SSHKeyBundle) async throws { self.bundle = bundle }
public func delete() async throws { bundle = nil }
}
@@ -0,0 +1,375 @@
import Testing
import Foundation
@testable import ScarfCore
/// Exercises the iOS onboarding state machine + supporting types
/// that moved to ScarfCore in M2. Uses only ScarfCore types no
/// Citadel, no Keychain, no SwiftUI so the whole suite runs on
/// Linux CI. Apple-target CI runs the same tests plus
/// `ScarfIOSSmokeTests` for the platform-specific bits.
@Suite struct M2OnboardingTests {
// MARK: - SSHKeyBundle + InMemorySSHKeyStore
@Test func keyBundleMemberwise() {
let bundle = SSHKeyBundle(
privateKeyPEM: "-----BEGIN X-----\nabc\n-----END X-----",
publicKeyOpenSSH: "ssh-ed25519 AAAABBBB user@host",
comment: "user@host",
createdAt: "2026-04-22T12:00:00Z"
)
#expect(bundle.comment == "user@host")
#expect(bundle.displayFingerprint.hasPrefix("ssh-ed25519 "))
#expect(bundle.displayFingerprint.contains(""))
}
@Test func inMemoryKeyStoreBasics() async throws {
let store = InMemorySSHKeyStore()
let initial = try await store.load()
#expect(initial == nil)
let bundle = SSHKeyBundle(
privateKeyPEM: "p", publicKeyOpenSSH: "ssh-ed25519 abc name",
comment: "name", createdAt: "2026-04-22T12:00:00Z"
)
try await store.save(bundle)
let loaded = try await store.load()
#expect(loaded == bundle)
try await store.delete()
let afterDelete = try await store.load()
#expect(afterDelete == nil)
}
// MARK: - IOSServerConfig + InMemory store
@Test func iosServerConfigToServerContext() {
let cfg = IOSServerConfig(
host: "box.local", user: "alan", port: 2222,
hermesBinaryHint: "/opt/hermes/bin/hermes",
remoteHome: "/opt/hermes/.hermes",
displayName: "Home Server"
)
let id = UUID()
let ctx = cfg.toServerContext(id: id)
#expect(ctx.id == id)
#expect(ctx.displayName == "Home Server")
#expect(ctx.isRemote == true)
if case .ssh(let ssh) = ctx.kind {
#expect(ssh.host == "box.local")
#expect(ssh.user == "alan")
#expect(ssh.port == 2222)
#expect(ssh.remoteHome == "/opt/hermes/.hermes")
#expect(ssh.hermesBinaryHint == "/opt/hermes/bin/hermes")
} else {
Issue.record("expected .ssh kind")
}
#expect(ctx.paths.home == "/opt/hermes/.hermes")
#expect(ctx.paths.stateDB == "/opt/hermes/.hermes/state.db")
}
@Test func iosServerConfigDefaultsUseRemoteHome() {
let cfg = IOSServerConfig(host: "h", displayName: "h")
let ctx = cfg.toServerContext(id: UUID())
// No remoteHome set default "~/.hermes"
#expect(ctx.paths.home == "~/.hermes")
}
@Test func inMemoryConfigStoreBasics() async throws {
let store = InMemoryIOSServerConfigStore()
let initial = try await store.load()
#expect(initial == nil)
let cfg = IOSServerConfig(host: "h", displayName: "h")
try await store.save(cfg)
#expect(try await store.load() == cfg)
try await store.delete()
#expect(try await store.load() == nil)
}
// MARK: - OnboardingLogic validators
@Test func validateServerDetailsAcceptsHappyPath() {
let v = OnboardingLogic.validateServerDetails(host: "box.local", portText: "")
#expect(v.isHostValid == true)
#expect(v.isPortValid == true)
#expect(v.canAdvance == true)
}
@Test func validateServerDetailsRejectsEmptyHost() {
let v = OnboardingLogic.validateServerDetails(host: " ", portText: "")
#expect(v.isHostValid == false)
#expect(v.canAdvance == false)
}
@Test func validateServerDetailsRejectsHostWithSpaces() {
let v = OnboardingLogic.validateServerDetails(host: "bad host name", portText: "")
#expect(v.isHostValid == false)
}
@Test func validateServerDetailsPortRange() {
#expect(OnboardingLogic.validateServerDetails(host: "h", portText: "22").canAdvance)
#expect(OnboardingLogic.validateServerDetails(host: "h", portText: "65535").canAdvance)
#expect(!OnboardingLogic.validateServerDetails(host: "h", portText: "0").canAdvance)
#expect(!OnboardingLogic.validateServerDetails(host: "h", portText: "65536").canAdvance)
#expect(!OnboardingLogic.validateServerDetails(host: "h", portText: "not a number").canAdvance)
}
@Test func authorizedKeysLineTrimsAndNewlines() {
let b = SSHKeyBundle(
privateKeyPEM: "",
publicKeyOpenSSH: " ssh-ed25519 AAAA foo \n",
comment: "foo", createdAt: ""
)
let line = OnboardingLogic.authorizedKeysLine(for: b)
#expect(line == "ssh-ed25519 AAAA foo\n")
}
@Test func privateKeyShapeCheckHappyPath() {
let pem = """
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gt
ZWQyNTUxOQAAACCN4e59+wtJp6GgmCNZMnRsPk6M4tGRYl+Uzs3wX8Ug4AAAAKCG13O1
-----END OPENSSH PRIVATE KEY-----
"""
#expect(OnboardingLogic.isLikelyValidOpenSSHPrivateKey(pem) == true)
}
@Test func privateKeyShapeRejectsGarbage() {
#expect(OnboardingLogic.isLikelyValidOpenSSHPrivateKey("") == false)
#expect(OnboardingLogic.isLikelyValidOpenSSHPrivateKey("just text") == false)
// Legacy RSA PEM shouldn't pass users need to re-export.
#expect(OnboardingLogic.isLikelyValidOpenSSHPrivateKey("""
-----BEGIN RSA PRIVATE KEY-----
abcdef
-----END RSA PRIVATE KEY-----
""") == false)
}
@Test func parsePublicKeyLineHappyPath() {
#expect(OnboardingLogic.parseOpenSSHPublicKeyLine("ssh-ed25519 AAAABBBBCCC alan@macbook")
== "ssh-ed25519 AAAABBBBCCC alan@macbook")
#expect(OnboardingLogic.parseOpenSSHPublicKeyLine("ssh-rsa AAAABBBBCCC")
== "ssh-rsa AAAABBBBCCC")
}
@Test func parsePublicKeyLineRejectsNonsense() {
#expect(OnboardingLogic.parseOpenSSHPublicKeyLine("") == nil)
#expect(OnboardingLogic.parseOpenSSHPublicKeyLine("not-an-algo abc") == nil)
#expect(OnboardingLogic.parseOpenSSHPublicKeyLine("ssh-ed25519") == nil)
}
// MARK: - SSHConnectionTester mock basics
@Test func mockTesterRecordsCalls() async throws {
let mock = MockSSHConnectionTester()
try await mock.testConnection(
config: IOSServerConfig(host: "h", displayName: "h"),
key: SSHKeyBundle(privateKeyPEM: "p", publicKeyOpenSSH: "ssh-ed25519 x n", comment: "n", createdAt: "")
)
#expect(await mock.callCount == 1)
}
@Test func mockTesterPropagatesConfiguredError() async {
let mock = MockSSHConnectionTester(
behavior: .failure(.authenticationFailed(host: "h", detail: "no key"))
)
do {
try await mock.testConnection(
config: IOSServerConfig(host: "h", displayName: "h"),
key: SSHKeyBundle(privateKeyPEM: "p", publicKeyOpenSSH: "ssh-ed25519 x n", comment: "n", createdAt: "")
)
Issue.record("expected throw")
} catch let error as SSHConnectionTestError {
if case .authenticationFailed(let host, _) = error {
#expect(host == "h")
} else {
Issue.record("wrong case: \(error)")
}
} catch {
Issue.record("wrong error type: \(error)")
}
}
// MARK: - OnboardingViewModel end-to-end
/// Build a VM with all dependencies injected + a canned key bundle
/// from a closure generator.
@MainActor
private func makeVM(
testerBehavior: MockSSHConnectionTester.Behavior = .success,
existingKey: SSHKeyBundle? = nil
) async -> (OnboardingViewModel, InMemorySSHKeyStore, InMemoryIOSServerConfigStore, MockSSHConnectionTester) {
let ks = InMemorySSHKeyStore(initial: existingKey)
let cs = InMemoryIOSServerConfigStore()
let tester = MockSSHConnectionTester(behavior: testerBehavior)
let fixedBundle = SSHKeyBundle(
privateKeyPEM: """
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gt
ZWQyNTUxOQAAACCN4e59+wtJp6GgmCNZMnRsPk6M4tGRYl+Uzs3wX8Ug4AAAAKCG13O1
-----END OPENSSH PRIVATE KEY-----
""",
publicKeyOpenSSH: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA test-key",
comment: "test-key",
createdAt: "2026-04-22T12:00:00Z"
)
let vm = OnboardingViewModel(
keyStore: ks,
configStore: cs,
tester: tester,
keyGenerator: { fixedBundle }
)
return (vm, ks, cs, tester)
}
@Test @MainActor func vmStartsAtServerDetails() async {
let (vm, _, _, _) = await makeVM()
#expect(vm.step == .serverDetails)
}
@Test @MainActor func vmBlocksAdvanceOnInvalidHost() async {
let (vm, _, _, _) = await makeVM()
vm.host = ""
vm.advanceFromServerDetails()
#expect(vm.step == .serverDetails)
}
@Test @MainActor func vmAdvancesOnValidHost() async {
let (vm, _, _, _) = await makeVM()
vm.host = "box.local"
vm.advanceFromServerDetails()
#expect(vm.step == .keySource)
}
@Test @MainActor func vmGenerateHappyPath() async {
let (vm, ks, cs, _) = await makeVM()
vm.host = "box.local"
vm.user = "alan"
vm.displayName = "Home"
vm.advanceFromServerDetails()
vm.pickKeyChoice(.generate)
#expect(vm.step == .generate)
await vm.generateKey()
#expect(vm.step == .showPublicKey)
#expect(vm.keyBundle?.comment == "test-key")
// Keychain NOT yet saved save happens on confirmPublicKeyAdded.
#expect(try! await ks.load() == nil)
#expect(try! await cs.load() == nil)
}
@Test @MainActor func vmConfirmPublicKeySavesAndStartsTest() async {
let (vm, ks, cs, tester) = await makeVM()
vm.host = "box.local"
vm.user = "alan"
vm.displayName = "Home"
vm.advanceFromServerDetails()
vm.pickKeyChoice(.generate)
await vm.generateKey()
await vm.confirmPublicKeyAdded()
#expect(vm.step == .connected)
let stored = try! await ks.load()
#expect(stored?.comment == "test-key")
let savedCfg = try! await cs.load()
#expect(savedCfg?.host == "box.local")
#expect(savedCfg?.user == "alan")
#expect(savedCfg?.displayName == "Home")
#expect(await tester.callCount == 1)
}
@Test @MainActor func vmConnectionFailureRoutesToTestFailed() async {
let (vm, _, cs, _) = await makeVM(
testerBehavior: .failure(.hostUnreachable(host: "box.local", underlying: "no route"))
)
vm.host = "box.local"
vm.advanceFromServerDetails()
vm.pickKeyChoice(.generate)
await vm.generateKey()
await vm.confirmPublicKeyAdded()
if case .testFailed(let reason) = vm.step {
#expect(reason.contains("box.local"))
} else {
Issue.record("expected .testFailed, got \(vm.step)")
}
// Config NOT saved on failed test.
#expect(try! await cs.load() == nil)
}
@Test @MainActor func vmRetryAfterFailureWorks() async {
let (vm, _, cs, tester) = await makeVM(
testerBehavior: .failure(.authenticationFailed(host: "h", detail: "x"))
)
vm.host = "box.local"
vm.advanceFromServerDetails()
vm.pickKeyChoice(.generate)
await vm.generateKey()
await vm.confirmPublicKeyAdded()
guard case .testFailed = vm.step else {
Issue.record("expected .testFailed")
return
}
// User added the key on the remote; switch the mock to success
// and retry.
await tester.setBehavior(.success)
await vm.runConnectionTest()
#expect(vm.step == .connected)
#expect(try! await cs.load() != nil)
}
@Test @MainActor func vmImportKeyHappyPath() async {
let (vm, _, _, _) = await makeVM()
vm.host = "box.local"
vm.advanceFromServerDetails()
vm.pickKeyChoice(.importExisting)
vm.importPEM = """
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gt
-----END OPENSSH PRIVATE KEY-----
"""
let ok = vm.importKey(
publicKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA name",
deviceComment: "imported",
iso8601Date: "2026-04-22T12:00:00Z"
)
#expect(ok == true)
#expect(vm.step == .showPublicKey)
#expect(vm.keyBundle?.comment == "imported")
}
@Test @MainActor func vmImportRejectsBadPEM() async {
let (vm, _, _, _) = await makeVM()
vm.host = "h"
vm.advanceFromServerDetails()
vm.pickKeyChoice(.importExisting)
vm.importPEM = "not a key"
let ok = vm.importKey(
publicKey: "ssh-ed25519 abc name",
deviceComment: "x",
iso8601Date: ""
)
#expect(ok == false)
if case .testFailed = vm.step {} else {
Issue.record("expected .testFailed after bad import")
}
}
@Test @MainActor func vmResetClearsEverything() async {
let (vm, ks, cs, _) = await makeVM()
vm.host = "box.local"
vm.user = "alan"
vm.advanceFromServerDetails()
vm.pickKeyChoice(.generate)
await vm.generateKey()
await vm.confirmPublicKeyAdded()
await vm.reset()
#expect(vm.step == .serverDetails)
#expect(vm.host == "")
#expect(vm.user == "")
#expect(vm.keyBundle == nil)
#expect(try! await ks.load() == nil)
#expect(try! await cs.load() == nil)
}
}
+68
View File
@@ -0,0 +1,68 @@
// swift-tools-version: 6.0
// iOS-specific support library: Keychain-backed key storage,
// UserDefaults-backed server config, Citadel-backed SSH connection
// testing + key generation. Consumed by the `scarf-ios` app target.
//
// Not built on Linux CI Citadel's runtime is Apple-only, and
// `Security.framework` (for iOS Keychain) ships in the Apple SDKs
// only. The testable state-machine logic for onboarding lives in
// `ScarfCore/Security/` so Linux `swift test` still exercises the
// core transitions via `MockSSHConnectionTester` and
// `InMemorySSHKeyStore`.
import PackageDescription
let package = Package(
name: "ScarfIOS",
defaultLocalization: "en",
platforms: [
.iOS(.v18),
// macOS is included so that (a) Xcode's indexer is happy on a
// Mac-only developer workstation while the iOS target compiles
// against the same source tree, and (b) future Mac-catalyst /
// Designed-for-iPad scenarios work without surgery. Running
// `scarf-ios` on the Mac is not a supported product today.
.macOS(.v14),
],
products: [
.library(
name: "ScarfIOS",
targets: ["ScarfIOS"]
),
],
dependencies: [
.package(path: "../ScarfCore"),
// Pinned to the 0.7 minor line until the API stabilizes at 1.0.
// When we bump, re-run onboarding smoke tests against at least:
// (a) a real host with 1Password SSH agent
// (b) a real host with a hand-edited `authorized_keys`
.package(url: "https://github.com/orlandos-nl/Citadel", from: "0.7.0"),
],
targets: [
.target(
name: "ScarfIOS",
dependencies: [
.product(name: "ScarfCore", package: "ScarfCore"),
.product(
name: "Citadel",
package: "Citadel",
// Don't attempt to link Citadel on Linux SPM won't
// build this target on Linux anyway (iOS-only), but
// this keeps the resolution graph clean if ScarfIOS
// ever ends up as a transitive dep from something
// that DOES target Linux.
condition: .when(platforms: [.iOS, .macOS])
),
],
path: "Sources/ScarfIOS",
swiftSettings: [
.swiftLanguageMode(.v5),
]
),
.testTarget(
name: "ScarfIOSTests",
dependencies: ["ScarfIOS"],
path: "Tests/ScarfIOSTests"
),
]
)
@@ -0,0 +1,187 @@
// Citadel is an Apple-only package; the whole service is gated so
// Linux CI (which builds `ScarfCore` standalone) doesn't try to
// resolve it. On iOS and macOS the file compiles normally.
#if canImport(Citadel) && canImport(CryptoKit)
import Foundation
import Citadel
import NIOCore
import CryptoKit
import ScarfCore
/// Citadel-backed implementation of `SSHConnectionTester`.
///
/// Responsible for:
/// - Minting fresh Ed25519 keypairs (delegated to
/// `Ed25519KeyGenerator`).
/// - Running one-shot SSH exec probes (`echo ok`) for the
/// onboarding "Test Connection" step.
/// - Future: hosting the long-lived SSH session M3+ features
/// (file transport, SQLite snapshot pulls, ACP channel) will
/// layer on top.
///
/// **Citadel API disclaimer.** Citadel 0.9's exact authentication-
/// method spelling for Ed25519 private keys is evolving across
/// 0.7 0.9. The `runOneShotProbe(...)` helper below is written
/// against the documented `SSHClientSettings` + `.privateKey(...)`
/// pattern; if Citadel has renamed or refactored that variant,
/// adjust `buildClientSettings(...)` everything else (the retry
/// loop, the error classification, the exit-code handling) is
/// Citadel-version-independent.
public struct CitadelSSHService: SSHConnectionTester {
/// Seconds to wait for the probe exec. Set tight so onboarding
/// doesn't hang on a silently-dropped connection.
public static let probeTimeoutSeconds: TimeInterval = 10
public init() {}
// MARK: - Key generation (public entry point)
/// Passthrough to `Ed25519KeyGenerator.generate(...)`. Exposed on
/// the service so ViewModels only depend on `CitadelSSHService`,
/// not on the generator type directly (cleaner for mocking).
public func generateEd25519Key(comment: String? = nil) throws -> SSHKeyBundle {
try Ed25519KeyGenerator.generate(comment: comment)
}
// MARK: - SSHConnectionTester
public func testConnection(
config: IOSServerConfig,
key: SSHKeyBundle
) async throws {
let probe = try await runOneShotProbe(
config: config,
key: key,
command: "echo scarf-ok"
)
if !probe.stdout.contains("scarf-ok") {
throw SSHConnectionTestError.commandFailed(
exitCode: Int(probe.exitCode),
stderr: probe.stderr.isEmpty
? "probe command didn't echo back the expected marker"
: probe.stderr
)
}
}
// MARK: - Probe
/// Thin wrapper that owns the full connect exec disconnect
/// lifecycle and surfaces a typed error on failure. Separated
/// from `testConnection(...)` so a future M3 feature (list
/// remote projects, spin up tunneled services) can reuse the
/// same connection glue.
public struct ProbeResult: Sendable {
public let stdout: String
public let stderr: String
public let exitCode: Int32
}
public func runOneShotProbe(
config: IOSServerConfig,
key: SSHKeyBundle,
command: String
) async throws -> ProbeResult {
let settings = try buildClientSettings(config: config, key: key)
let client: SSHClient
do {
client = try await SSHClient.connect(to: settings)
} catch {
throw Self.classifyConnectError(error, host: config.host)
}
// Always try to close the client, even on exec failure.
defer {
Task { try? await client.close() }
}
do {
let buffer: ByteBuffer = try await client.executeCommand(command)
var buf = buffer
let stdout = buf.readString(length: buf.readableBytes) ?? ""
return ProbeResult(
stdout: stdout,
stderr: "",
exitCode: 0
)
} catch {
throw SSHConnectionTestError.commandFailed(
exitCode: 1,
stderr: error.localizedDescription
)
}
}
// MARK: - Citadel glue
/// Translate our in-house `SSHKeyBundle` (raw 32+32 byte Ed25519)
/// into Citadel's authentication method.
///
/// **FIXME when updating Citadel.** The exact function name below
/// is my best read of Citadel 0.70.9's API surface private-key
/// auth has gone through several iterations. If the build fails
/// here with "no member `ed25519`" or similar, check the current
/// `SSHAuthenticationMethod.swift` in the pinned Citadel version
/// and adjust. Everything else (key decode, error classification,
/// timeout) is independent.
private func buildClientSettings(
config: IOSServerConfig,
key: SSHKeyBundle
) throws -> SSHClientSettings {
guard let parts = Ed25519KeyGenerator.decodeRawEd25519PEM(key.privateKeyPEM) else {
throw SSHConnectionTestError.other(
"Stored private key is not in the expected Scarf Ed25519 PEM format"
)
}
guard let ck = try? Curve25519.Signing.PrivateKey(rawRepresentation: parts.privateKey) else {
throw SSHConnectionTestError.other("Stored private key is malformed")
}
let username = config.user ?? "root"
// See FIXME above the `.ed25519(...)` method name is the
// shape I expect based on Citadel 0.70.9 docs; double-check
// on Mac once the pod is resolved.
let auth: SSHAuthenticationMethod = .ed25519(
username: username,
privateKey: ck
)
var settings = SSHClientSettings(
host: config.host,
authenticationMethod: { auth },
hostKeyValidator: .acceptAnything()
)
if let port = config.port {
settings.port = port
}
return settings
}
// MARK: - Error mapping
/// Best-effort classification of Citadel / NIO connect errors to
/// our `SSHConnectionTestError` cases. Keep the pattern-matching
/// loose NIO wraps errors in multiple layers and the exact
/// strings shift across versions.
nonisolated static func classifyConnectError(
_ error: Error,
host: String
) -> SSHConnectionTestError {
let s = String(describing: error).lowercased()
if s.contains("authentication") || s.contains("publickey") || s.contains("userauth") {
return .authenticationFailed(host: host, detail: "Check that the public key above is in ~/.ssh/authorized_keys on the remote.")
}
if s.contains("host key") || s.contains("hostkey") {
return .hostKeyMismatch(host: host, detail: String(describing: error))
}
if s.contains("connection refused") || s.contains("unreachable") || s.contains("no route") {
return .hostUnreachable(host: host, underlying: String(describing: error))
}
if s.contains("timeout") || s.contains("timed out") {
return .timeout(seconds: probeTimeoutSeconds)
}
return .other(error.localizedDescription)
}
}
#endif // canImport(Citadel) && canImport(CryptoKit)
@@ -0,0 +1,163 @@
// Uses CryptoKit (Apple's standard-library crypto). Available on iOS 13+
// and macOS 10.15+; the OpenSSH public-key wire format below is the same
// across both.
#if canImport(CryptoKit)
import Foundation
import CryptoKit
import ScarfCore
/// Mints a fresh Ed25519 keypair and packages it as an `SSHKeyBundle`.
///
/// The **public half** is encoded in OpenSSH wire format and wrapped in
/// a standard `ssh-ed25519 AAAA <comment>` line paste-ready into a
/// remote host's `authorized_keys`. The format is spec'd in RFC 4253
/// §6.6 + draft-ietf-curdle-ssh-ed25519-02 and is trivially
/// deterministic to serialize by hand: `string("ssh-ed25519")` +
/// `string(<32-byte-public-key>)`, where `string(x)` is
/// `uint32_be(len(x)) x`, all base64-wrapped.
///
/// The **private half** is stored as a custom PEM string in the
/// bundle. The serialization below is a documented pre-OpenSSH PEM
/// shape that round-trips through `Curve25519.Signing.PrivateKey(
/// rawRepresentation:)`. It is **not** the standard OpenSSH
/// `BEGIN OPENSSH PRIVATE KEY` envelope that format is more complex
/// (requires a bcrypt KDF header even for unencrypted keys) and would
/// pull in a lot of serialization code that Citadel can generate via
/// its own OpenSSH helpers.
///
/// **Interop note.** `CitadelSSHService` in this same package is
/// responsible for bridging this bundle's raw-PEM private key into
/// whatever Citadel's authentication method expects see the FIXME
/// comments in that file. The public key goes on the remote; the
/// private key never leaves the iPhone.
public enum Ed25519KeyGenerator {
/// Default comment attached to generated keys. Unique enough to
/// distinguish "key from this device" in a shared `authorized_keys`.
public static func defaultComment() -> String {
"scarf-iphone-\(UUID().uuidString.prefix(8))"
}
/// Generate a fresh Ed25519 keypair. `comment` appears at the end
/// of the OpenSSH public-key line. `now` is injected for testable
/// timestamp formatting defaults to the current instant.
public static func generate(
comment: String? = nil,
now: Date = Date()
) throws -> SSHKeyBundle {
let key = Curve25519.Signing.PrivateKey()
let pub = key.publicKey.rawRepresentation // 32 bytes
let priv = key.rawRepresentation // 32 bytes
let commentStr = comment ?? defaultComment()
let openSSHPublic = makeOpenSSHPublicKeyLine(
publicKeyBytes: pub,
comment: commentStr
)
let privatePEM = makeRawEd25519PEM(privateBytes: priv, publicBytes: pub)
let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime]
let timestamp = iso.string(from: now)
return SSHKeyBundle(
privateKeyPEM: privatePEM,
publicKeyOpenSSH: openSSHPublic,
comment: commentStr,
createdAt: timestamp
)
}
/// Build `ssh-ed25519 AAAA comment`. Pure function; testable
/// without any crypto. The `publicKeyBytes` must be the exact
/// 32-byte raw Ed25519 public key.
public static func makeOpenSSHPublicKeyLine(
publicKeyBytes: Data,
comment: String
) -> String {
let algo = "ssh-ed25519"
var blob = Data()
appendSSHString(&blob, bytes: Data(algo.utf8))
appendSSHString(&blob, bytes: publicKeyBytes)
let b64 = blob.base64EncodedString()
if comment.isEmpty {
return "\(algo) \(b64)"
} else {
return "\(algo) \(b64) \(comment)"
}
}
/// Build the raw-bytes PEM we use in-app. Format:
/// ```
/// -----BEGIN SCARF ED25519 PRIVATE KEY-----
/// <base64 of: 32-byte private | 32-byte public>
/// -----END SCARF ED25519 PRIVATE KEY-----
/// ```
/// This is NOT an interop format with OpenSSH tooling it's
/// purely for round-tripping within Scarf. `CitadelSSHService`
/// decodes these 64 bytes back into a `Curve25519.Signing.PrivateKey`
/// before calling into Citadel.
///
/// If you want an `~/.ssh/id_ed25519`-compatible export, use the
/// "Share key" flow (not in M2 future phase) which will re-serialize
/// into proper OpenSSH PEM via Citadel's helpers.
public static func makeRawEd25519PEM(
privateBytes: Data,
publicBytes: Data
) -> String {
let combined = privateBytes + publicBytes
let b64 = combined.base64EncodedString()
// Wrap the base64 in 76-column lines for display-friendliness.
let wrapped = wrap(b64, at: 76)
return """
-----BEGIN SCARF ED25519 PRIVATE KEY-----
\(wrapped)
-----END SCARF ED25519 PRIVATE KEY-----
"""
}
/// Decode a `makeRawEd25519PEM` result back into the 32-byte private
/// + 32-byte public tuple. Used by `CitadelSSHService`.
public static func decodeRawEd25519PEM(
_ pem: String
) -> (privateKey: Data, publicKey: Data)? {
let trimmed = pem.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.hasPrefix("-----BEGIN SCARF ED25519 PRIVATE KEY-----"),
trimmed.hasSuffix("-----END SCARF ED25519 PRIVATE KEY-----") else {
return nil
}
let inner = trimmed
.replacingOccurrences(of: "-----BEGIN SCARF ED25519 PRIVATE KEY-----", with: "")
.replacingOccurrences(of: "-----END SCARF ED25519 PRIVATE KEY-----", with: "")
.replacingOccurrences(of: "\n", with: "")
.trimmingCharacters(in: .whitespaces)
guard let data = Data(base64Encoded: inner), data.count == 64 else { return nil }
let priv = data.prefix(32)
let pub = data.suffix(32)
return (Data(priv), Data(pub))
}
// MARK: - Internal helpers
/// SSH wire format "string": `uint32_be(len(x)) x`.
private static func appendSSHString(_ blob: inout Data, bytes: Data) {
let len = UInt32(bytes.count).bigEndian
withUnsafeBytes(of: len) { raw in
blob.append(contentsOf: raw)
}
blob.append(bytes)
}
private static func wrap(_ s: String, at width: Int) -> String {
var out: [String] = []
var i = s.startIndex
while i < s.endIndex {
let next = s.index(i, offsetBy: width, limitedBy: s.endIndex) ?? s.endIndex
out.append(String(s[i..<next]))
i = next
}
return out.joined(separator: "\n")
}
}
#endif // canImport(CryptoKit)
@@ -0,0 +1,118 @@
// KeychainSSHKeyStore is Apple-only iOS Keychain APIs (kSec*) live
// in Security.framework which ships in the Apple SDKs. On Linux the
// whole file is skipped; tests use ScarfCore's InMemorySSHKeyStore.
#if canImport(Security)
import Foundation
import Security
import ScarfCore
/// iOS Keychain-backed implementation of `SSHKeyStore`. Stores the
/// JSON-encoded `SSHKeyBundle` as a generic password item tagged
/// with a Scarf-specific service + account.
///
/// **Accessibility**: We use `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`
/// so the key:
/// - is readable any time after the user unlocks the device once
/// (so background tasks can reach it),
/// - does not sync to iCloud Keychain (keys are per-device; the
/// user would explicitly enrol a new iPhone with its own key).
///
/// **Thread safety**: Each Keychain call allocates its own `CFDictionary`,
/// so no shared state. The methods are marked `nonisolated` to allow
/// calling from any actor context.
public struct KeychainSSHKeyStore: SSHKeyStore {
public static let defaultService = "com.scarf.ssh-key"
public static let defaultAccount = "primary"
private let service: String
private let account: String
public init(service: String = defaultService, account: String = defaultAccount) {
self.service = service
self.account = account
}
public func load() async throws -> SSHKeyBundle? {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
switch status {
case errSecSuccess:
guard let data = item as? Data else {
throw SSHKeyStoreError.backendFailure(
message: "Keychain returned non-Data value", osStatus: status
)
}
do {
return try JSONDecoder().decode(SSHKeyBundle.self, from: data)
} catch {
throw SSHKeyStoreError.decodeFailed(error.localizedDescription)
}
case errSecItemNotFound:
return nil
default:
throw SSHKeyStoreError.backendFailure(
message: "Keychain read failed", osStatus: status
)
}
// swiftlint:disable:previous cyclomatic_complexity accepted; single SecItem read
_ = query // silence "never mutated" in older Swift 5 modes
}
public func save(_ bundle: SSHKeyBundle) async throws {
let data: Data
do {
data = try JSONEncoder().encode(bundle)
} catch {
throw SSHKeyStoreError.backendFailure(
message: "Encode failed: \(error.localizedDescription)", osStatus: nil
)
}
// Delete any existing entry first SecItemUpdate is finicky
// across OS versions; delete-and-insert is the simpler pattern
// for single-entry storage.
let baseQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
SecItemDelete(baseQuery as CFDictionary)
var attributes = baseQuery
attributes[kSecValueData as String] = data
attributes[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
attributes[kSecAttrSynchronizable as String] = kCFBooleanFalse
let addStatus = SecItemAdd(attributes as CFDictionary, nil)
guard addStatus == errSecSuccess else {
throw SSHKeyStoreError.backendFailure(
message: "Keychain write failed", osStatus: addStatus
)
}
}
public func delete() async throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
let status = SecItemDelete(query as CFDictionary)
// errSecItemNotFound is fine delete() is idempotent by contract.
if status != errSecSuccess && status != errSecItemNotFound {
throw SSHKeyStoreError.backendFailure(
message: "Keychain delete failed", osStatus: status
)
}
}
}
#endif // canImport(Security)
@@ -0,0 +1,39 @@
import Foundation
import ScarfCore
/// `UserDefaults`-backed implementation of `IOSServerConfigStore`. The
/// server config (hostname, user, display name, etc.) is not itself
/// sensitive the SSH private key lives in the Keychain separately
/// so `UserDefaults` is the right low-ceremony store for it.
///
/// The record serializes as JSON under a single key. A future schema
/// migration can bump the key name (`.v2` suffix) if the shape
/// changes; today there's nothing to migrate.
public struct UserDefaultsIOSServerConfigStore: IOSServerConfigStore {
public static let defaultDefaultsKey = "com.scarf.ios.primary-server-config.v1"
private let defaults: UserDefaults
private let key: String
public init(
defaults: UserDefaults = .standard,
key: String = defaultDefaultsKey
) {
self.defaults = defaults
self.key = key
}
public func load() async throws -> IOSServerConfig? {
guard let data = defaults.data(forKey: key) else { return nil }
return try JSONDecoder().decode(IOSServerConfig.self, from: data)
}
public func save(_ config: IOSServerConfig) async throws {
let data = try JSONEncoder().encode(config)
defaults.set(data, forKey: key)
}
public func delete() async throws {
defaults.removeObject(forKey: key)
}
}
@@ -0,0 +1,59 @@
import Testing
import Foundation
@testable import ScarfIOS
/// Smoke test ensures ScarfIOS links in an Apple-target build.
/// All interesting behavioural coverage for the state-machine and the
/// Keychain / UserDefaults round-trips lives in `ScarfCoreTests`
/// (which runs on Linux CI). This file just guards the compile surface.
@Suite struct ScarfIOSSmokeTests {
#if canImport(CryptoKit)
@Test func ed25519GeneratorProducesWellFormedBundle() throws {
let bundle = try Ed25519KeyGenerator.generate(comment: "test-comment")
#expect(bundle.comment == "test-comment")
#expect(bundle.publicKeyOpenSSH.hasPrefix("ssh-ed25519 "))
#expect(bundle.publicKeyOpenSSH.hasSuffix("test-comment"))
#expect(bundle.privateKeyPEM.contains("SCARF ED25519 PRIVATE KEY"))
// Round-trip: decode PEM back and verify lengths.
let parts = Ed25519KeyGenerator.decodeRawEd25519PEM(bundle.privateKeyPEM)
#expect(parts != nil)
#expect(parts?.privateKey.count == 32)
#expect(parts?.publicKey.count == 32)
// Display fingerprint has the right shape.
#expect(bundle.displayFingerprint.hasPrefix("ssh-ed25519 "))
#expect(bundle.displayFingerprint.contains(""))
}
@Test func ed25519PublicKeyLineIsDeterministic() {
// Pin the OpenSSH wire format wrong encoding would silently
// break every authorized_keys paste.
let fakePubKey = Data(repeating: 0xAB, count: 32)
let line = Ed25519KeyGenerator.makeOpenSSHPublicKeyLine(
publicKeyBytes: fakePubKey,
comment: "hello"
)
let parts = line.split(separator: " ")
#expect(parts.count == 3)
#expect(String(parts[0]) == "ssh-ed25519")
#expect(String(parts[2]) == "hello")
// Base64 blob decodes to:
// string("ssh-ed25519") + string(<32 bytes of 0xAB>)
// = 4 + 11 + 4 + 32 = 51 bytes
let b64 = String(parts[1])
let decoded = Data(base64Encoded: b64)
#expect(decoded?.count == 51)
}
@Test func decodeRejectsCorruptedPEM() {
#expect(Ed25519KeyGenerator.decodeRawEd25519PEM("garbage") == nil)
#expect(Ed25519KeyGenerator.decodeRawEd25519PEM("""
-----BEGIN SCARF ED25519 PRIVATE KEY-----
not-base64!!
-----END SCARF ED25519 PRIVATE KEY-----
""") == nil)
}
#endif
}
+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. - **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. - **`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 — shipped (on `claude/ios-m2-skeleton` branch, separate PR from M0+M1)
### M2 — pending
**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 ### M3 — pending
### M4 — pending ### M4 — pending
### M5 — pending ### M5 — pending
+93
View File
@@ -0,0 +1,93 @@
import SwiftUI
import ScarfCore
import ScarfIOS
/// App entry point. Renders a single `WindowGroup` whose root decides
/// between onboarding and the connected-app surface based on whether
/// a `IOSServerConfig` + `SSHKeyBundle` pair is already stored.
@main
struct ScarfIOSApp: App {
@State private var root = RootModel(
keyStore: KeychainSSHKeyStore(),
configStore: UserDefaultsIOSServerConfigStore()
)
var body: some Scene {
WindowGroup {
RootView(model: root)
.task { await root.load() }
}
}
}
/// Decides whether the user needs to onboard or can see the Dashboard.
@Observable
@MainActor
final class RootModel {
enum State {
case loading
case onboarding
case connected(IOSServerConfig, SSHKeyBundle)
}
private(set) var state: State = .loading
private let keyStore: any SSHKeyStore
private let configStore: any IOSServerConfigStore
init(keyStore: any SSHKeyStore, configStore: any IOSServerConfigStore) {
self.keyStore = keyStore
self.configStore = configStore
}
func load() async {
do {
let key = try await keyStore.load()
let cfg = try await configStore.load()
if let key, let cfg {
state = .connected(cfg, key)
} else {
state = .onboarding
}
} catch {
// Corrupted state re-onboard. Logging would go here.
state = .onboarding
}
}
/// Called from OnboardingView when the flow reaches `.connected`.
/// Re-reads the stores and flips the root state.
func onboardingFinished() async {
await load()
}
/// Called from Dashboard "Disconnect" to wipe state and restart onboarding.
func disconnect() async {
try? await keyStore.delete()
try? await configStore.delete()
state = .onboarding
}
}
struct RootView: View {
let model: RootModel
var body: some View {
switch model.state {
case .loading:
ProgressView("Loading…")
case .onboarding:
OnboardingRootView(onFinished: {
await model.onboardingFinished()
})
case .connected(let config, let key):
DashboardView(
config: config,
key: key,
onDisconnect: {
await model.disconnect()
}
)
}
}
}
@@ -0,0 +1,69 @@
import SwiftUI
import ScarfCore
import ScarfIOS
/// Placeholder dashboard for M2. Shows the connected server and a
/// "Disconnect" affordance that wipes the stored key + config and
/// returns the user to onboarding.
///
/// **M3 replaces this** with a real dashboard backed by
/// `HermesDataService` running over a Citadel-backed transport.
/// For now this view just proves the "connected" state is reachable.
struct DashboardView: View {
let config: IOSServerConfig
let key: SSHKeyBundle
let onDisconnect: @MainActor () async -> Void
@State private var isDisconnecting = false
var body: some View {
NavigationStack {
List {
Section("Connected to") {
LabeledContent("Display name", value: config.displayName)
LabeledContent("Host", value: config.host)
if let user = config.user {
LabeledContent("User", value: user)
}
if let port = config.port {
LabeledContent("Port", value: String(port))
}
}
Section("Device key") {
LabeledContent("Comment", value: key.comment)
LabeledContent("Fingerprint", value: key.displayFingerprint)
LabeledContent("Created", value: key.createdAt)
}
Section {
Button(role: .destructive) {
Task {
isDisconnecting = true
await onDisconnect()
}
} label: {
HStack {
Spacer()
if isDisconnecting {
ProgressView()
} else {
Text("Disconnect")
}
Spacer()
}
}
.disabled(isDisconnecting)
}
Section {
Text("Dashboard data comes in M3 — this view is M2's \"hello, you're connected\" placeholder.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.navigationTitle(config.displayName)
.navigationBarTitleDisplayMode(.large)
}
}
}
@@ -0,0 +1,289 @@
import SwiftUI
import ScarfCore
import ScarfIOS
/// Owns the `OnboardingViewModel` and renders the current step.
/// Each step gets its own small view; the view switch is driven by
/// `vm.step`.
struct OnboardingRootView: View {
let onFinished: @MainActor () async -> Void
@State private var vm: OnboardingViewModel = {
let tester = CitadelSSHService()
let service = tester // reuse the same instance for key generation
return OnboardingViewModel(
keyStore: KeychainSSHKeyStore(),
configStore: UserDefaultsIOSServerConfigStore(),
tester: tester,
keyGenerator: { try service.generateEd25519Key() }
)
}()
var body: some View {
NavigationStack {
Group {
switch vm.step {
case .serverDetails: ServerDetailsStep(vm: vm)
case .keySource: KeySourceStep(vm: vm)
case .generate: GenerateKeyStep(vm: vm)
case .importKey: ImportKeyStep(vm: vm)
case .showPublicKey: ShowPublicKeyStep(vm: vm)
case .testConnection: TestConnectionStep(vm: vm)
case .testFailed(let reason): TestFailedStep(vm: vm, reason: reason)
case .connected: ConnectedStep()
}
}
.navigationTitle("Connect to Hermes")
.navigationBarTitleDisplayMode(.inline)
}
.onChange(of: vm.step) { _, new in
if case .connected = new {
Task { await onFinished() }
}
}
}
}
// MARK: - Steps
private struct ServerDetailsStep: View {
let vm: OnboardingViewModel
var body: some View {
Form {
Section("Remote host") {
TextField("hostname or IP", text: Bindable(vm).host)
.textContentType(.URL)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
TextField("username (optional)", text: Bindable(vm).user)
.textContentType(.username)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
TextField("port (default 22)", text: Bindable(vm).portText)
.keyboardType(.numberPad)
TextField("nickname (optional)", text: Bindable(vm).displayName)
.autocorrectionDisabled()
}
Section {
Button {
vm.advanceFromServerDetails()
} label: {
HStack {
Spacer()
Text("Next")
.bold()
Spacer()
}
}
.disabled(!vm.serverDetailsValidation.canAdvance)
}
}
}
}
private struct KeySourceStep: View {
let vm: OnboardingViewModel
var body: some View {
VStack(spacing: 24) {
Text("SSH key")
.font(.title2)
.bold()
Text("Scarf authenticates to your Hermes host with an SSH key. You can generate a new one on this device, or import one you already use.")
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
.padding(.horizontal)
VStack(spacing: 12) {
Button {
vm.pickKeyChoice(.generate)
} label: {
Label("Generate a new key", systemImage: "key.fill")
.frame(maxWidth: .infinity)
.padding()
}
.buttonStyle(.borderedProminent)
Button {
vm.pickKeyChoice(.importExisting)
} label: {
Label("Import existing key", systemImage: "square.and.arrow.down")
.frame(maxWidth: .infinity)
.padding()
}
.buttonStyle(.bordered)
}
.padding()
Spacer()
}
.padding(.top)
}
}
private struct GenerateKeyStep: View {
let vm: OnboardingViewModel
var body: some View {
VStack(spacing: 16) {
ProgressView()
.scaleEffect(1.2)
Text("Generating Ed25519 keypair…")
.foregroundStyle(.secondary)
}
.task {
await vm.generateKey()
}
}
}
private struct ImportKeyStep: View {
let vm: OnboardingViewModel
@State private var publicKey: String = ""
var body: some View {
Form {
Section("Paste your private key") {
TextEditor(text: Bindable(vm).importPEM)
.font(.system(.caption, design: .monospaced))
.frame(minHeight: 160)
}
Section("Paste the matching public-key line") {
TextEditor(text: $publicKey)
.font(.system(.caption, design: .monospaced))
.frame(minHeight: 80)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
}
Section {
Button("Import") {
let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime]
_ = vm.importKey(
publicKey: publicKey,
deviceComment: "scarf-ios-imported",
iso8601Date: iso.string(from: Date())
)
}
.frame(maxWidth: .infinity, alignment: .center)
}
}
}
}
private struct ShowPublicKeyStep: View {
let vm: OnboardingViewModel
@State private var copied = false
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Text("Add this public key to the remote")
.font(.title3)
.bold()
Text("Append the line below to `~/.ssh/authorized_keys` on the Hermes host. Once added, tap **I've added this key** to test the connection.")
.font(.callout)
.foregroundStyle(.secondary)
if let bundle = vm.keyBundle {
Text(OnboardingLogic.authorizedKeysLine(for: bundle))
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 10))
Button(copied ? "Copied" : "Copy") {
UIPasteboard.general.string =
OnboardingLogic.authorizedKeysLine(for: bundle)
copied = true
Task {
try? await Task.sleep(nanoseconds: 2_000_000_000)
copied = false
}
}
.buttonStyle(.bordered)
}
Spacer()
Button {
Task { await vm.confirmPublicKeyAdded() }
} label: {
HStack {
Spacer()
Text("I've added this key")
.bold()
Spacer()
}
.padding(.vertical, 8)
}
.buttonStyle(.borderedProminent)
.disabled(vm.isWorking)
}
.padding()
}
}
}
private struct TestConnectionStep: View {
let vm: OnboardingViewModel
var body: some View {
VStack(spacing: 16) {
ProgressView()
.scaleEffect(1.2)
Text("Testing connection to \(vm.host)")
.foregroundStyle(.secondary)
}
}
}
private struct TestFailedStep: View {
let vm: OnboardingViewModel
let reason: String
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Label("Connection failed", systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
.font(.title3)
.bold()
Text(reason)
.font(.callout)
HStack {
Button("Back") {
vm.goBackToServerDetails()
}
.buttonStyle(.bordered)
Button("Retry") {
Task { await vm.runConnectionTest() }
}
.buttonStyle(.borderedProminent)
}
}
.padding()
}
}
}
private struct ConnectedStep: View {
var body: some View {
VStack(spacing: 16) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 64))
.foregroundStyle(.green)
Text("Connected")
.font(.title2)
.bold()
Text("Loading dashboard…")
.foregroundStyle(.secondary)
}
}
}
+154
View File
@@ -0,0 +1,154 @@
# scarf-ios — Xcode target setup
This folder contains the source tree for the iOS app (`scarf-ios`), but
**not** the Xcode project file. Creating the `.xcodeproj` is a one-time
step you do in Xcode's UI — it's about 5 minutes, and doing it by hand
produces a project file that's definitely correct for whichever Xcode
version you're running, rather than a hand-edited pbxproj that might
drift from Xcode's expectations.
Everything the app needs — SSH key generation, Keychain storage,
onboarding state machine, Citadel-backed connection testing — already
lives in the shared SPM packages (`Packages/ScarfCore`,
`Packages/ScarfIOS`) and is exercised by 88 passing unit tests on
Linux CI. The Xcode target is mostly just a wrapper that assembles
those packages behind a `@main` SwiftUI app.
## One-time: create the Xcode target
1. Open **Xcode** → **File → New → Project…**
2. Choose **iOS → App**. Click Next.
3. Fill in:
- **Product Name**: `scarf-ios`
- **Team**: `3Q6X2L86C4` (the same team the Mac app uses for notarization)
- **Organization Identifier**: `com.scarf`
- **Interface**: **SwiftUI**
- **Language**: **Swift**
- **Storage**: **None** (no Core Data, no CloudKit)
- **Include Tests**: unchecked (SPM covers them)
4. On the save-location sheet, navigate to `<repo>/scarf/` (the same
level as `scarf.xcodeproj`) and hit **Create**.
5. Xcode produces `<repo>/scarf/scarf-ios/scarf-ios.xcodeproj` and a
default source tree you'll immediately throw away.
## One-time: set project settings
In the **scarf-ios** project (target of the same name):
1. **General → Minimum Deployments → iPhone**: `iOS 18.0`.
2. **General → Supported Destinations**: keep **iPhone** only. Remove
iPad + Mac Catalyst + Vision.
3. **Info → Bundle Identifier**: `com.scarf.scarf-ios`.
4. **Signing & Capabilities → Team**: `3Q6X2L86C4` (same as Mac).
5. **Build Settings → Swift Language Version**: `Swift 5` (matches
the Mac app and both SPM packages).
## One-time: wire the SPM packages
1. **File → Add Package Dependencies…**
2. **Add Local…** button in the lower-left of the dialog.
3. Select `<repo>/scarf/Packages/ScarfCore`. Click **Add Package**.
4. Target to attach it to: **scarf-ios**. Click **Add Package**.
5. Repeat steps 14 for `<repo>/scarf/Packages/ScarfIOS`.
- `ScarfIOS` already declares `Citadel` as a dependency — Xcode
will resolve it automatically when you add the local package.
- On first resolution expect a ~30s wait while Citadel +
SwiftNIO-SSH fetch from GitHub.
## One-time: replace the default source tree
1. In Xcode's Project Navigator, delete the auto-generated files
Xcode created for you:
- `scarf_iosApp.swift` (or `scarf-iosApp.swift`)
- `ContentView.swift`
- `Assets.xcassets` (keep this one — we'll reuse)
2. In Finder, open `<repo>/scarf/scarf-ios/`. Drag the **App/**,
**Onboarding/**, and **Dashboard/** folders onto the `scarf-ios`
target in Xcode's navigator.
- In the import sheet: **Create groups**, **Add to target:
scarf-ios**.
3. Build (`⌘B`). It should compile cleanly. If Citadel's
authentication-method variant has changed since I wrote
`CitadelSSHService`, adjust `buildClientSettings(...)` — see the
FIXME comment in that file.
## One-time: app icon + accent color
The `Assets.xcassets` that Xcode scaffolded already has a blank
`AppIcon` and `AccentColor`. Drop your icon asset + pick an accent
color in the Inspector. Nothing else to configure.
## Info.plist additions for M2
None required. Citadel uses SwiftNIO, which doesn't need the
network-usage `Info.plist` key unless you hit local-network
discovery — which onboarding doesn't.
If you want to publish to TestFlight in M2, add these under
**Info → Custom iOS Target Properties**:
- `LSRequiresIPhoneOS = YES` (defaults to YES, usually already set)
- `UIApplicationSceneManifest → UIApplicationSupportsMultipleScenes = NO`
(single-window for iPhone)
- `UILaunchScreen` — empty dictionary is fine.
## Smoke test the target
1. Pick an iPhone simulator (any iPhone running iOS 18+) and hit ⌘R.
2. You should see the onboarding flow: **Remote host** form → **SSH
key** choice → **Generate****Show public key** → …
3. On **Show public key**, the OpenSSH line is selectable and
copy-able. The text renders as `ssh-ed25519 AAAA… scarf-iphone-XXXX`.
4. Tap **I've added this key**. Onboarding calls `CitadelSSHService`
to connect. With no real server, this will fail with
`.hostUnreachable` — that's expected. You should land on the
**Connection failed** screen with a "Retry" button.
5. Real end-to-end test: use a host you actually own, copy the shown
public key into its `~/.ssh/authorized_keys`, tap Retry. You
should reach the **Connected** state and then the placeholder
**Dashboard** with a Disconnect button.
## TestFlight (still M2 — optional)
1. **Product → Archive** (select a physical iPhone or "Any iOS
Device (arm64)" as the target).
2. **Window → Organizer → Archives → Distribute App → App Store
Connect → Upload**. Uses your existing team signing setup.
3. First upload will trigger App Store Connect to create the app
record if it doesn't exist. Give it `com.scarf.scarf-ios` and
the same team.
4. After processing, invite testers from App Store Connect.
## What's *not* in M2
- Dashboard data (sessions, messages, stats) — **M3** adds a
Citadel-backed `ServerTransport` so `HermesDataService` and friends
work over SSH from iOS.
- Chat — **M4** adds an `SSHExecACPChannel` (the iOS counterpart to
`ProcessACPChannel`) so `ACPClient` runs over a Citadel exec
session.
- Memory editing, Cron, Skills, Settings — **M5**.
- Polish, App Store submission — **M6**.
## Troubleshooting
**Citadel fails to resolve.** Delete derived data (`~/Library/Developer/Xcode/DerivedData/scarf-ios-*`)
and `File → Packages → Reset Package Caches`, then rebuild.
**`SSHAuthenticationMethod` has no member `ed25519`.** Citadel's
private-key auth has changed variant names between 0.7 and 0.9. See
`CitadelSSHService.buildClientSettings(...)` — there's one line to
update. Keep the protocol conformance intact.
**Keychain reads empty after relaunch.** Check that you haven't
accidentally set `kSecAttrAccessible` to a value that requires
biometric unlock — M2 uses `AfterFirstUnlockThisDeviceOnly` which
should always be readable.
**The shown public-key line doesn't match what OpenSSH generates
from the private key.** It won't — `scarf-ios` uses a compact
internal PEM shape for the private key (see `Ed25519KeyGenerator`
for the format). The **public** key is standard OpenSSH wire format
and is interop-safe with `authorized_keys`. If you want to export
the private key for use with `ssh`, that export flow is deferred
to a future phase.