From bf50e7927f721fdcccc7a67261260b47b4bf9903 Mon Sep 17 00:00:00 2001 From: David Date: Fri, 25 Sep 2026 11:36:57 -0400 Subject: [PATCH 1/2] feat: link enrolled controllers to a site --- apps/ios/Origin89.xcodeproj/project.pbxproj | 4 + apps/ios/Origin89/AccountView.swift | 11 + apps/ios/Origin89/SetupView.swift | 5 +- apps/ios/Origin89/SitesView.swift | 216 ++++++++++++++++++ .../SetupBench/Sources/SetupBench/main.swift | 5 + .../SetupCore/RustControllerClient.swift | 11 + .../SetupKit/AccountEnrolmentStore.swift | 5 + .../SetupKit/Sources/SetupKit/Contract.swift | 37 +++ .../SetupKit/Sources/SetupKit/SetupFlow.swift | 6 + .../ios/SetupKit/Sources/SetupKit/Sites.swift | 119 ++++++++++ .../Tests/SetupKitTests/GenerationTests.swift | 55 +++++ .../SetupKitTests/KeptEnrolmentTests.swift | 2 + .../Tests/SetupKitTests/SitesTests.swift | 189 +++++++++++++++ crates/setup/src/code.rs | 2 +- crates/setup/src/engine.rs | 32 ++- crates/setup/src/lib.rs | 11 + crates/setup/tests/exchange.rs | 50 +++- 17 files changed, 753 insertions(+), 7 deletions(-) create mode 100644 apps/ios/Origin89/SitesView.swift create mode 100644 apps/ios/SetupKit/Sources/SetupKit/Sites.swift create mode 100644 apps/ios/SetupKit/Tests/SetupKitTests/GenerationTests.swift create mode 100644 apps/ios/SetupKit/Tests/SetupKitTests/SitesTests.swift diff --git a/apps/ios/Origin89.xcodeproj/project.pbxproj b/apps/ios/Origin89.xcodeproj/project.pbxproj index 10e6230..cd22860 100644 --- a/apps/ios/Origin89.xcodeproj/project.pbxproj +++ b/apps/ios/Origin89.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ AB8900000000000000000009 /* SetupKit in Frameworks */ = {isa = PBXBuildFile; productRef = AB8900000000000000000007 /* SetupKit */; }; AB890000000000000000000A /* SetupCore in Frameworks */ = {isa = PBXBuildFile; productRef = AB8900000000000000000008 /* SetupCore */; }; AB8900000000000000000015 /* AccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB8900000000000000000014 /* AccountView.swift */; }; + AB8900000000000000000031 /* SitesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB8900000000000000000030 /* SitesView.swift */; }; AB890000000000000000000C /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB890000000000000000000B /* SetupView.swift */; }; AB890000000000000000000E /* NetworkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB890000000000000000000D /* NetworkView.swift */; }; AB8900000000000000000010 /* CodeScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB890000000000000000000F /* CodeScannerView.swift */; }; @@ -21,6 +22,7 @@ /* Begin PBXFileReference section */ AB8900000000000000000014 /* AccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountView.swift; sourceTree = ""; }; + AB8900000000000000000030 /* SitesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SitesView.swift; sourceTree = ""; }; AB8900000000000000000016 /* Account.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Account.xcconfig; sourceTree = ""; }; 291B0A8141E0F76A304D6029 /* Signing.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Signing.xcconfig; sourceTree = ""; }; 8E94264721F66A5291BC0C5B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -69,6 +71,7 @@ isa = PBXGroup; children = ( AB8900000000000000000014 /* AccountView.swift */, + AB8900000000000000000030 /* SitesView.swift */, 8E94264721F66A5291BC0C5B /* Assets.xcassets */, AB890000000000000000000F /* CodeScannerView.swift */, AB890000000000000000000D /* NetworkView.swift */, @@ -161,6 +164,7 @@ files = ( D725D6BB1B4CC521C45F1B0E /* Origin89App.swift in Sources */, AB8900000000000000000015 /* AccountView.swift in Sources */, + AB8900000000000000000031 /* SitesView.swift in Sources */, AB890000000000000000000C /* SetupView.swift in Sources */, AB890000000000000000000E /* NetworkView.swift in Sources */, AB8900000000000000000010 /* CodeScannerView.swift in Sources */, diff --git a/apps/ios/Origin89/AccountView.swift b/apps/ios/Origin89/AccountView.swift index 74ef7ce..558d670 100644 --- a/apps/ios/Origin89/AccountView.swift +++ b/apps/ios/Origin89/AccountView.swift @@ -10,6 +10,8 @@ struct AccountView: View { /// Nil in a build without a cloud. let cloud: CloudClient? let pairings: any AccountPairingStore + /// The generations of the pairings this account sees on this phone. + let generations: () -> [ControllerGeneration] @State private var failure: String? @State private var confirmingSignOut = false @@ -90,6 +92,15 @@ struct AccountView: View { } header: { Text("Signed in") } + if let cloud { + Section { + NavigationLink("Sites") { + SitesView(cloud: cloud, generations: generations, authenticate: authenticate) + } + } footer: { + Text("Link the controllers paired with this phone to a site in your account.") + } + } Section { Button("Sign out", role: .destructive) { confirmingSignOut = true } .disabled(deleting) diff --git a/apps/ios/Origin89/SetupView.swift b/apps/ios/Origin89/SetupView.swift index 6e8d37f..1561742 100644 --- a/apps/ios/Origin89/SetupView.swift +++ b/apps/ios/Origin89/SetupView.swift @@ -1,4 +1,5 @@ import Origin89UI +import SetupCore import SetupKit import SwiftUI @@ -59,7 +60,9 @@ struct SetupView: View { } .tint(Color.origin89.action) .sheet(isPresented: $accountShown) { - AccountView(account: account, cloud: cloud, pairings: pairings) + AccountView( + account: account, cloud: cloud, pairings: pairings, + generations: { flow.keptGenerations(reader: RustGenerationReader()) }) } .confirmationDialog( confirming == .all ? "Forget all controllers?" : "Forget this controller?", diff --git a/apps/ios/Origin89/SitesView.swift b/apps/ios/Origin89/SitesView.swift new file mode 100644 index 0000000..cfc74c8 --- /dev/null +++ b/apps/ios/Origin89/SitesView.swift @@ -0,0 +1,216 @@ +import Origin89UI +import SetupKit +import SwiftUI + +/// The signed-in user's sites, and the controllers this phone is paired with +/// that can be linked to one. A link names a controller generation for the +/// account; it grants no access to the controller, which pairing alone does. +struct SitesView: View { + let cloud: CloudClient + /// The generations of the pairings this account sees on this phone. + let generations: () -> [ControllerGeneration] + /// Presents AuthKit when the cloud wants a fresh sign-in. + let authenticate: (URL, String) async throws(AccountError) -> URL + + @State private var sites: [Site]? + @State private var paired: [ControllerGeneration] = [] + @State private var failure: CloudError? + @State private var busy = false + @State private var newSiteName = "" + @State private var creatingSite = false + @State private var linking: ControllerGeneration? + + var body: some View { + Form { + if let failure { + Section { Origin89Notice(failure.message, tone: .alarm) } + } + if let sites { + controllers(sites) + siteList(sites) + } else { + Section { ProgressView() } + } + } + .navigationTitle("Sites") + .navigationBarTitleDisplayMode(.inline) + .disabled(busy) + .task { await reload() } + .refreshable { await reload() } + .alert("New site", isPresented: $creatingSite) { + TextField("Name", text: $newSiteName) + Button("Create") { Task { await createSite() } } + .disabled(DisplayName(newSiteName) == nil) + Button("Cancel", role: .cancel) {} + } + .sheet(item: $linking) { generation in + LinkControllerView( + generation: generation, + sites: (sites ?? []).filter { $0.role == .owner }, + link: { name, site in await link(generation, named: name, to: site) }) + } + } + + @ViewBuilder private func controllers(_ sites: [Site]) -> some View { + Section { + if paired.isEmpty { + Text("No controller is paired with this phone yet.").foregroundStyle(.secondary) + } + ForEach(paired, id: \.self) { generation in + let site = sites.first { $0.links(generation) } + HStack { + VStack(alignment: .leading, spacing: 2) { + Text( + site?.controllers.first { $0.generation == generation }?.name + ?? generation.defaultName) + Text(site.map { "Linked to \($0.name)" } ?? "Not linked") + .font(.footnote).foregroundStyle(.secondary) + } + Spacer() + if site == nil { + Button("Link") { linking = generation } + .disabled(!sites.contains { $0.role == .owner }) + } + } + } + } header: { + Text("Paired with this phone") + } footer: { + Text( + "Linking sends only the controller's ID and reset count to your account, never its setup code or this phone's key." + ) + } + } + + @ViewBuilder private func siteList(_ sites: [Site]) -> some View { + Section { + ForEach(sites) { site in + VStack(alignment: .leading, spacing: 2) { + Text(site.name) + Text(Self.summary(of: site)).font(.footnote).foregroundStyle(.secondary) + } + } + Button("New site") { + newSiteName = "" + creatingSite = true + } + } header: { + Text("Sites") + } + } + + private static func summary(of site: Site) -> String { + let role = site.role == .owner ? "Owner" : "Admin" + return switch site.controllers.count { + case 0: "\(role) · no controllers" + case 1: "\(role) · 1 controller" + case let count: "\(role) · \(count) controllers" + } + } + + private func reload() async { + paired = generations() + do { + sites = try await cloud.sites() + failure = nil + } catch { + failure = error + } + } + + private func createSite() async { + guard let name = DisplayName(newSiteName) else { return } + busy = true + defer { busy = false } + do { + _ = try await cloud.createSite(named: name) + await reload() + } catch { + failure = error + } + } + + /// Nil on success; otherwise the failure, which the link sheet shows. + private func link( + _ generation: ControllerGeneration, named name: DisplayName, to site: Site.ID + ) async -> CloudError? { + do { + _ = try await cloud.link(generation, named: name, to: site, authenticate: authenticate) + } catch { + return error + } + await reload() + return nil + } +} + +/// Choose a site the user owns and a name, then link. +private struct LinkControllerView: View { + let generation: ControllerGeneration + let sites: [Site] + let link: (DisplayName, Site.ID) async -> CloudError? + + @State private var name: String + @State private var site: Site.ID? + @State private var failure: CloudError? + @State private var linking = false + @Environment(\.dismiss) private var dismiss + + init( + generation: ControllerGeneration, sites: [Site], + link: @escaping (DisplayName, Site.ID) async -> CloudError? + ) { + self.generation = generation + self.sites = sites + self.link = link + _name = State(initialValue: generation.defaultName) + _site = State(initialValue: sites.count == 1 ? sites.first?.id : nil) + } + + var body: some View { + NavigationStack { + Form { + Section("Name") { TextField("Name", text: $name) } + Section("Site") { + Picker("Site", selection: $site) { + Text("Choose a site").tag(Site.ID?.none) + ForEach(sites) { Text($0.name).tag(Site.ID?.some($0.id)) } + } + } + if let failure { + Section { Origin89Notice(failure.message, tone: .alarm) } + } + } + .navigationTitle("Link controller") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } + ToolbarItem(placement: .confirmationAction) { + if linking { + ProgressView() + } else { + Button("Link") { Task { await submit() } } + .disabled(site == nil || DisplayName(name) == nil) + } + } + } + .disabled(linking) + } + } + + private func submit() async { + guard let site, let name = DisplayName(name) else { return } + linking = true + defer { linking = false } + if let error = await link(name, site) { + failure = error == .account(.cancelled) ? nil : error + } else { + dismiss() + } + } +} + +extension ControllerGeneration { + /// A name until the person gives one: the start of the `device_id`. + fileprivate var defaultName: String { "Controller \(deviceID.prefix(8))" } +} diff --git a/apps/ios/SetupBench/Sources/SetupBench/main.swift b/apps/ios/SetupBench/Sources/SetupBench/main.swift index 516a472..396dcb0 100644 --- a/apps/ios/SetupBench/Sources/SetupBench/main.swift +++ b/apps/ios/SetupBench/Sources/SetupBench/main.swift @@ -95,6 +95,11 @@ struct FileEnrolmentStore: EnrolmentStore { func removeAll() throws { try removeIfPresent(BenchFiles.directory.appending(path: "enrolments")) } + func deviceIDs() -> [String] { + let directory = BenchFiles.directory.appending(path: "enrolments") + let names = (try? FileManager.default.contentsOfDirectory(atPath: directory.path)) ?? [] + return names.filter(isDeviceID).sorted() + } } func removeIfPresent(_ url: URL) throws { diff --git a/apps/ios/SetupCore/Sources/SetupCore/RustControllerClient.swift b/apps/ios/SetupCore/Sources/SetupCore/RustControllerClient.swift index d717ae4..00914c1 100644 --- a/apps/ios/SetupCore/Sources/SetupCore/RustControllerClient.swift +++ b/apps/ios/SetupCore/Sources/SetupCore/RustControllerClient.swift @@ -38,6 +38,17 @@ public struct RustControllerClientFactory: ControllerClientFactory { } } +/// Reads a kept enrolment's generation with the Rust core, which owns its +/// encoding. The core clears its copy of the bytes. +public struct RustGenerationReader: GenerationReader { + public init() {} + public func generation(of enrolment: Data) -> ControllerGeneration? { + Origin89SetupCore.keptGeneration(kept: enrolment).map { + ControllerGeneration(deviceID: $0.deviceId, epoch: $0.epoch) + } + } +} + /// One controller, one Rust `SetupSession`, driven over a ``FrameTransport``. actor RustControllerClient: ControllerClient { /// Frames that answer nothing outstanding (P-024) before a step gives up. diff --git a/apps/ios/SetupKit/Sources/SetupKit/AccountEnrolmentStore.swift b/apps/ios/SetupKit/Sources/SetupKit/AccountEnrolmentStore.swift index ccabf64..387f9b9 100644 --- a/apps/ios/SetupKit/Sources/SetupKit/AccountEnrolmentStore.swift +++ b/apps/ios/SetupKit/Sources/SetupKit/AccountEnrolmentStore.swift @@ -33,6 +33,11 @@ public struct AccountEnrolmentStore: EnrolmentStore { try Self.both({ try own.removeAll() }, { try signedOut.removeAll() }) } + /// Both stores' controllers, each once. + public func deviceIDs() -> [String] { + Set(own.deviceIDs()).union(signedOut.deviceIDs()).sorted() + } + private static func both(_ first: () throws -> Void, _ second: () throws -> Void) throws { var failure: (any Error)? do { try first() } catch { failure = error } diff --git a/apps/ios/SetupKit/Sources/SetupKit/Contract.swift b/apps/ios/SetupKit/Sources/SetupKit/Contract.swift index 5406109..5ff1c7b 100644 --- a/apps/ios/SetupKit/Sources/SetupKit/Contract.swift +++ b/apps/ios/SetupKit/Sources/SetupKit/Contract.swift @@ -114,6 +114,43 @@ public protocol EnrolmentStore: Sendable { func remove(deviceID: String) throws /// Remove every kept enrolment. func removeAll() throws + /// The `device_id` of every kept enrolment, sorted. + func deviceIDs() -> [String] +} + +/// The ownership generation a kept enrolment belongs to: its controller and +/// the `epoch` it was issued at. A factory reset raises the `epoch`. Neither +/// is secret: both are on the controller's `Discover` answer. +public struct ControllerGeneration: Sendable, Hashable, Identifiable { + public var id: Self { self } + public let deviceID: String + public let epoch: UInt32 + public init(deviceID: String, epoch: UInt32) { + self.deviceID = deviceID + self.epoch = epoch + } +} + +/// Reads the generation from an encoded enrolment. Implemented by the Rust +/// core, which owns the encoding. +public protocol GenerationReader: Sendable { + /// Nil for bytes that do not decode as an enrolment. + func generation(of enrolment: Data) -> ControllerGeneration? +} + +extension EnrolmentStore { + /// The generation of every kept enrolment, sorted by `device_id`. An entry + /// that cannot be read, or that names another controller than the one it is + /// kept under, is left out. + public func generations(reader: any GenerationReader) -> [ControllerGeneration] { + deviceIDs().compactMap { deviceID in + guard var kept = load(deviceID: deviceID) else { return nil } + defer { kept.resetBytes(in: kept.startIndex.. [ControllerGeneration] { + store.generations(reader: reader) + } + /// Forget the known controller: start over, then remove its kept /// enrolment and address. Its setup code then pairs as a new phone would. /// When the enrolment cannot be removed the error is thrown, and the flow diff --git a/apps/ios/SetupKit/Sources/SetupKit/Sites.swift b/apps/ios/SetupKit/Sources/SetupKit/Sites.swift new file mode 100644 index 0000000..4a0b926 --- /dev/null +++ b/apps/ios/SetupKit/Sources/SetupKit/Sites.swift @@ -0,0 +1,119 @@ +import Foundation + +/// A site's role for the signed-in user. Only an owner can link controllers. +public enum SiteRole: String, Sendable, Decodable { + case owner, admin +} + +/// One ownership generation linked to a site, as the cloud lists it. +public struct LinkedController: Sendable, Equatable, Decodable { + public let generation: ControllerGeneration + public let name: String + + public init(generation: ControllerGeneration, name: String) { + self.generation = generation + self.name = name + } + + private enum CodingKeys: String, CodingKey { case deviceId, epoch, name } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + generation = ControllerGeneration( + deviceID: try container.decode(String.self, forKey: .deviceId), + epoch: try container.decode(UInt32.self, forKey: .epoch)) + name = try container.decode(String.self, forKey: .name) + } +} + +public struct Site: Sendable, Equatable, Identifiable, Decodable { + /// A UUID, kept as the cloud wrote it because it goes back in request paths. + public let id: String + public let name: String + public let role: SiteRole + public let controllers: [LinkedController] + + public init(id: String, name: String, role: SiteRole, controllers: [LinkedController]) { + self.id = id + self.name = name + self.role = role + self.controllers = controllers + } + + private enum CodingKeys: String, CodingKey { case id, name, role, controllers } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let id = try container.decode(String.self, forKey: .id) + guard UUID(uuidString: id) != nil else { + throw DecodingError.dataCorruptedError( + forKey: .id, in: container, debugDescription: "a site ID is a UUID") + } + self.id = id + name = try container.decode(String.self, forKey: .name) + role = try container.decode(SiteRole.self, forKey: .role) + controllers = try container.decode([LinkedController].self, forKey: .controllers) + } + + /// Whether `generation` is linked here. + public func links(_ generation: ControllerGeneration) -> Bool { + controllers.contains { $0.generation == generation } + } +} + +/// A name for a site or a controller: 1 to 80 characters once trimmed, as the +/// cloud accepts. +public struct DisplayName: Sendable, Equatable { + public let rawValue: String + + public init?(_ text: String) { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard (1...80).contains(trimmed.count) else { return nil } + rawValue = trimmed + } +} + +extension CloudClient { + /// The signed-in user's sites, each with its linked controllers. + public func sites() async throws(CloudError) -> [Site] { + struct Sites: Decodable { let sites: [Site] } + return try await send(.get, "/v1/sites", as: Sites.self).sites + } + + /// A new site owned by the signed-in user. + public func createSite(named name: DisplayName) async throws(CloudError) -> Site { + struct Request: Encodable { let name: String } + return try await send(.post, "/v1/sites", body: Request(name: name.rawValue), as: Site.self) + } + + /// Link `generation` to `site`. Only its `device_id`, `epoch` and a display + /// name are sent: never the setup code or the enrolment key, and the link + /// grants no access to the controller. Linking the same generation to the + /// same site again answers the stored link. + /// + /// The cloud wants a sign-in from the last five minutes for this. A stale + /// one asks the person to sign in again through `authenticate`, then + /// retries once. + public func link( + _ generation: ControllerGeneration, named name: DisplayName, to site: Site.ID, + authenticate: (URL, String) async throws(AccountError) -> URL + ) async throws(CloudError) -> LinkedController { + struct Request: Encodable { + let deviceId: String + let epoch: UInt32 + let name: String + } + let path = "/v1/sites/\(site)/controllers" + let body = Request(deviceId: generation.deviceID, epoch: generation.epoch, name: name.rawValue) + do { + return try await send(.post, path, body: body, as: LinkedController.self) + } catch .reauthenticationRequired { + do { + try await account.reauthenticate(using: authenticate) + } catch { + throw .account(error) + } + return try await send(.post, path, body: body, as: LinkedController.self) + } + } +} diff --git a/apps/ios/SetupKit/Tests/SetupKitTests/GenerationTests.swift b/apps/ios/SetupKit/Tests/SetupKitTests/GenerationTests.swift new file mode 100644 index 0000000..38a4416 --- /dev/null +++ b/apps/ios/SetupKit/Tests/SetupKitTests/GenerationTests.swift @@ -0,0 +1,55 @@ +import Foundation +import Testing + +@testable import SetupKit + +/// Reads the first byte as the epoch and the rest as the `device_id`; a +/// zero epoch or no bytes do not decode. +struct ByteGenerationReader: GenerationReader { + func generation(of enrolment: Data) -> ControllerGeneration? { + guard let epoch = enrolment.first, epoch != 0 else { return nil } + return ControllerGeneration( + deviceID: String(decoding: enrolment.dropFirst(), as: UTF8.self), epoch: UInt32(epoch)) + } +} + +private func kept(_ deviceID: String, epoch: UInt8) -> Data { Data([epoch]) + Data(deviceID.utf8) } + +@Test func everyKeptEnrolmentNamesItsGeneration() { + let store = MemoryEnrolmentStore(["b": kept("b", epoch: 2), "a": kept("a", epoch: 5)]) + #expect( + store.generations(reader: ByteGenerationReader()) == [ + ControllerGeneration(deviceID: "a", epoch: 5), ControllerGeneration(deviceID: "b", epoch: 2), + ]) +} + +@Test func nothingKeptNamesNoGeneration() { + #expect(MemoryEnrolmentStore().generations(reader: ByteGenerationReader()).isEmpty) + #expect(NoEnrolmentStore().generations(reader: ByteGenerationReader()).isEmpty) +} + +@Test func unreadableOrMisfiledEnrolmentsAreLeftOut() { + let store = MemoryEnrolmentStore([ + "a": kept("a", epoch: 1), + "b": kept("b", epoch: 0), + "c": Data(), + "d": kept("e", epoch: 3), + ]) + #expect( + store.generations(reader: ByteGenerationReader()) == [ + ControllerGeneration(deviceID: "a", epoch: 1) + ]) +} + +@Test func anAccountListsItsOwnAndSignedOutControllersOnce() { + let own = MemoryEnrolmentStore(["a": kept("a", epoch: 4), "b": kept("b", epoch: 1)]) + let signedOut = MemoryEnrolmentStore(["a": kept("a", epoch: 2), "c": kept("c", epoch: 7)]) + let store = AccountEnrolmentStore(own: own, signedOut: signedOut) + #expect(store.deviceIDs() == ["a", "b", "c"]) + // The account's own enrolment wins, as it does when setup loads one. + #expect( + store.generations(reader: ByteGenerationReader()) == [ + ControllerGeneration(deviceID: "a", epoch: 4), ControllerGeneration(deviceID: "b", epoch: 1), + ControllerGeneration(deviceID: "c", epoch: 7), + ]) +} diff --git a/apps/ios/SetupKit/Tests/SetupKitTests/KeptEnrolmentTests.swift b/apps/ios/SetupKit/Tests/SetupKitTests/KeptEnrolmentTests.swift index ef4e655..2504815 100644 --- a/apps/ios/SetupKit/Tests/SetupKitTests/KeptEnrolmentTests.swift +++ b/apps/ios/SetupKit/Tests/SetupKitTests/KeptEnrolmentTests.swift @@ -9,6 +9,7 @@ struct NoEnrolmentStore: EnrolmentStore { func save(_ enrolment: Data, deviceID: String) throws {} func remove(deviceID: String) throws {} func removeAll() throws {} + func deviceIDs() -> [String] { [] } } /// Keeps entries in memory, or refuses every save or removal. @@ -38,6 +39,7 @@ final class MemoryEnrolmentStore: EnrolmentStore, @unchecked Sendable { if refusesRemovals { throw Refused() } lock.withLock { entries.removeAll() } } + func deviceIDs() -> [String] { lock.withLock { entries.keys.sorted() } } } private actor Transport: FrameTransport { diff --git a/apps/ios/SetupKit/Tests/SetupKitTests/SitesTests.swift b/apps/ios/SetupKit/Tests/SetupKitTests/SitesTests.swift new file mode 100644 index 0000000..0bdcd85 --- /dev/null +++ b/apps/ios/SetupKit/Tests/SetupKitTests/SitesTests.swift @@ -0,0 +1,189 @@ +import Foundation +import Testing + +@testable import SetupKit + +private let now = Date(timeIntervalSince1970: 1_800_000_000) +private let user = AccountUser(id: AccountID("user_01A"), email: "a@example.com") +private let siteID = "7c0e6f3a-1b2d-4c5e-8f90-a1b2c3d4e5f6" +private let deviceID = "343199df00112233445566778899aabb" +private let generation = ControllerGeneration(deviceID: deviceID, epoch: 5) + +private func token(_ tag: String) -> String { + let payload = #"{"exp":\#(Int(now.timeIntervalSince1970 + 300)),"t":"\#(tag)"}"# + return "e30.\(Base64URL.encode(Data(payload.utf8))).sig" +} + +private func failure(_ code: String) -> String { + #"{"error":{"code":"\#(code)","message":"From the cloud."}}"# +} + +private let linked = #""" + {"deviceId":"\#(deviceID)","epoch":5,"name":"Barn","linkedAt":"2026-09-25T15:00:00.000Z"} + """# + +private let fresh = #""" + {"user":{"id":"user_01A","email":"a@example.com"},"access_token":"\#(token("new"))", + "refresh_token":"r2"} + """# + +@MainActor private func cloud( + _ answers: [StubHTTP.Answer], auth: [StubHTTP.Answer] = [] +) throws -> (CloudClient, StubHTTP) { + let http = StubHTTP(answers) + let configuration = try #require(AuthKitConfiguration(clientID: "client_01TEST")) + let account = Account( + client: AuthKitClient(configuration: configuration, http: StubHTTP(auth)), + store: MemorySessionStore( + AccountSession(user: user, accessToken: token("old"), refreshToken: "r1")), + now: { now }) + let client = CloudClient( + configuration: try #require(CloudConfiguration(baseURL: "https://cloud.example.com")), + account: account, http: http) + return (client, http) +} + +/// Answers like AuthKit, echoing the request's `state`, and counts the sheets. +private final class Sheet: @unchecked Sendable { + private(set) var opened = 0 + var cancels = false + func authenticate(_ url: URL, _ scheme: String) throws(AccountError) -> URL { + opened += 1 + if cancels { throw .cancelled } + let state = + URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems? + .first { $0.name == "state" }?.value ?? "" + guard let callback = URL(string: "\(scheme)://auth/callback?code=c&state=\(state)") else { + throw .invalidResponse + } + return callback + } +} + +private func json(_ request: URLRequest?) -> NSDictionary? { + request?.httpBody.flatMap { try? JSONSerialization.jsonObject(with: $0) as? NSDictionary } +} + +private let name = DisplayName("Barn")! + +// MARK: Sites + +@Test @MainActor func sitesDecodeWithTheirLinkedGenerations() async throws { + let (cloud, http) = try cloud([ + .status( + 200, + #""" + {"sites":[{"id":"\#(siteID)","name":"Farm","role":"owner", + "createdAt":"2026-09-25T14:00:00.000Z","controllers":[\#(linked)]}]} + """#) + ]) + let sites = try await cloud.sites() + #expect( + sites == [ + Site( + id: siteID, name: "Farm", role: .owner, + controllers: [LinkedController(generation: generation, name: "Barn")]) + ]) + #expect(sites.first?.links(generation) == true) + #expect(sites.first?.links(ControllerGeneration(deviceID: deviceID, epoch: 6)) == false) + #expect(http.requests.first?.httpMethod == "GET") + #expect(http.requests.first?.url?.path() == "/v1/sites") +} + +@Test @MainActor func aSiteWithoutAUUIDOrAKnownRoleIsNotTheContract() async throws { + for site in [ + #"{"id":"../account","name":"F","role":"owner","createdAt":"x","controllers":[]}"#, + #"{"id":"\#(siteID)","name":"F","role":"viewer","createdAt":"x","controllers":[]}"#, + ] { + let (cloud, _) = try cloud([.status(200, #"{"sites":[\#(site)]}"#)]) + await #expect(throws: CloudError.invalidResponse) { try await cloud.sites() } + } +} + +@Test @MainActor func creatingASiteSendsItsTrimmedName() async throws { + let (cloud, http) = try cloud([ + .status( + 201, + #"{"id":"\#(siteID)","name":"Farm","role":"owner","createdAt":"x","controllers":[]}"#) + ]) + let site = try await cloud.createSite(named: try #require(DisplayName(" Farm \n"))) + #expect(site.id == siteID) + #expect(json(http.requests.first) == ["name": "Farm"]) +} + +@Test func aDisplayNameIsOneToEightyCharactersOnceTrimmed() { + #expect(DisplayName(" ")?.rawValue == nil) + #expect(DisplayName("")?.rawValue == nil) + #expect(DisplayName(String(repeating: "a", count: 80))?.rawValue.count == 80) + #expect(DisplayName(String(repeating: "a", count: 81)) == nil) + #expect(DisplayName(" é ")?.rawValue == "é") +} + +// MARK: Linking + +@Test(arguments: [201, 200]) +@MainActor func aLinkSendsOnlyTheGenerationAndName(status: Int) async throws { + // 201 links; 200 is the same site linking the same generation again. + let (cloud, http) = try cloud([.status(status, linked)]) + let sheet = Sheet() + let link = try await cloud.link( + generation, named: name, to: siteID, authenticate: sheet.authenticate) + #expect(link == LinkedController(generation: generation, name: "Barn")) + let request = try #require(http.requests.first) + #expect(request.httpMethod == "POST") + #expect(request.url?.path() == "/v1/sites/\(siteID)/controllers") + #expect(json(request) == ["deviceId": deviceID, "epoch": 5, "name": "Barn"]) + #expect(sheet.opened == 0) +} + +@Test(arguments: [ + (409, "generation_linked", CloudError.generationLinked), + (409, "stale_epoch", .staleEpoch), + (404, "not_found", .notFound), +]) +@MainActor func aRefusedLinkIsTold(status: Int, code: String, expected: CloudError) async throws { + let (cloud, http) = try cloud([.status(status, failure(code))]) + let sheet = Sheet() + await #expect(throws: expected) { + try await cloud.link(generation, named: name, to: siteID, authenticate: sheet.authenticate) + } + #expect(http.requests.count == 1) + #expect(sheet.opened == 0) +} + +@Test @MainActor func aStaleSignInSignsInAgainAndRetriesOnce() async throws { + let (cloud, http) = try cloud( + [.status(401, failure("reauthentication_required")), .status(201, linked)], + auth: [.status(200, fresh)]) + let sheet = Sheet() + _ = try await cloud.link(generation, named: name, to: siteID, authenticate: sheet.authenticate) + #expect(sheet.opened == 1) + #expect(http.requests.count == 2) + #expect( + http.requests.last?.value(forHTTPHeaderField: "Authorization") == "Bearer \(token("new"))") +} + +@Test @MainActor func aCancelledSignInDoesNotRetry() async throws { + let (cloud, http) = try cloud([.status(401, failure("reauthentication_required"))]) + let sheet = Sheet() + sheet.cancels = true + await #expect(throws: CloudError.account(.cancelled)) { + try await cloud.link(generation, named: name, to: siteID, authenticate: sheet.authenticate) + } + #expect(http.requests.count == 1) +} + +@Test @MainActor func aSecondStaleAnswerIsNotRetriedAgain() async throws { + let (cloud, http) = try cloud( + [ + .status(401, failure("reauthentication_required")), + .status(401, failure("reauthentication_required")), + ], + auth: [.status(200, fresh)]) + let sheet = Sheet() + await #expect(throws: CloudError.reauthenticationRequired) { + try await cloud.link(generation, named: name, to: siteID, authenticate: sheet.authenticate) + } + #expect(sheet.opened == 1) + #expect(http.requests.count == 2) +} diff --git a/crates/setup/src/code.rs b/crates/setup/src/code.rs index 55990c0..5cbefd0 100644 --- a/crates/setup/src/code.rs +++ b/crates/setup/src/code.rs @@ -9,7 +9,7 @@ use zeroize::Zeroizing; /// The exact length of a P-049 payload: `4 + 1 + 1 + 1 + 32 + 1 + 64`. pub const SETUP_CODE_LEN: usize = 104; -const DEVICE_ID_BYTES: usize = 16; +pub(crate) const DEVICE_ID_BYTES: usize = 16; const SECRET_BYTES: usize = 32; const PREFIX: &[u8] = b"km43"; diff --git a/crates/setup/src/engine.rs b/crates/setup/src/engine.rs index c583e9d..1cca1dc 100644 --- a/crates/setup/src/engine.rs +++ b/crates/setup/src/engine.rs @@ -28,7 +28,7 @@ use km43::{ }; use zeroize::{Zeroize, Zeroizing}; -use crate::code::{ControllerId, SetupCode}; +use crate::code::{ControllerId, DEVICE_ID_BYTES, SetupCode}; use crate::wifi::{NetworkScan, WifiStatus}; const NONCE_BYTES: usize = 16; @@ -124,6 +124,36 @@ pub struct ControllerSummary { pub enrolled: bool, } +/// The ownership generation a kept enrolment belongs to: the controller and +/// the `epoch` it was issued at. A factory reset raises the `epoch`, so the +/// pair names one owner's generation of that controller. Neither field is +/// secret; both are on the controller's `Discover` answer. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct KeptGeneration { + /// The `device_id`, 32 lowercase hexadecimal characters. + pub device_id: String, + /// The `epoch`, never zero. + pub epoch: u32, +} + +impl KeptGeneration { + /// Read the generation from an enrolment [`Engine::kept_enrolment`] + /// encoded. `None` for bytes km43 does not decode as a stored enrolment. + #[must_use] + pub fn read(kept: &[u8]) -> Option { + // km43 validates the encoding but gives out neither field, so they are + // read from its documented layout: format byte, `device_id`, `epoch`. + drop(StoredEnrolment::decode(kept).ok()?); + let device_id: [u8; DEVICE_ID_BYTES] = kept.get(1..=DEVICE_ID_BYTES)?.try_into().ok()?; + let epoch_at = 1 + DEVICE_ID_BYTES; + let epoch: [u8; 4] = kept.get(epoch_at..epoch_at + 4)?.try_into().ok()?; + Some(Self { + device_id: ControllerId::from(device_id).to_string(), + epoch: u32::from_be_bytes(epoch), + }) + } +} + /// The enrolment `Pair` produced. #[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Record)] pub struct PairedClient { diff --git a/crates/setup/src/lib.rs b/crates/setup/src/lib.rs index 8e32fb4..2226ce7 100644 --- a/crates/setup/src/lib.rs +++ b/crates/setup/src/lib.rs @@ -78,6 +78,17 @@ pub fn resume_session( }) } +/// The controller and `epoch` an enrolment kept by an earlier launch was +/// issued for, for linking that generation to a site. `None` when the bytes do +/// not decode. The bytes are cleared either way; nothing secret is returned. +#[uniffi::export] +#[must_use] +pub fn kept_generation(mut kept: Vec) -> Option { + let generation = KeptGeneration::read(&kept); + kept.zeroize(); + generation +} + #[uniffi::export] impl SetupSession { /// Start a session from a scanned or pasted setup code, enrolling as diff --git a/crates/setup/tests/exchange.rs b/crates/setup/tests/exchange.rs index 36db968..e213f0e 100644 --- a/crates/setup/tests/exchange.rs +++ b/crates/setup/tests/exchange.rs @@ -16,10 +16,10 @@ use km43::{ SignedClaim, StateSeq, Tagged, Time, TimeAck, Topology, Version, Wrapper, }; use origin89_setup::{ - ControllerId, Engine, ErrorNote, FrameNote, HeardNetwork, JoinFailure, NetworkBand, - NetworkChange, NetworkScan, NetworkSecurity, NetworkSettings, NonceSource, RadioState, - RadioStatus, ScanProgress, ScanRefusal, SetupCode, SetupFailure, SetupSession, WifiStatus, - resume_session, + ControllerId, Engine, ErrorNote, FrameNote, HeardNetwork, JoinFailure, KeptGeneration, + NetworkBand, NetworkChange, NetworkScan, NetworkSecurity, NetworkSettings, NonceSource, + RadioState, RadioStatus, ScanProgress, ScanRefusal, SetupCode, SetupFailure, SetupSession, + WifiStatus, kept_generation, resume_session, }; use serde_json::Value; @@ -1746,6 +1746,48 @@ fn a_kept_enrolment_for_another_epoch_pairs_again() { } } +#[test] +fn a_kept_enrolment_names_its_generation() { + let mut controller = Controller::new(); + let kept = kept_after_pairing(&mut controller); + let expected = KeptGeneration { + device_id: hex::encode(controller.device_id), + epoch: 1, + }; + assert_eq!(KeptGeneration::read(&kept), Some(expected.clone())); + assert_eq!(kept_generation(kept), Some(expected)); +} + +#[test] +fn a_generation_follows_the_epoch_paired_at() { + let mut controller = Controller::new(); + controller.epoch = Epoch::new(u32::MAX).unwrap(); + let kept = kept_after_pairing(&mut controller); + let generation = KeptGeneration::read(&kept).expect("decodes"); + assert_eq!(generation.epoch, u32::MAX); + assert_eq!( + generation.device_id, + ControllerId::from(controller.device_id).to_string() + ); +} + +#[test] +fn undecodable_bytes_name_no_generation() { + let mut controller = Controller::new(); + let kept = kept_after_pairing(&mut controller); + let mut unknown_format = kept.clone(); + unknown_format[0] = 2; + let mut zero_epoch = kept.clone(); + zero_epoch[17..21].copy_from_slice(&[0; 4]); + let cut = &kept[..kept.len() - 1]; + let mut longer = kept.clone(); + longer.push(0); + for bytes in [&[][..], cut, &longer, &unknown_format, &zero_epoch] { + assert_eq!(KeptGeneration::read(bytes), None, "{} bytes", bytes.len()); + } + assert_eq!(kept_generation(zero_epoch), None); +} + /// The controller answers a kept key it will not take with an `Error`; the /// engine drops the key and pairs with the setup code on the next link. #[test] From d1ad7b5f17526acf778033efaefbb4e00857b46b Mon Sep 17 00:00:00 2001 From: David Date: Fri, 25 Sep 2026 12:16:16 -0400 Subject: [PATCH 2/2] fix: report unreadable pairings and keep links on one account --- apps/ios/Origin89/AccountView.swift | 2 +- apps/ios/Origin89/SetupView.swift | 2 +- apps/ios/Origin89/SitesView.swift | 14 ++++++--- .../SetupBench/Sources/SetupBench/main.swift | 9 ++++-- .../SetupKit/AccountEnrolmentStore.swift | 4 +-- .../SetupKit/Sources/SetupKit/Contract.swift | 12 ++++--- .../SetupKit/Sources/SetupKit/SetupFlow.swift | 4 +-- .../ios/SetupKit/Sources/SetupKit/Sites.swift | 5 +++ .../Tests/SetupKitTests/GenerationTests.swift | 31 +++++++++++++------ .../SetupKitTests/KeptEnrolmentTests.swift | 14 +++++++-- .../Tests/SetupKitTests/SitesTests.swift | 28 +++++++++++++++-- 11 files changed, 92 insertions(+), 33 deletions(-) diff --git a/apps/ios/Origin89/AccountView.swift b/apps/ios/Origin89/AccountView.swift index 558d670..1a28b4c 100644 --- a/apps/ios/Origin89/AccountView.swift +++ b/apps/ios/Origin89/AccountView.swift @@ -11,7 +11,7 @@ struct AccountView: View { let cloud: CloudClient? let pairings: any AccountPairingStore /// The generations of the pairings this account sees on this phone. - let generations: () -> [ControllerGeneration] + let generations: () throws -> [ControllerGeneration] @State private var failure: String? @State private var confirmingSignOut = false diff --git a/apps/ios/Origin89/SetupView.swift b/apps/ios/Origin89/SetupView.swift index 1561742..53f5b30 100644 --- a/apps/ios/Origin89/SetupView.swift +++ b/apps/ios/Origin89/SetupView.swift @@ -62,7 +62,7 @@ struct SetupView: View { .sheet(isPresented: $accountShown) { AccountView( account: account, cloud: cloud, pairings: pairings, - generations: { flow.keptGenerations(reader: RustGenerationReader()) }) + generations: { try flow.keptGenerations(reader: RustGenerationReader()) }) } .confirmationDialog( confirming == .all ? "Forget all controllers?" : "Forget this controller?", diff --git a/apps/ios/Origin89/SitesView.swift b/apps/ios/Origin89/SitesView.swift index cfc74c8..6b0ae72 100644 --- a/apps/ios/Origin89/SitesView.swift +++ b/apps/ios/Origin89/SitesView.swift @@ -8,12 +8,13 @@ import SwiftUI struct SitesView: View { let cloud: CloudClient /// The generations of the pairings this account sees on this phone. - let generations: () -> [ControllerGeneration] + let generations: () throws -> [ControllerGeneration] /// Presents AuthKit when the cloud wants a fresh sign-in. let authenticate: (URL, String) async throws(AccountError) -> URL @State private var sites: [Site]? - @State private var paired: [ControllerGeneration] = [] + /// Nil when this phone's pairings could not be read. + @State private var paired: [ControllerGeneration]? = [] @State private var failure: CloudError? @State private var busy = false @State private var newSiteName = "" @@ -53,10 +54,13 @@ struct SitesView: View { @ViewBuilder private func controllers(_ sites: [Site]) -> some View { Section { - if paired.isEmpty { + if paired == nil { + Origin89Notice( + "This phone's pairings could not be read. Unlock the phone and try again.", tone: .alarm) + } else if paired?.isEmpty == true { Text("No controller is paired with this phone yet.").foregroundStyle(.secondary) } - ForEach(paired, id: \.self) { generation in + ForEach(paired ?? [], id: \.self) { generation in let site = sites.first { $0.links(generation) } HStack { VStack(alignment: .leading, spacing: 2) { @@ -109,7 +113,7 @@ struct SitesView: View { } private func reload() async { - paired = generations() + paired = try? generations() do { sites = try await cloud.sites() failure = nil diff --git a/apps/ios/SetupBench/Sources/SetupBench/main.swift b/apps/ios/SetupBench/Sources/SetupBench/main.swift index 396dcb0..c7d0dc1 100644 --- a/apps/ios/SetupBench/Sources/SetupBench/main.swift +++ b/apps/ios/SetupBench/Sources/SetupBench/main.swift @@ -95,9 +95,14 @@ struct FileEnrolmentStore: EnrolmentStore { func removeAll() throws { try removeIfPresent(BenchFiles.directory.appending(path: "enrolments")) } - func deviceIDs() -> [String] { + func storedDeviceIDs() throws -> [String] { let directory = BenchFiles.directory.appending(path: "enrolments") - let names = (try? FileManager.default.contentsOfDirectory(atPath: directory.path)) ?? [] + let names: [String] + do { + names = try FileManager.default.contentsOfDirectory(atPath: directory.path) + } catch CocoaError.fileReadNoSuchFile { + return [] + } return names.filter(isDeviceID).sorted() } } diff --git a/apps/ios/SetupKit/Sources/SetupKit/AccountEnrolmentStore.swift b/apps/ios/SetupKit/Sources/SetupKit/AccountEnrolmentStore.swift index 387f9b9..75d684c 100644 --- a/apps/ios/SetupKit/Sources/SetupKit/AccountEnrolmentStore.swift +++ b/apps/ios/SetupKit/Sources/SetupKit/AccountEnrolmentStore.swift @@ -34,8 +34,8 @@ public struct AccountEnrolmentStore: EnrolmentStore { } /// Both stores' controllers, each once. - public func deviceIDs() -> [String] { - Set(own.deviceIDs()).union(signedOut.deviceIDs()).sorted() + public func storedDeviceIDs() throws -> [String] { + Set(try own.storedDeviceIDs()).union(try signedOut.storedDeviceIDs()).sorted() } private static func both(_ first: () throws -> Void, _ second: () throws -> Void) throws { diff --git a/apps/ios/SetupKit/Sources/SetupKit/Contract.swift b/apps/ios/SetupKit/Sources/SetupKit/Contract.swift index 5ff1c7b..558e21b 100644 --- a/apps/ios/SetupKit/Sources/SetupKit/Contract.swift +++ b/apps/ios/SetupKit/Sources/SetupKit/Contract.swift @@ -114,8 +114,10 @@ public protocol EnrolmentStore: Sendable { func remove(deviceID: String) throws /// Remove every kept enrolment. func removeAll() throws - /// The `device_id` of every kept enrolment, sorted. - func deviceIDs() -> [String] + /// The `device_id` of every kept enrolment, sorted. Throws when the + /// storage cannot be read, as a locked phone's Keychain cannot, rather than + /// answering none. + func storedDeviceIDs() throws -> [String] } /// The ownership generation a kept enrolment belongs to: its controller and @@ -141,9 +143,9 @@ public protocol GenerationReader: Sendable { extension EnrolmentStore { /// The generation of every kept enrolment, sorted by `device_id`. An entry /// that cannot be read, or that names another controller than the one it is - /// kept under, is left out. - public func generations(reader: any GenerationReader) -> [ControllerGeneration] { - deviceIDs().compactMap { deviceID in + /// kept under, is left out. Throws when the list itself cannot be read. + public func generations(reader: any GenerationReader) throws -> [ControllerGeneration] { + try storedDeviceIDs().compactMap { deviceID in guard var kept = load(deviceID: deviceID) else { return nil } defer { kept.resetBytes(in: kept.startIndex.. [ControllerGeneration] { - store.generations(reader: reader) + public func keptGenerations(reader: any GenerationReader) throws -> [ControllerGeneration] { + try store.generations(reader: reader) } /// Forget the known controller: start over, then remove its kept diff --git a/apps/ios/SetupKit/Sources/SetupKit/Sites.swift b/apps/ios/SetupKit/Sources/SetupKit/Sites.swift index 4a0b926..25e3fd5 100644 --- a/apps/ios/SetupKit/Sources/SetupKit/Sites.swift +++ b/apps/ios/SetupKit/Sources/SetupKit/Sites.swift @@ -105,14 +105,19 @@ extension CloudClient { } let path = "/v1/sites/\(site)/controllers" let body = Request(deviceId: generation.deviceID, epoch: generation.epoch, name: name.rawValue) + // The generation comes from this account's pairings: never link it for + // another account that signed in meanwhile. + guard let owner = account.owner else { throw .account(.signedOut) } do { return try await send(.post, path, body: body, as: LinkedController.self) } catch .reauthenticationRequired { + guard account.owner == owner else { throw .account(.differentAccount) } do { try await account.reauthenticate(using: authenticate) } catch { throw .account(error) } + guard account.owner == owner else { throw .account(.differentAccount) } return try await send(.post, path, body: body, as: LinkedController.self) } } diff --git a/apps/ios/SetupKit/Tests/SetupKitTests/GenerationTests.swift b/apps/ios/SetupKit/Tests/SetupKitTests/GenerationTests.swift index 38a4416..1899ffd 100644 --- a/apps/ios/SetupKit/Tests/SetupKitTests/GenerationTests.swift +++ b/apps/ios/SetupKit/Tests/SetupKitTests/GenerationTests.swift @@ -15,20 +15,20 @@ struct ByteGenerationReader: GenerationReader { private func kept(_ deviceID: String, epoch: UInt8) -> Data { Data([epoch]) + Data(deviceID.utf8) } -@Test func everyKeptEnrolmentNamesItsGeneration() { +@Test func everyKeptEnrolmentNamesItsGeneration() throws { let store = MemoryEnrolmentStore(["b": kept("b", epoch: 2), "a": kept("a", epoch: 5)]) #expect( - store.generations(reader: ByteGenerationReader()) == [ + try store.generations(reader: ByteGenerationReader()) == [ ControllerGeneration(deviceID: "a", epoch: 5), ControllerGeneration(deviceID: "b", epoch: 2), ]) } -@Test func nothingKeptNamesNoGeneration() { - #expect(MemoryEnrolmentStore().generations(reader: ByteGenerationReader()).isEmpty) - #expect(NoEnrolmentStore().generations(reader: ByteGenerationReader()).isEmpty) +@Test func nothingKeptNamesNoGeneration() throws { + #expect(try MemoryEnrolmentStore().generations(reader: ByteGenerationReader()).isEmpty) + #expect(try NoEnrolmentStore().generations(reader: ByteGenerationReader()).isEmpty) } -@Test func unreadableOrMisfiledEnrolmentsAreLeftOut() { +@Test func unreadableOrMisfiledEnrolmentsAreLeftOut() throws { let store = MemoryEnrolmentStore([ "a": kept("a", epoch: 1), "b": kept("b", epoch: 0), @@ -36,20 +36,31 @@ private func kept(_ deviceID: String, epoch: UInt8) -> Data { Data([epoch]) + Da "d": kept("e", epoch: 3), ]) #expect( - store.generations(reader: ByteGenerationReader()) == [ + try store.generations(reader: ByteGenerationReader()) == [ ControllerGeneration(deviceID: "a", epoch: 1) ]) } -@Test func anAccountListsItsOwnAndSignedOutControllersOnce() { +@Test func anAccountListsItsOwnAndSignedOutControllersOnce() throws { let own = MemoryEnrolmentStore(["a": kept("a", epoch: 4), "b": kept("b", epoch: 1)]) let signedOut = MemoryEnrolmentStore(["a": kept("a", epoch: 2), "c": kept("c", epoch: 7)]) let store = AccountEnrolmentStore(own: own, signedOut: signedOut) - #expect(store.deviceIDs() == ["a", "b", "c"]) + #expect(try store.storedDeviceIDs() == ["a", "b", "c"]) // The account's own enrolment wins, as it does when setup loads one. #expect( - store.generations(reader: ByteGenerationReader()) == [ + try store.generations(reader: ByteGenerationReader()) == [ ControllerGeneration(deviceID: "a", epoch: 4), ControllerGeneration(deviceID: "b", epoch: 1), ControllerGeneration(deviceID: "c", epoch: 7), ]) } + +@Test func anUnreadableListIsAnErrorNotNothingKept() { + let locked = MemoryEnrolmentStore(["a": kept("a", epoch: 1)], refusesReads: true) + #expect(throws: MemoryEnrolmentStore.Refused.self) { + try locked.generations(reader: ByteGenerationReader()) + } + let store = AccountEnrolmentStore(own: MemoryEnrolmentStore(), signedOut: locked) + #expect(throws: MemoryEnrolmentStore.Refused.self) { + try store.generations(reader: ByteGenerationReader()) + } +} diff --git a/apps/ios/SetupKit/Tests/SetupKitTests/KeptEnrolmentTests.swift b/apps/ios/SetupKit/Tests/SetupKitTests/KeptEnrolmentTests.swift index 2504815..156ebae 100644 --- a/apps/ios/SetupKit/Tests/SetupKitTests/KeptEnrolmentTests.swift +++ b/apps/ios/SetupKit/Tests/SetupKitTests/KeptEnrolmentTests.swift @@ -9,7 +9,7 @@ struct NoEnrolmentStore: EnrolmentStore { func save(_ enrolment: Data, deviceID: String) throws {} func remove(deviceID: String) throws {} func removeAll() throws {} - func deviceIDs() -> [String] { [] } + func storedDeviceIDs() throws -> [String] { [] } } /// Keeps entries in memory, or refuses every save or removal. @@ -19,8 +19,13 @@ final class MemoryEnrolmentStore: EnrolmentStore, @unchecked Sendable { private var entries: [String: Data] private let refusesSaves: Bool private let refusesRemovals: Bool - init(_ entries: [String: Data] = [:], refusesSaves: Bool = false, refusesRemovals: Bool = false) { + private let refusesReads: Bool + init( + _ entries: [String: Data] = [:], refusesSaves: Bool = false, refusesRemovals: Bool = false, + refusesReads: Bool = false + ) { self.entries = entries + self.refusesReads = refusesReads self.refusesSaves = refusesSaves self.refusesRemovals = refusesRemovals } @@ -39,7 +44,10 @@ final class MemoryEnrolmentStore: EnrolmentStore, @unchecked Sendable { if refusesRemovals { throw Refused() } lock.withLock { entries.removeAll() } } - func deviceIDs() -> [String] { lock.withLock { entries.keys.sorted() } } + func storedDeviceIDs() throws -> [String] { + if refusesReads { throw Refused() } + return lock.withLock { entries.keys.sorted() } + } } private actor Transport: FrameTransport { diff --git a/apps/ios/SetupKit/Tests/SetupKitTests/SitesTests.swift b/apps/ios/SetupKit/Tests/SetupKitTests/SitesTests.swift index 0bdcd85..d341737 100644 --- a/apps/ios/SetupKit/Tests/SetupKitTests/SitesTests.swift +++ b/apps/ios/SetupKit/Tests/SetupKitTests/SitesTests.swift @@ -28,14 +28,14 @@ private let fresh = #""" """# @MainActor private func cloud( - _ answers: [StubHTTP.Answer], auth: [StubHTTP.Answer] = [] + _ answers: [StubHTTP.Answer], auth: [StubHTTP.Answer] = [], signedIn: AccountUser = user ) throws -> (CloudClient, StubHTTP) { let http = StubHTTP(answers) let configuration = try #require(AuthKitConfiguration(clientID: "client_01TEST")) let account = Account( client: AuthKitClient(configuration: configuration, http: StubHTTP(auth)), store: MemorySessionStore( - AccountSession(user: user, accessToken: token("old"), refreshToken: "r1")), + AccountSession(user: signedIn, accessToken: token("old"), refreshToken: "r1")), now: { now }) let client = CloudClient( configuration: try #require(CloudConfiguration(baseURL: "https://cloud.example.com")), @@ -187,3 +187,27 @@ private let name = DisplayName("Barn")! #expect(sheet.opened == 1) #expect(http.requests.count == 2) } + +@Test @MainActor func aSignInAsAnotherAccountDoesNotLink() async throws { + // The sheet signs in someone else: the pairing belongs to the first account. + let other = #""" + {"user":{"id":"user_01B","email":"b@example.com"},"access_token":"\#(token("b"))", + "refresh_token":"r2"} + """# + let (cloud, http) = try cloud( + [.status(401, failure("reauthentication_required"))], auth: [.status(200, other)]) + await #expect(throws: CloudError.account(.differentAccount)) { + try await cloud.link(generation, named: name, to: siteID, authenticate: Sheet().authenticate) + } + #expect(http.requests.count == 1) + #expect(cloud.account.owner == user.id) +} + +@Test @MainActor func aSignedOutAccountLinksNothing() async throws { + let (cloud, http) = try cloud([]) + try cloud.account.signOut() + await #expect(throws: CloudError.account(.signedOut)) { + try await cloud.link(generation, named: name, to: siteID, authenticate: Sheet().authenticate) + } + #expect(http.requests.isEmpty) +}