mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
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:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.7–0.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.7–0.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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 1–4 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.
|
||||||
Reference in New Issue
Block a user