Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,10 @@ jobs:
- plan
- host
runs-on: "ubuntu-22.04"
# npm trusts this repo's release.yml only when it runs in the npm-publish
# environment, which GitHub restricts to `main`. A dispatch from any
# other branch cannot get a publish token.
environment: npm-publish
permissions:
"id-token": "write"
"contents": "read"
Expand Down
93 changes: 39 additions & 54 deletions apps/ios/Sources/Litter/Models/AlleycatCredentialStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ final class AlleycatCredentialStore {
cacheLock.unlock()
if let cached { return cached }

// Match device-only and iCloud items: a pre-release build could move
// tokens to iCloud Keychain, and those must still be found.
let query = baseQuery(nodeId: nodeId).merging([
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny,
kSecReturnData as String: true,
Expand All @@ -53,6 +55,9 @@ final class AlleycatCredentialStore {
let token = String(data: data, encoding: .utf8), !token.isEmpty else {
throw AlleycatCredentialStoreError.decodingFailed
}
if (attributes[kSecAttrSynchronizable as String] as? Bool) == true {
moveToDeviceOnly(token, nodeId: nodeId)
}
cacheLock.lock()
tokenCache[key] = token
cacheLock.unlock()
Expand All @@ -64,24 +69,30 @@ final class AlleycatCredentialStore {
}
}

/// Saves the token device-only by default. Pass `synchronizable: true`
/// only for computers that sync across devices (Kittylitter hosts, which
/// authenticate by token alone): the token then goes to iCloud Keychain,
/// which is end-to-end encrypted, so the user's other devices can connect.
func saveToken(_ token: String, nodeId: String, synchronizable: Bool = false) throws {
/// Tokens are device-only: `AfterFirstUnlockThisDeviceOnly`, never
/// synchronizable. Adds the item, or updates it in place if it exists,
/// so a failed write never removes a working token.
func saveToken(_ token: String, nodeId: String) throws {
guard let data = token.data(using: .utf8) else {
throw AlleycatCredentialStoreError.encodingFailed
}

try deleteKeychainItems(nodeId: nodeId)
let attributes = baseQuery(nodeId: nodeId).merging([
kSecAttrSynchronizable as String: synchronizable,
kSecAttrAccessible as String: synchronizable
? kSecAttrAccessibleAfterFirstUnlock
: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
let query = baseQuery(nodeId: nodeId).merging([
kSecAttrSynchronizable as String: false
]) { _, new in new }
let attributes = query.merging([
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
kSecValueData as String: data
]) { _, new in new }
let status = SecItemAdd(attributes as CFDictionary, nil)

var status = SecItemAdd(attributes as CFDictionary, nil)
if status == errSecDuplicateItem {
let updates: [String: Any] = [
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
]
status = SecItemUpdate(query as CFDictionary, updates as CFDictionary)
}
guard status == errSecSuccess else {
throw AlleycatCredentialStoreError.keychain(status)
}
Expand All @@ -90,56 +101,30 @@ final class AlleycatCredentialStore {
cacheLock.unlock()
}

private var synchronizedNodeIds: Set<String> = []

/// Moves a device-only token (saved before iCloud sync) to iCloud
/// Keychain. Called for computers that sync, so a computer that appears
/// on another device also brings its credential. No-op once done.
func makeSynchronizable(nodeId: String) {
let key = normalizedNodeId(nodeId)
cacheLock.lock()
let done = synchronizedNodeIds.contains(key)
cacheLock.unlock()
guard !done else { return }

let query = baseQuery(nodeId: nodeId).merging([
kSecAttrSynchronizable as String: false,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
/// Moves a token that a pre-release build put in iCloud Keychain back to
/// device-only. Writes the device-only copy first and deletes the iCloud
/// copy only once that succeeded, so the token is never lost.
private func moveToDeviceOnly(_ token: String, nodeId: String) {
do {
try saveToken(token, nodeId: nodeId)
} catch {
LLog.error("alleycat", "moving token to device-only keychain failed", error: error)
return
}
let synced = baseQuery(nodeId: nodeId).merging([
kSecAttrSynchronizable as String: true
]) { _, new in new }
var item: CFTypeRef?
if SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
let data = item as? Data,
let token = String(data: data, encoding: .utf8), !token.isEmpty {
do {
try saveToken(token, nodeId: nodeId, synchronizable: true)
} catch {
LLog.error("alleycat", "moving token to iCloud Keychain failed", error: error)
return
}
let status = SecItemDelete(synced as CFDictionary)
if status != errSecSuccess && status != errSecItemNotFound {
LLog.error("alleycat", "deleting iCloud keychain token failed", error: AlleycatCredentialStoreError.keychain(status))
}
cacheLock.lock()
synchronizedNodeIds.insert(key)
cacheLock.unlock()
}

/// Drop cached tokens so the next read sees iCloud Keychain changes made
/// on another device (for example a re-pair with a new token).
func clearCache() {
cacheLock.lock()
tokenCache.removeAll()
cacheLock.unlock()
}

func deleteToken(nodeId: String) throws {
cacheLock.lock()
tokenCache[normalizedNodeId(nodeId)] = nil
cacheLock.unlock()
try deleteKeychainItems(nodeId: nodeId)
}

/// Deletes both the device-only and the iCloud copy.
private func deleteKeychainItems(nodeId: String) throws {
// Both the device-only item and any iCloud copy.
let query = baseQuery(nodeId: nodeId).merging([
kSecAttrSynchronizable as String: kSecAttrSynchronizableAny
]) { _, new in new }
Expand Down
4 changes: 1 addition & 3 deletions apps/ios/Sources/Litter/Models/CloudKVSBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,6 @@ final class CloudKVSBridge {
// initial sync.
store.synchronize()
applyEnvelopeFromStore()
SavedServerStore.syncWithCloud()
scheduleExport()
}

Expand Down Expand Up @@ -122,8 +121,7 @@ final class CloudKVSBridge {
)
applyWritebacks(writebacks)
lastAppliedEnvelopeHash = hash
SavedServerStore.syncWithCloud()
NotificationCenter.default.post(name: .litterThreadPreferencesDidChange, object: nil)
NotificationCenter.default.post(name: .litterThreadPreferencesDidChange, object: nil)
} catch {
LLog.warn(
"cloud_sync",
Expand Down
88 changes: 0 additions & 88 deletions apps/ios/Sources/Litter/Models/SavedServerStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,101 +17,13 @@ enum SavedServerStore {
private static var cachedServers: [SavedServer] = []

static func save(_ servers: [SavedServer]) {
persist(reconcileWithCloud(servers))
}

private static func persist(_ servers: [SavedServer]) {
guard let data = try? JSONEncoder().encode(servers) else { return }
UserDefaults.standard.set(data, forKey: savedServersKey)
cachedRaw = data
cachedServers = servers
NotificationCenter.default.post(name: .litterSavedServersDidChange, object: nil)
}

// MARK: - iCloud sync

/// Ledger shared with the user's other devices through `CloudKVSBridge`.
private static let syncedComputersKey = "litter.syncedComputers"
/// This device's copy of the ledger. Not synced.
private static let computerLedgerKey = "litter.syncedComputers.ledger"

/// Computers that any of the user's devices can reach with what syncs:
/// Kittylitter hosts (token-only auth; tokens sync via iCloud Keychain)
/// and direct Codex URLs. Local Studio grants are bound to one device's
/// key, SSH keys never leave the device, and ChatGPT-connected computers
/// need that device's own sign-in, so those stay local.
static func isCloudSyncable(_ server: SavedServer) -> Bool {
guard server.rememberedByUser, server.source != .local, server.source != .ssh else { return false }
guard !server.id.hasPrefix("slingshot-") else { return false }
guard server.preferredConnectionMode != .ssh, server.alleycatAgentWire != "ssh-bridge" else { return false }
if server.alleycatNodeId != nil {
return !server.id.hasPrefix("alleycat:local-studio:")
}
return server.alleycatHost == nil
}

/// Re-merge after another device changed the shared ledger.
static func syncWithCloud() {
AlleycatCredentialStore.shared.clearCache()
let current = load()
let merged = reconcileWithCloud(current)
if merged != current {
persist(merged)
}
}

/// Records this device's syncable computers in the ledger, merges in the
/// ledger from other devices, and returns the saved list the merge
/// implies. The merge policy lives in Rust (`cloud_sync::computers`).
private static func reconcileWithCloud(_ servers: [SavedServer]) -> [SavedServer] {
let defaults = UserDefaults.standard
let encoder = JSONEncoder()
encoder.outputFormatting = .sortedKeys
for server in servers where isCloudSyncable(server) {
if let nodeId = server.alleycatNodeId {
AlleycatCredentialStore.shared.makeSynchronizable(nodeId: nodeId)
}
}
let local = servers.filter(isCloudSyncable).compactMap { server -> SyncedComputer? in
guard let data = try? encoder.encode(server),
let json = String(data: data, encoding: .utf8) else { return nil }
return SyncedComputer(id: server.id, payloadJson: json)
}
let remote = defaults.string(forKey: syncedComputersKey)
let result = cloudSyncReconcileComputers(
ledgerJson: defaults.string(forKey: computerLedgerKey),
local: local,
remoteJson: remote,
nowMs: Int64(Date().timeIntervalSince1970 * 1000)
)
defaults.set(result.ledgerJson, forKey: computerLedgerKey)
if remote != result.ledgerJson {
// Observed by CloudKVSBridge, which pushes it to other devices.
defaults.set(result.ledgerJson, forKey: syncedComputersKey)
}

let decoder = JSONDecoder()
var synced: [String: SavedServer] = [:]
for computer in result.computers {
guard let data = computer.payloadJson.data(using: .utf8),
let server = try? decoder.decode(SavedServer.self, from: data) else { continue }
synced[computer.id] = server
}
// Keep this device's order; computers added elsewhere go last.
var merged: [SavedServer] = []
for server in servers {
if isCloudSyncable(server) {
if let updated = synced.removeValue(forKey: server.id) {
merged.append(updated)
}
} else {
merged.append(server)
}
}
merged.append(contentsOf: synced.values.sorted { $0.name < $1.name })
return merged
}

static func load() -> [SavedServer] {
guard let data = UserDefaults.standard.data(forKey: savedServersKey) else {
cachedRaw = nil
Expand Down
8 changes: 1 addition & 7 deletions apps/ios/Sources/Litter/Views/AlleycatAddServerSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -494,13 +494,7 @@ struct AlleycatAddServerSheet: View {
// saved-server record. Reconnect re-reads the token and surfaces
// a re-pair prompt if it is genuinely missing.
do {
// Kittylitter computers sync to the user's other devices;
// Local Studio grants are bound to this device's key.
try AlleycatCredentialStore.shared.saveToken(
params.token,
nodeId: params.nodeId,
synchronizable: pairingMode == .kittylitter
)
try AlleycatCredentialStore.shared.saveToken(params.token, nodeId: params.nodeId)
} catch {
LLog.error("alleycat", "keychain save failed after successful pair", error: error)
}
Expand Down
43 changes: 0 additions & 43 deletions apps/ios/Tests/LitterTests/SavedServerStoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,49 +24,6 @@ final class SavedServerStoreTests: XCTestCase {
)
}

func testOnlyKittylitterAndDirectURLComputersSyncAcrossDevices() {
XCTAssertTrue(SavedServerStore.isCloudSyncable(alleycatServer(id: "alleycat:node-a", nodeId: "node-a")))
XCTAssertTrue(SavedServerStore.isCloudSyncable(server(id: "manual-ws", websocketURL: "wss://box.example:8390")))

// Local Studio grants are bound to one device's key.
XCTAssertFalse(SavedServerStore.isCloudSyncable(
alleycatServer(id: "alleycat:local-studio:node-a", nodeId: "node-a")
))
// SSH keys never leave the device.
XCTAssertFalse(SavedServerStore.isCloudSyncable(server(id: "ssh-box", source: .ssh)))
XCTAssertFalse(SavedServerStore.isCloudSyncable(server(id: "manual-ssh-box:22", mode: .ssh)))
// ChatGPT-connected computers need that device's own sign-in.
XCTAssertFalse(SavedServerStore.isCloudSyncable(server(id: "slingshot-env", websocketURL: "wss://chatgpt.com/x")))
// This device, and computers the user never chose to keep.
XCTAssertFalse(SavedServerStore.isCloudSyncable(server(id: "local", source: .local)))
XCTAssertFalse(SavedServerStore.isCloudSyncable(server(id: "manual-ws", remembered: false)))
}

private func server(
id: String,
source: ServerSource = .manual,
mode: PreferredConnectionMode? = .directCodex,
websocketURL: String? = nil,
remembered: Bool = true
) -> SavedServer {
SavedServer(
id: id,
name: id,
hostname: "box.example",
port: 8390,
codexPorts: [],
sshPort: nil,
source: source,
hasCodexServer: true,
wakeMAC: nil,
preferredConnectionMode: mode,
preferredCodexPort: nil,
sshPortForwardingEnabled: nil,
websocketURL: websocketURL,
rememberedByUser: remembered
)
}

private func alleycatServer(id: String, nodeId: String) -> SavedServer {
SavedServer(
id: id,
Expand Down
Loading
Loading