diff --git a/appleApp/FTW.xcodeproj/project.pbxproj b/appleApp/FTW.xcodeproj/project.pbxproj index 4bfaf37..7230353 100644 --- a/appleApp/FTW.xcodeproj/project.pbxproj +++ b/appleApp/FTW.xcodeproj/project.pbxproj @@ -772,6 +772,7 @@ INFOPLIST_FILE = "FTW-Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = FTW; INFOPLIST_KEY_NSCameraUsageDescription = "FTW uses the camera to read the pairing code from your box."; + INFOPLIST_KEY_NSFaceIDUsageDescription = "FTW uses Face ID when you let an assistant or Sourceful support reach your home."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -803,6 +804,7 @@ INFOPLIST_FILE = "FTW-Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = FTW; INFOPLIST_KEY_NSCameraUsageDescription = "FTW uses the camera to read the pairing code from your box."; + INFOPLIST_KEY_NSFaceIDUsageDescription = "FTW uses Face ID when you let an assistant or Sourceful support reach your home."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; diff --git a/appleApp/FTW/Access/AccessSettings.swift b/appleApp/FTW/Access/AccessSettings.swift new file mode 100644 index 0000000..0d9db5f --- /dev/null +++ b/appleApp/FTW/Access/AccessSettings.swift @@ -0,0 +1,34 @@ +import FTWKit +import SwiftUI + +/// The Access rows in Settings: who may reach the home, with what waits +/// for the owner, and Get help. Only an owner whose box takes agents sees +/// them. +struct AccessSettingsSection: View { + let app: AppModel + let access: AccessModel + + var body: some View { + if access.available { + Section(AccessWords.text("Access")) { + NavigationLink { AccessView(app: app, access: access) } label: { + LabeledContent { + if !access.pending.isEmpty { + Text(AccessWords.format("%@ waiting", String(access.pending.count))) + .foregroundStyle(Theme.ink) + .contentTransition(.numericText()) + } + } label: { + Label(AccessWords.text("Assistants and support"), systemImage: "person.badge.key") + } + } + .accessibilityIdentifier("settings.access") + NavigationLink { GetHelpView(app: app, access: access) } label: { + Label(AccessWords.text("Get help"), systemImage: "lifepreserver") + } + .accessibilityIdentifier("settings.help") + } + .task { await access.refresh() } + } + } +} diff --git a/appleApp/FTW/Access/AccessView.swift b/appleApp/FTW/Access/AccessView.swift new file mode 100644 index 0000000..3f8e820 --- /dev/null +++ b/appleApp/FTW/Access/AccessView.swift @@ -0,0 +1,157 @@ +import FTWKit +import SwiftUI + +/// Who may reach this home other than its phones and watches: the +/// assistants and Sourceful support the owner let in, what they ask for, +/// and what they did. The box decides; this is what it said, and the +/// owner's word back. +struct AccessView: View { + let app: AppModel + let access: AccessModel + + var body: some View { + List { + if !access.pending.isEmpty { + Section(AccessWords.text("Waiting for you")) { + ForEach(access.pending) { req in + NavigationLink { RequestView(access: access, request: req) } label: { RequestLine(access: access, request: req) } + .accessibilityIdentifier("access.request.\(req.reqId)") + } + } + } + + Section { + ForEach(access.assistants) { row in + NavigationLink { GrantView(app: app, access: access, id: row.id) } label: { GrantLine(access: access, row: row) } + .swipeActions { remove(row) } + .accessibilityIdentifier("access.grant.\(row.id)") + } + if access.list != nil, access.assistants.isEmpty { + Text(AccessWords.text("No assistant can reach your home.")) + .foregroundStyle(Theme.ink2) + } + NavigationLink { + OfferFlowView(app: app, flow: OfferFlow(link: nil, lookup: app.offerLookup, keys: app.offerKeys)) + } label: { + Label(AccessWords.text("Add an assistant"), systemImage: "plus") + } + .accessibilityIdentifier("access.add") + } header: { + Text(AccessWords.text("Assistants")) + } footer: { + Text(AccessWords.text("Assistants such as Claude or ChatGPT reach your home through FTW Cloud, with what you let them. Your box checks every call.")) + } + + Section(AccessWords.text("Sourceful support")) { + ForEach(access.support) { row in + NavigationLink { GrantView(app: app, access: access, id: row.id) } label: { GrantLine(access: access, row: row) } + .swipeActions { remove(row) } + } + NavigationLink { GetHelpView(app: app, access: access) } label: { + Label(AccessWords.text("Get help"), systemImage: "lifepreserver") + } + .accessibilityIdentifier("access.help") + } + + Section { + NavigationLink { AuditView(access: access) } label: { + Label(AccessWords.text("Activity"), systemImage: "list.bullet.rectangle") + } + .accessibilityIdentifier("access.activity") + } footer: { + KeyFooter(access: access) + } + } + .redacted(reason: access.list == nil ? .placeholder : []) + .scrollContentBackground(.hidden) + .background(Theme.surface) + .navigationTitle(AccessWords.text("Access")) + .navigationBarTitleDisplayMode(.inline) + .animation(Theme.spring, value: access.list) + .task { await access.refresh() } + .refreshable { await access.refresh() } + } + + /// Removing is one tap: no Face ID and no question, since it is always + /// safe. + private func remove(_ row: AgentRow) -> some View { + Button(role: .destructive) { + Task { await access.revoke(row) } + } label: { + Label(AccessWords.text("Remove access"), systemImage: "xmark.circle") + } + } +} + +/// One grant in the list: its name, where it lives, what it may do, when it +/// ends and when it last called. +struct GrantLine: View { + let access: AccessModel + let row: AgentRow + + var body: some View { + let now = Date().timeIntervalSince1970 * 1000 + VStack(alignment: .leading, spacing: 3) { + HStack(alignment: .firstTextBaseline) { + Text(AccessWords.name(row)).font(.body.weight(.semibold)).foregroundStyle(Theme.ink) + Spacer(minLength: 8) + if access.working == row.id { ProgressView().controlSize(.small) } + } + let place = access.place(row) + if !place.isEmpty { + Text(place) + .font(.subheadline) + .foregroundStyle(Theme.ink2) + } + Text("\(AccessWords.summary(row.scopes)) · \(AccessWords.until(row.expiresAtMs, nowMs: now)) · \(AccessWords.lastUsed(row.lastSeenMs, nowMs: now))") + .font(.footnote) + .foregroundStyle(Theme.ink2) + } + .padding(.vertical, 2) + .accessibilityElement(children: .combine) + } +} + +/// A request in the list: who asks for what, and until when it waits. +struct RequestLine: View { + let access: AccessModel + let request: AgentRequest + + var body: some View { + let now = Date().timeIntervalSince1970 * 1000 + HStack(alignment: .center, spacing: 12) { + Image(systemName: "hand.raised.fill") + .foregroundStyle(Theme.ink) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 3) { + Text(AccessWords.format("%@ asks to", access.agentName(request.agent))) + .font(.subheadline) + .foregroundStyle(Theme.ink2) + Text(AccessWords.op(request.op)).font(.body.weight(.semibold)).foregroundStyle(Theme.ink) + Text(AccessWords.format("Waits %@", AppWords.day(Double(request.expiresAtMs), nowMs: now, zone: .current))) + .font(.footnote) + .foregroundStyle(Theme.ink2) + } + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + } +} + +/// How this phone approves: Face ID, a key yet to be made, or a key Face ID +/// voided. +struct KeyFooter: View { + let access: AccessModel + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + // Only the box's own list says whether it holds this phone's key. + if access.keysKnown { + Text(AccessWords.format(access.boxHasKey ? "Approvals on this phone use %@." : "This phone makes its approval key the first time you approve, under the same %@.", AccessWords.biometry)) + } + if let help = access.help { + Text(help).foregroundStyle(Theme.alarm) + } + } + } +} diff --git a/appleApp/FTW/Access/ApprovalKeychain.swift b/appleApp/FTW/Access/ApprovalKeychain.swift new file mode 100644 index 0000000..58f7176 --- /dev/null +++ b/appleApp/FTW/Access/ApprovalKeychain.swift @@ -0,0 +1,204 @@ +import CryptoKit +import FTWKit +import LocalAuthentication +import Security + +/// This phone's approval keys (docs/agents.md in srcfl/ftw, The approval +/// signature): one P-256 key per home in the Secure Enclave, usable only +/// after Face ID with the current enrolment. The key never leaves the +/// enclave; the Keychain keeps the enclave's wrapped handle to it, which +/// no other device can use. +/// +/// One Face ID covers one approval: the context it evaluated signs every +/// message of it, so a first grant that also registers the key, or a +/// signature sent again with the box's clock, asks once. The context is +/// invalidated when the approval ends. +/// +/// A replacement waits in a second slot until the box is known to hold it, +/// so neither key is stranded by an answer that never came. +/// +/// The simulator has no Secure Enclave. There the key is a software key in +/// the Keychain, behind the same Face ID, which the simulator's Features +/// menu can match: enough to try the flow, never to ship. +@MainActor +final class ApprovalKeychain: ApprovalKeyring { + private let store: SecureStore + + init(store: SecureStore) { + self.store = store + } + + func unlock(home: String, reason: String) async throws -> ApprovalUnlock { + let context = LAContext() + context.localizedCancelTitle = AccessWords.text("Cancel") + var error: NSError? + if !context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) { + throw Self.meaning(error ?? NSError(domain: LAError.errorDomain, code: LAError.biometryNotAvailable.rawValue)) + } + do { + try await context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) + } catch { + throw Self.meaning(error) + } + return Unlock(store: store, home: home, context: context) + } + + func forget(home: String) { + store.remove(Self.account(home)) + store.remove(Self.pendingAccount(home)) + } + + static func account(_ home: String) -> String { "approval-\(home)" } + static func pendingAccount(_ home: String) -> String { "approval-next-\(home)" } + + /// What an error from Face ID, the Keychain or the enclave means for the + /// owner. Only a key the system no longer accepts is a lost key: a + /// cancel is a cancel, a lockout a lockout. + static func meaning(_ error: Error) -> Error { + let ns = error as NSError + if ns.domain == LAError.errorDomain { + switch LAError.Code(rawValue: ns.code) { + case .userCancel, .appCancel, .systemCancel, .userFallback, .notInteractive: return ApprovalCancelled() + case .biometryLockout: return ApprovalLocked() + case .biometryNotEnrolled, .biometryNotAvailable, .passcodeNotSet: return FaceIDMissing() + default: return ApprovalSigningFailed() + } + } + if ns.domain == NSOSStatusErrorDomain { + switch OSStatus(ns.code) { + case errSecUserCanceled: return ApprovalCancelled() + // The enclave no longer takes the key: Face ID was enrolled + // again, so the biometryCurrentSet key is void. + case errSecAuthFailed, errSecItemNotFound, errSecInvalidKeyRef: return ApprovalKeyLost() + default: return ApprovalSigningFailed() + } + } + return ApprovalSigningFailed() + } +} + +/// Approving from this phone needs Face ID (or Touch ID) set up. +struct FaceIDMissing: HelpfulError { + var help: String { AccessWords.format("Set up %@ on this phone to approve from it, or approve on your box's page.", AccessWords.biometry) } +} + +/// What this phone unlocks with, for the words and the symbol. +@MainActor +enum Biometry { + static let touchID: Bool = { + let context = LAContext() + _ = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil) + return context.biometryType == .touchID + }() + + static var symbol: String { touchID ? "touchid" : "faceid" } + + /// Once at launch, so every sentence names the right one. + static func name() { + AccessWords.biometry = touchID ? "Touch ID" : "Face ID" + } +} + +@MainActor +private final class Unlock: ApprovalUnlock { + let store: SecureStore + let home: String + let context: LAContext + + init(store: SecureStore, home: String, context: LAContext) { + self.store = store + self.home = home + self.context = context + } + + func existing() throws -> ApprovalSigner? { + try signer(ApprovalKeychain.account(home)) + } + + func pending() throws -> ApprovalSigner? { + try signer(ApprovalKeychain.pendingAccount(home)) + } + + private func signer(_ account: String) throws -> ApprovalSigner? { + guard let blob = try store.read(account) else { return nil } + #if targetEnvironment(simulator) + return try SoftwareSigner(raw: blob) + #else + do { + return EnclaveSigner(try SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: Data(blob), authenticationContext: context)) + } catch { + throw ApprovalKeychain.meaning(error) + } + #endif + } + + func make() throws -> ApprovalSigner { + #if targetEnvironment(simulator) + return SoftwareSigner() + #else + var error: Unmanaged? + guard let access = SecAccessControlCreateWithFlags( + nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, [.privateKeyUsage, .biometryCurrentSet], &error + ) else { + throw ApprovalSigningFailed() + } + do { + return EnclaveSigner(try SecureEnclave.P256.Signing.PrivateKey(accessControl: access, authenticationContext: context)) + } catch { + throw ApprovalKeychain.meaning(error) + } + #endif + } + + func keep(_ signer: ApprovalSigner) throws { + try store.write(ApprovalKeychain.account(home), blob(signer)) + } + + func keepPending(_ signer: ApprovalSigner) throws { + try store.write(ApprovalKeychain.pendingAccount(home), blob(signer)) + } + + func promote() throws { + guard let next = try store.read(ApprovalKeychain.pendingAccount(home)) else { return } + try store.write(ApprovalKeychain.account(home), next) + store.remove(ApprovalKeychain.pendingAccount(home)) + } + + func dropPending() { + store.remove(ApprovalKeychain.pendingAccount(home)) + } + + func end() { + context.invalidate() + } + + private func blob(_ signer: ApprovalSigner) throws -> Bytes { + #if targetEnvironment(simulator) + guard let s = signer as? SoftwareSigner else { throw ApprovalKeyNotKept() } + return s.rawKey + #else + guard let s = signer as? EnclaveSigner else { throw ApprovalKeyNotKept() } + return s.key.dataRepresentation.byteArray + #endif + } +} + +/// A Secure Enclave key, signing under the context Face ID evaluated. +@MainActor +private final class EnclaveSigner: ApprovalSigner { + let key: SecureEnclave.P256.Signing.PrivateKey + + init(_ key: SecureEnclave.P256.Signing.PrivateKey) { + self.key = key + } + + var publicKey: Bytes { key.publicKey.x963Representation.byteArray } + + func sign(_ message: Bytes) throws -> Bytes { + do { + return try key.signature(for: Data(message)).rawRepresentation.byteArray + } catch { + throw ApprovalKeychain.meaning(error) + } + } +} diff --git a/appleApp/FTW/Access/AuditView.swift b/appleApp/FTW/Access/AuditView.swift new file mode 100644 index 0000000..80e5953 --- /dev/null +++ b/appleApp/FTW/Access/AuditView.swift @@ -0,0 +1,61 @@ +import FTWKit +import SwiftUI + +/// What assistants, support and owners did, newest first, as the box wrote +/// it. A support call names the person at Sourceful who made it, as FTW +/// Cloud named them: the box cannot check that, and the line says so. +struct AuditView: View { + let access: AccessModel + + var body: some View { + List { + ForEach(access.audit) { row in + AuditLine(access: access, row: row) + } + if access.audit.isEmpty { + Text(AccessWords.text("Nothing has happened yet.")) + .foregroundStyle(Theme.ink2) + } + if access.auditMore { + Button(AccessWords.text("Load older")) { Task { await access.loadAudit(more: true) } } + .accessibilityIdentifier("audit.more") + } + } + .scrollContentBackground(.hidden) + .background(Theme.surface) + .navigationTitle(AccessWords.text("Activity")) + .navigationBarTitleDisplayMode(.inline) + .task { await access.loadAudit() } + .refreshable { await access.loadAudit() } + } +} + +struct AuditLine: View { + let access: AccessModel + let row: AuditRow + + var body: some View { + let now = Date().timeIntervalSince1970 * 1000 + VStack(alignment: .leading, spacing: 3) { + HStack(alignment: .firstTextBaseline) { + Text(AccessWords.actor(row, me: access.signedFor.deviceID, agents: access.list?.agents ?? [])) + .font(.subheadline.weight(.semibold)) + Spacer(minLength: 8) + Text(AppWords.day(Double(row.atMs), nowMs: now, zone: .current)) + .font(Theme.mono(.caption)) + .foregroundStyle(Theme.ink2) + } + Text(AccessWords.what(row)) + if let by = AccessWords.by(row) { + Label(by, systemImage: "person") + .font(.footnote) + .foregroundStyle(Theme.ink2) + } + if let result = AccessWords.result(row) { + Text(result).font(.footnote).foregroundStyle(row.result == "declined" ? Theme.ink2 : Theme.alarm) + } + } + .padding(.vertical, 2) + .accessibilityElement(children: .combine) + } +} diff --git a/appleApp/FTW/Access/GetHelpView.swift b/appleApp/FTW/Access/GetHelpView.swift new file mode 100644 index 0000000..7c562e0 --- /dev/null +++ b/appleApp/FTW/Access/GetHelpView.swift @@ -0,0 +1,99 @@ +import FTWKit +import SwiftUI + +/// Get help: the owner says what is wrong, and Sourceful's help desk opens +/// a ticket and answers with Sourceful's signed offer for it. The owner +/// then sees that offer as it will be signed, the same page an assistant's +/// gets: what Sourceful may read, control as its own switch, off, and for +/// how long. One Face ID grants it, tied to the ticket; it ends when the +/// ticket closes, when its time runs out or when the owner removes it. +/// +/// There is no help desk behind the app yet. A release build says so and +/// offers nothing to press; the simulated home has a desk of its own. +struct GetHelpView: View { + let app: AppModel + let access: AccessModel + @State private var problem = "" + @State private var flow: OfferFlow? + @State private var reviewing = false + @State private var asking = false + @State private var help: String? + @FocusState private var writing: Bool + + var body: some View { + let desk = app.desk + List { + Section(AccessWords.text("Describe the problem")) { + TextField(AccessWords.text("What happened, and when?"), text: $problem, axis: .vertical) + .lineLimit(3...8) + .focused($writing) + .accessibilityIdentifier("help.problem") + } + Section { + } footer: { + VStack(alignment: .leading, spacing: 6) { + Text(AccessWords.text("Your words go to Sourceful's help desk with the ticket. Your box never sees them.")) + Text(AccessWords.format("Next you see what Sourceful asks to do, and approve it with %@.", AccessWords.biometry)) + Text(AccessWords.text("Every call names the person at Sourceful who made it. You can remove the access at any time, and it ends when the ticket closes.")) + if !desk.isOpen { Text(AccessWords.text("Sourceful can't open a ticket from the app yet.")).foregroundStyle(Theme.alarm) } + if let help { Text(help).foregroundStyle(Theme.alarm) } + } + } + } + .scrollContentBackground(.hidden) + .scrollDismissesKeyboard(.immediately) + .background(Theme.surface) + .navigationTitle(AccessWords.text("Get help")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button(AccessWords.text("Done")) { writing = false } + .accessibilityIdentifier("help.written") + } + } + .safeAreaInset(edge: .bottom) { + Button { Task { await ask(desk) } } label: { + HStack(spacing: 8) { + if asking { ProgressView().tint(Theme.onAccent) } + Text(AccessWords.text("Let Sourceful look")) + } + } + .buttonStyle(FTWButtonStyle(kind: .primary)) + .disabled(!desk.isOpen || asking || problem.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !access.available) + .padding(16) + .background(Theme.surface) + .accessibilityIdentifier("help.look") + } + .navigationDestination(isPresented: $reviewing) { + if let flow { OfferFlowView(app: app, flow: flow) } + } + } + + /// The desk opens the ticket with the owner's words and hands back + /// Sourceful's signed offer, which is checked like any other and shown + /// as it will be signed. + private func ask(_ desk: SupportDesk) async { + asking = true + defer { asking = false } + help = nil + let text = problem.trimmingCharacters(in: .whitespacesAndNewlines) + do { + let signed = try await desk.open(problem: text) + let next = OfferFlow(link: nil, lookup: app.offerLookup, keys: app.offerKeys) + next.present(signed, home: app.cloudHome) + guard case .review(let offer) = next.step, offer.isSupport else { + if case .refused(let e) = next.step { help = AccessWords.offer(e) } + return + } + next.onDone = { row in + access.note(row.principal.ticket, problem: text) + problem = "" + } + flow = next + reviewing = true + } catch { + help = AccessWords.help(error) + } + } +} diff --git a/appleApp/FTW/Access/GrantView.swift b/appleApp/FTW/Access/GrantView.swift new file mode 100644 index 0000000..f2c0e05 --- /dev/null +++ b/appleApp/FTW/Access/GrantView.swift @@ -0,0 +1,173 @@ +import FTWKit +import SwiftUI + +/// One grant: who, what it may do, until when, and when it last called. +/// Any change is signed with Face ID, narrowing included; removing it is +/// one tap. +struct GrantView: View { + let app: AppModel + let access: AccessModel + let id: String + @State private var scopes: Set = [] + @State private var control = false + @State private var lengthMs: Int64 = 0 + @State private var loaded = false + @Environment(\.dismiss) private var dismiss + + var body: some View { + if let row = access.row(id) { + form(row) + .onAppear { load(row) } + .onChange(of: row.epoch) { load(row) } + } else { + // Removed, here or elsewhere. + Color.clear.onAppear { dismiss() } + } + } + + private func load(_ row: AgentRow) { + scopes = Set(row.scopes).subtracting(AccessWire.controlScopes) + control = row.hasControl + lengthMs = 0 + loaded = true + } + + private func form(_ row: AgentRow) -> some View { + let now = Date().timeIntervalSince1970 * 1000 + let reads = (Contract.agentRoleScopes[row.role] ?? []).filter { !AccessWire.controlScopes.contains($0) } + let lengths = Grant.lengths(role: row.role) + return List { + Section { + VStack(alignment: .leading, spacing: 6) { + Text(AccessWords.name(row)).font(.title2.weight(.bold)) + let place = access.place(row) + if !place.isEmpty { + Text(place) + .foregroundStyle(Theme.ink2) + .textSelection(.enabled) + } + if let verified = access.verified(row), !row.isSupport { + Label(AccessWords.text(verified ? "Verified by FTW Cloud" : "Not verified"), + systemImage: verified ? "checkmark.seal.fill" : "exclamationmark.triangle.fill") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(verified ? Theme.ink : Theme.alarm) + } + if let problem = access.problem(row) { + Text(problem).font(.callout).foregroundStyle(Theme.ink2) + } + } + .padding(.vertical, 4) + LabeledContent(AccessWords.text("Ends"), value: AccessWords.ends(row.expiresAtMs, nowMs: now)) + .accessibilityIdentifier("grant.ends") + LabeledContent(AccessWords.text("Last used"), value: AccessWords.lastCall(row.lastSeenMs, nowMs: now)) + LabeledContent(AccessWords.text("Approved"), value: AccessWords.approvedWhere(row, me: access.signedFor.deviceID)) + } + + Section(AccessWords.text("May")) { + ForEach(AccessWords.ordered(reads), id: \.self) { scope in + Toggle(AccessWords.scope(scope), isOn: Binding( + get: { scopes.contains(scope) }, + set: { on in if on { scopes.insert(scope) } else { scopes.remove(scope) } } + )) + .tint(Theme.live) + } + } + + if row.role != Contract.roleRelease { + let canTurnOn = row.hasControl || !(access.asked(row)?.askedOps.isEmpty ?? true) + Section { + Toggle(AccessWords.text("Control"), isOn: $control) + .tint(Theme.live) + .disabled(!canTurnOn) + } footer: { + let g = grant(row) + if !canTurnOn { + Text(AccessWords.text("Add it again from its page to let it control.")) + } else { + Text(control ? AccessWords.control(g.caps, scopes: g.scopes) : AccessWords.text("Off: it reads and changes nothing.")) + } + } + if control { + Section(AccessWords.text("With control it may")) { + ForEach(grant(row).ops, id: \.self) { op in + Label(AccessWords.op(op), systemImage: Contract.agentOps[op] == AccessWire.scopeHold ? "bolt" : "hand.raised") + } + } + } + } + + Section { + Picker(AccessWords.text("Renew for"), selection: $lengthMs) { + Text(AccessWords.until(row.expiresAtMs, nowMs: now)).tag(Int64(0)) + ForEach(lengths, id: \.self) { Text(AccessWords.length($0)).tag($0) } + } + } footer: { + if let help = access.help { Text(help).foregroundStyle(Theme.alarm) } + } + + Section { + Button(role: .destructive) { + Task { + if await access.revoke(row) { dismiss() } + } + } label: { + Label(AccessWords.text("Remove access"), systemImage: "xmark.circle") + } + .accessibilityIdentifier("grant.remove") + } + } + .scrollContentBackground(.hidden) + .background(Theme.surface) + .navigationTitle(AccessWords.name(row)) + .navigationBarTitleDisplayMode(.inline) + .safeAreaInset(edge: .bottom) { + if loaded, changed(row) { + Button { + Task { await access.widen(row, to: grant(row)) } + } label: { + HStack(spacing: 8) { + if access.working == row.id { ProgressView().tint(Theme.onAccent) } else { Image(systemName: Biometry.symbol) } + Text(AccessWords.format("Save with %@", AccessWords.biometry)) + } + } + .buttonStyle(FTWButtonStyle(kind: .primary)) + .disabled(scopes.isEmpty || access.working != nil) + .padding(16) + .background(Theme.surface) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .accessibilityIdentifier("grant.save") + } + } + .animation(Theme.spring, value: changed(row)) + } + + /// The whole new grant. Control kept keeps the row's own ops and caps; + /// control turned on again asks for what the client asked for when this + /// phone approved it, never more; control off drops both. + private func grant(_ row: AgentRow) -> Grant { + var all = scopes + var ops = row.ops + var caps = row.caps + if control, row.hasControl { + all.formUnion(Set(row.scopes).intersection(AccessWire.controlScopes)) + } else if control, let asked = access.asked(row) { + ops = asked.askedOps.filter { Contract.agentOps[$0] != nil } + let needed = Set(ops.compactMap { Contract.agentOps[$0] }) + all.formUnion(needed) + caps = Caps(perMin: min(asked.askedCaps.perMin, 60)) + if needed.contains(AccessWire.scopeHold) { + caps.holdS = min(asked.askedCaps.holdS, AccessWire.holdSRange.upperBound) + caps.maxW = min(asked.askedCaps.maxW, AccessWire.maxWRange.upperBound) + } + } else { + ops = [] + caps = Caps(perMin: row.caps.perMin) + } + // Unrenewed, the end is sent exactly as the box has it. + return Grant(scopes: Array(all), ops: ops, caps: caps, lengthMs: lengthMs, expiresAtMs: lengthMs == 0 ? row.expiresAtMs : nil) + } + + private func changed(_ row: AgentRow) -> Bool { + scopes != Set(row.scopes).subtracting(AccessWire.controlScopes) || control != row.hasControl || lengthMs != 0 + } +} diff --git a/appleApp/FTW/Access/OfferFlowView.swift b/appleApp/FTW/Access/OfferFlowView.swift new file mode 100644 index 0000000..16915c9 --- /dev/null +++ b/appleApp/FTW/Access/OfferFlowView.swift @@ -0,0 +1,243 @@ +import FTWKit +import SwiftUI + +/// An assistant's offer, from its consent page to Face ID. The code the +/// page shows first, so a link someone else sent stops here; then who is +/// asking, the host before the name it gives itself, what it may read, +/// control as its own switch, off, and for how long. +/// The steps of one offer, pushed over the home for a link or from Access +/// for a typed code. A page, never a sheet: FTW Cloud's first screen or +/// Settings may be up when a link arrives, and a page waits under them. +struct OfferFlowView: View { + let app: AppModel + @State var flow: OfferFlow + @Environment(\.dismiss) private var dismiss + @FocusState private var codeFocused: Bool + + private var access: AccessModel? { app.home?.access } + + var body: some View { + content + .scrollContentBackground(.hidden) + .background(Theme.surface) + .navigationBarTitleDisplayMode(.inline) + .task { await flow.start(home: app.cloudHome) } + .sensoryFeedback(trigger: flow.step) { _, step in + switch step { + case .done: return .success + case .refused: return .error + default: return nil + } + } + .animation(Theme.spring, value: flow.step) + } + + @ViewBuilder private var content: some View { + switch flow.step { + case .code: + codeStep + case .checking: + VStack(spacing: 12) { + ProgressView() + Text(AccessWords.text("Checking the offer")).foregroundStyle(Theme.ink2) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .review(let offer), .approving(let offer): + if let access { + OfferReview(app: app, access: access, flow: flow, offer: offer) + } + case .done(let row): + Outcome(icon: "checkmark.seal.fill", tint: Theme.ink, title: AccessWords.format("%@ can now reach your home.", AccessWords.name(row)), + text: AccessWords.text("Your box checks every call, and you can remove the access at any time.")) { dismiss() } + case .refused(let e): + Outcome(icon: "xmark.octagon.fill", tint: Theme.alarm, title: AccessWords.offer(e), text: nil) { dismiss() } + } + } + + private var codeStep: some View { + List { + Section { + VStack(alignment: .leading, spacing: 10) { + Text(AccessWords.text("Type the code on the page")).font(.title2.weight(.bold)) + Text(AccessWords.text("The page that asked for access shows a code. Type it here only if you started this yourself, on a page you trust.")) + .foregroundStyle(Theme.ink2) + } + .padding(.vertical, 4) + } + Section { + TextField("ABCD-EFGH", text: $flow.typed) + .font(.title2.monospaced().weight(.semibold)) + .textInputAutocapitalization(.characters) + .autocorrectionDisabled() + .textContentType(.oneTimeCode) + .submitLabel(.continue) + .focused($codeFocused) + .onSubmit { Task { await flow.submit(home: app.cloudHome) } } + .accessibilityIdentifier("offer.code") + } footer: { + if let help = flow.codeHelp { Text(help).foregroundStyle(Theme.alarm) } + } + } + .safeAreaInset(edge: .bottom) { + Button(AccessWords.text("Continue")) { Task { await flow.submit(home: app.cloudHome) } } + .buttonStyle(FTWButtonStyle(kind: .primary)) + .disabled(OfferCode.normalize(flow.typed) == nil) + .padding(16) + .accessibilityIdentifier("offer.continue") + } + .onAppear { codeFocused = true } + } +} + +/// The offer, verified, for the owner to choose from. +struct OfferReview: View { + let app: AppModel + let access: AccessModel + @Bindable var flow: OfferFlow + let offer: GrantOffer + + var body: some View { + let grant = flow.grant(offer) + List { + Section { + VStack(alignment: .leading, spacing: 8) { + Text(offer.clientHost) + .font(.title2.weight(.bold)) + .foregroundStyle(Theme.ink) + .textSelection(.enabled) + .accessibilityIdentifier("offer.host") + Label(AccessWords.text(offer.verified ? "Verified by FTW Cloud" : "Not verified"), + systemImage: offer.verified ? "checkmark.seal.fill" : "exclamationmark.triangle.fill") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(offer.verified ? Theme.ink : Theme.alarm) + if offer.isSupport { + Text(AccessWords.text("Sourceful support")).foregroundStyle(Theme.ink2) + } else if AccessWords.nameHidden(offer.client) { + Text(AccessWords.text("The name it gives mentions Sourceful or FTW, so it is not shown.")) + .font(.subheadline) + .foregroundStyle(Theme.alarm) + } else if let name = AccessWords.ownName(offer.client) { + Text(AccessWords.format("Calls itself %@", name)).foregroundStyle(Theme.ink2) + } + if offer.isSupport, !offer.ticket.isEmpty { + Text(AccessWords.format("Ticket %@", offer.ticket)).font(.subheadline).foregroundStyle(Theme.ink2) + } + } + .padding(.vertical, 4) + } footer: { + if !offer.verified { + Text(AccessWords.text("FTW Cloud doesn't know this client. Check that the address is the one you expect.")) + } + } + + if !offer.verified { + Section { + Toggle(AccessWords.format("I started this myself and trust %@.", offer.clientHost), isOn: $flow.trustsUnknown) + .tint(Theme.alarm) + .accessibilityIdentifier("offer.trust") + } + } + + Section(AccessWords.text("It may")) { + ForEach(AccessWords.ordered(grant.scopes.filter { !AccessWire.controlScopes.contains($0) }), id: \.self) { scope in + Label(AccessWords.scope(scope), systemImage: "eye") + } + } + + if offer.asksControl { + Section { + Toggle(AccessWords.text("Also let it control"), isOn: $flow.control) + .tint(Theme.live) + .accessibilityIdentifier("offer.control") + } footer: { + let asked = Grant.from(offer, control: true, lengthMs: flow.lengthMs) + Text(flow.control ? AccessWords.control(asked.caps, scopes: asked.scopes) : AccessWords.text("Off: it reads and changes nothing.")) + } + if flow.control { + Section(AccessWords.text("With control it may")) { + ForEach(Grant.from(offer, control: true, lengthMs: flow.lengthMs).ops, id: \.self) { op in + Label(AccessWords.op(op), systemImage: Contract.agentOps[op] == AccessWire.scopeHold ? "bolt" : "hand.raised") + } + } + } + } + + + Section { + Picker(AccessWords.text("For"), selection: $flow.lengthMs) { + ForEach(flow.lengths, id: \.self) { Text(AccessWords.length($0)).tag($0) } + } + .accessibilityIdentifier("offer.length") + HomePick(app: app, flow: flow, offer: offer) + } footer: { + if let help = access.help { Text(help).foregroundStyle(Theme.alarm) } + } + } + .safeAreaInset(edge: .bottom) { + Button { Task { await flow.approve(access, home: app.cloudHome) } } label: { + HStack(spacing: 8) { + if case .approving = flow.step { ProgressView().tint(Theme.onAccent) } else { Image(systemName: Biometry.symbol) } + Text(AccessWords.format("Approve with %@", AccessWords.biometry)) + } + } + .buttonStyle(FTWButtonStyle(kind: .primary)) + .disabled(!access.available || flow.offer == nil || !(offer.verified || flow.trustsUnknown) + || { if case .approving = flow.step { return true }; return false }()) + .padding(16) + .background(Theme.surface) + .accessibilityIdentifier("offer.approve") + } + } +} + +/// The home the grant is for. With several, the owner picks one: that +/// home opens and the link starts again there, from the code, so nothing +/// checked at one home is taken to another. An offer that names its home, +/// or one found by a code, which FTW Cloud gives once, stays where it is. +struct HomePick: View { + let app: AppModel + let flow: OfferFlow + let offer: GrantOffer + + var body: some View { + if app.homes.count > 1, offer.home.isEmpty, let link = flow.link, let current = app.site?.siteId { + Picker(AccessWords.text("Home"), selection: Binding(get: { current }, set: { home in + guard home != current else { return } + app.open(home) + app.accessLink = link + })) { + ForEach(app.homes) { Text(HomeName.title($0.label)).tag($0.siteId) } + } + } else { + LabeledContent(AccessWords.text("Home"), value: app.homeName) + } + } +} + +/// How a sheet ended, with the one way out. +struct Outcome: View { + let icon: String + let tint: Color + let title: String + let text: String? + let done: () -> Void + + var body: some View { + VStack(spacing: 16) { + Spacer() + Image(systemName: icon) + .font(.system(size: 52)) + .foregroundStyle(tint) + .symbolEffect(.bounce, value: title) + .accessibilityHidden(true) + Text(title).font(.title3.weight(.semibold)).multilineTextAlignment(.center) + if let text { Text(text).foregroundStyle(Theme.ink2).multilineTextAlignment(.center) } + Spacer() + Button(AccessWords.text("Done"), action: done) + .buttonStyle(FTWButtonStyle(kind: .primary)) + .accessibilityIdentifier("offer.done") + } + .padding(24) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/appleApp/FTW/Access/RequestView.swift b/appleApp/FTW/Access/RequestView.swift new file mode 100644 index 0000000..bd3b87e --- /dev/null +++ b/appleApp/FTW/Access/RequestView.swift @@ -0,0 +1,89 @@ +import FTWKit +import SwiftUI + +/// A lasting change an assistant asked for: exactly what it asks, each +/// value under the registry's label for it, and its reason in its own +/// words. Approving signs those very values with Face ID; declining is one +/// tap. +struct RequestView: View { + let access: AccessModel + let request: AgentRequest + @Environment(\.dismiss) private var dismiss + @State private var answered: Bool? + + var body: some View { + let now = Date().timeIntervalSince1970 * 1000 + List { + Section { + VStack(alignment: .leading, spacing: 6) { + Text(AccessWords.format("%@ asks to", access.agentName(request.agent))) + .foregroundStyle(Theme.ink2) + Text(AccessWords.op(request.op)).font(.title2.weight(.bold)) + } + .padding(.vertical, 4) + ForEach(Array(AccessWords.arguments(request, modes: access.site.session.modes).enumerated()), id: \.offset) { _, line in + LabeledContent(line.label, value: line.value) + } + } + + let why = AccessWords.clean(request.why).trimmingCharacters(in: .whitespaces) + if !why.isEmpty { + Section { + Text("“\(why)”").italic() + } header: { + Text(AccessWords.text("In its own words")) + } footer: { + Text(AccessWords.format("Asked %@", AppWords.day(Double(request.atMs), nowMs: now, zone: .current))) + } + } + + Section { + } footer: { + VStack(alignment: .leading, spacing: 6) { + if AccessWords.approvable(request) { + Text(AccessWords.text("Approving runs it on your box now, as if you had made the change yourself.")) + } else { + Text(AccessWords.text("This request has something this app can't show you, so it can only be declined.")) + .foregroundStyle(Theme.alarm) + } + Text(AccessWords.format("Waits %@", AppWords.day(Double(request.expiresAtMs), nowMs: now, zone: .current))) + if let help = access.help { Text(help).foregroundStyle(Theme.alarm) } + } + } + } + .scrollContentBackground(.hidden) + .background(Theme.surface) + .navigationTitle(access.agentName(request.agent)) + .navigationBarTitleDisplayMode(.inline) + .safeAreaInset(edge: .bottom) { + VStack(spacing: 8) { + if AccessWords.approvable(request) { + Button { answer(true) } label: { + HStack(spacing: 8) { + if access.working == request.reqId { ProgressView().tint(Theme.onAccent) } else { Image(systemName: Biometry.symbol) } + Text(AccessWords.format("Approve with %@", AccessWords.biometry)) + } + } + .buttonStyle(FTWButtonStyle(kind: .primary)) + .accessibilityIdentifier("request.approve") + } + Button(AccessWords.text("Decline")) { answer(false) } + .buttonStyle(FTWButtonStyle(kind: .quiet)) + .accessibilityIdentifier("request.decline") + } + .disabled(access.working != nil) + .padding(16) + .background(Theme.surface) + } + .sensoryFeedback(trigger: answered) { _, now in now == true ? .success : nil } + } + + private func answer(_ approve: Bool) { + Task { + if await access.answer(request, approve: approve) { + answered = true + dismiss() + } + } + } +} diff --git a/appleApp/FTW/FTWApp.swift b/appleApp/FTW/FTWApp.swift index e51c715..2e56ee4 100644 --- a/appleApp/FTW/FTWApp.swift +++ b/appleApp/FTW/FTWApp.swift @@ -41,6 +41,11 @@ struct FTWApp: App { // The Apple Watch reads through this phone and sends its commands // here, over the same runner. if let runner = IntentRunner.shared { WatchBridge.shared.install(model, runner: runner) } + // Approving an assistant or support signs with this phone's key in + // the Secure Enclave, behind Face ID; the demo has keys of its own. + model.approvalKeys = ApprovalKeychain(store: store) + Biometry.name() + model.demoAccess = (SimulatedKeyring(), SimulatedAccess.offerKeys, SimulatedDesk(), SimulatedAccess.lookup) // A paired phone paints its cached home before anything else runs. model.launch() #if DEBUG @@ -54,6 +59,9 @@ struct FTWApp: App { // For end-to-end tests: `-FTWPairLink ` offers a pairing link // as a scanned QR would, where the simulator has no camera. if let link = UserDefaults.standard.string(forKey: "FTWPairLink") { model.offer(link) } + // `-FTWAccessLink ` offers a consent page's link as a tap on it + // would, where the simulator has no universal link to the app yet. + if let link = UserDefaults.standard.string(forKey: "FTWAccessLink") { model.offer(link) } #endif _app = State(initialValue: model) network = NetworkWatch { [weak model] in model?.networkOnline() } @@ -75,7 +83,9 @@ struct FTWApp: App { RootView(app: app) // A pairing QR scanned with the iOS camera arrives as a // universal link to https://app.ftw.energy/p#…, and takes the - // same path as any other pairing link. + // same path as any other pairing link. An assistant's consent + // page links to https://app.ftw.energy/a#…, which opens its + // own sheet. .onOpenURL { app.offer($0.absoluteString) } .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in if let url = activity.webpageURL { app.offer(url.absoluteString) } diff --git a/appleApp/FTW/Platform/KeychainStore.swift b/appleApp/FTW/Platform/KeychainStore.swift index 19d1133..de83c1a 100644 --- a/appleApp/FTW/Platform/KeychainStore.swift +++ b/appleApp/FTW/Platform/KeychainStore.swift @@ -38,15 +38,21 @@ final class KeychainStore: SecureStore { } func put(_ key: String, _ value: Bytes) { + try? write(key, value) + } + + /// Update or add, and say when neither kept the value. + func write(_ key: String, _ value: Bytes) throws { let query = base(key) let update: [String: Any] = [kSecValueData as String: Data(value)] - let status = SecItemUpdate(query as CFDictionary, update as CFDictionary) + var status = SecItemUpdate(query as CFDictionary, update as CFDictionary) if status == errSecItemNotFound { var item = query item[kSecValueData as String] = Data(value) item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly - SecItemAdd(item as CFDictionary, nil) + status = SecItemAdd(item as CFDictionary, nil) } + guard status == errSecSuccess else { throw SecureStoreUnavailable(status: status) } } func remove(_ key: String) { diff --git a/appleApp/FTW/UI/ModeSheet.swift b/appleApp/FTW/UI/ModeSheet.swift index 9264d76..269e554 100644 --- a/appleApp/FTW/UI/ModeSheet.swift +++ b/appleApp/FTW/UI/ModeSheet.swift @@ -36,6 +36,9 @@ struct ModeSheet: View { List { planSection manualSection + if let app, let access = app.home?.access { + AccessSettingsSection(app: app, access: access) + } phoneSection } .listStyle(.insetGrouped) diff --git a/appleApp/FTW/UI/RootView.swift b/appleApp/FTW/UI/RootView.swift index 3089a5c..31fc316 100644 --- a/appleApp/FTW/UI/RootView.swift +++ b/appleApp/FTW/UI/RootView.swift @@ -38,6 +38,8 @@ enum HomeRoute: Hashable { case price /// What the home saved, from the home's node and the title's menu. case savings + /// An assistant's consent link: its offer, from the code to Face ID. + case offer(OfferLink) /// The name in the bar, the same as the screen's own title. var title: String { @@ -47,6 +49,7 @@ enum HomeRoute: Hashable { case .plan: return PlanGlance.title case .price: return L("Electricity price") case .savings: return L("Savings") + case .offer: return AccessWords.text("Add an assistant") } } @@ -116,6 +119,8 @@ struct HomeView: View { PriceView(home: home) case .savings: SavingsView(home: home) + case .offer(let link): + OfferFlowView(app: app, flow: OfferFlow(link: link, lookup: app.offerLookup, keys: app.offerKeys)) } } .homeBar(app: app, home: home, title: route.title) @@ -129,6 +134,14 @@ struct HomeView: View { case .about(let start): AboutView(app: app, start: start) } } + // A consent page's link opens its offer as a page over the home, + // in front of anything open, once this phone can grant from here. + .onChange(of: app.accessLink, initial: true) { _, link in + guard let link, home.access != nil else { return } + app.accessLink = nil + sheet = nil + path.append(.offer(link)) + } // Now and the car read the chargers from one model, so a screen // appearing asks the box once. .onAppear { home.loadpoints.activate() } diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedAccess.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedAccess.swift new file mode 100644 index 0000000..f3901fe --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedAccess.swift @@ -0,0 +1,331 @@ +import Foundation +import FTWKit + +/// The simulated box's agents (docs/agents.md in srcfl/ftw): an assistant, +/// a support grant for one ticket, a request waiting for the owner, and the +/// audit log behind them. It checks each approval signature as the box +/// does, over the bytes the box would build, with the key the phone +/// registered. +/// +/// It is not the box: it keeps nothing, runs no op and lets no one in. Its +/// offers are signed with a key made in this process, which only the demo +/// pins, so a demo offer reaches the simulated box and nothing else. +@MainActor +public final class SimulatedAccess { + public private(set) var rows: [AgentRow] = [] + public private(set) var requests: [AgentRequest] = [] + /// deviceId → registered approval key. + public private(set) var keys: [String: Bytes] = [:] + public private(set) var audit: [AuditRow] = [] + private var seen = Set() + private var seq: UInt64 = 0 + let nowMs: @MainActor () -> Double + let signedFor = AccessModel.demoFor + + init(nowMs: @escaping @MainActor () -> Double) { + self.nowMs = nowMs + } + + /// A lived-in home: Claude for a week, Sourceful on a ticket, and Claude + /// asking for a change of mode. + public func seed() { + let now = Int64(nowMs()) + let day: Int64 = 86_400_000 + rows = [ + AgentRow(id: "q7Xn2vKd", key: Bytes(repeating: 0x11, count: 32), role: Contract.roleAgent, + scopes: ["ftw.goals.request", "ftw.health.read", "ftw.live.read", "ftw.plan.read"], ops: ["site.mode.set"], + caps: Caps(perMin: 6), expiresAtMs: now + 23 * day, principal: Principal(client: "Claude", clientId: "https://claude.ai/oauth/mcp-oauth-client-metadata"), + approvedBy: "app:\(signedFor.deviceID)", addedAtMs: now - 7 * day, lastSeenMs: now - 12 * 60_000, epoch: 1), + AgentRow(id: "Hs93LmQa", key: Bytes(repeating: 0x22, count: 32), role: Contract.roleSupport, + scopes: ["ftw.config.read", "ftw.diag.run", "ftw.health.read", "ftw.live.read", "ftw.logs.read"], ops: [], + caps: Caps(perMin: 6), expiresAtMs: now + 5 * day, principal: Principal(client: "Sourceful support", clientId: AccessWire.supportClientID, ticket: "FTW-1042"), + approvedBy: "app:\(signedFor.deviceID)", addedAtMs: now - 2 * day, lastSeenMs: now - 3 * 3_600_000, epoch: 1), + ] + requests = [ + AgentRequest(reqId: "AAECAwQFBgcICQoLDA0ODw", agent: "q7Xn2vKd", op: "site.mode.set", + args: .map([("mode", .text("self_consumption"))]), + why: "Tomorrow looks sunny and tonight's prices are flat, so the battery can wait for the sun.", + atMs: now - 4 * 60_000, expiresAtMs: now - 4 * 60_000 + 12 * 3_600_000, state: "pending"), + ] + // Oldest first, so the newest row has the highest seq. + let past: [(String, String, String, UInt64)] = [ + ("app:\(signedFor.deviceID)", "", "agent.add", 0), + ("agent:q7Xn2vKd", "", "live.get", 12), ("agent:q7Xn2vKd", "", "plan.get", 4), ("agent:q7Xn2vKd", "", "agent.request", 0), + ("agent:Hs93LmQa", "staff:anna.lindqvist", "health.get", 1), ("agent:Hs93LmQa", "staff:anna.lindqvist", "logs.get", 3), + ] + for (i, row) in past.enumerated() { + log(row.0, by: row.1, what: row.2, result: "ok", count: row.3, atMs: now - Int64(past.count - i) * 9 * 60_000) + } + } + + // MARK: Messages + + /// For a test: act on the next message but lose its answer, as a + /// dropped connection would. + public var loseNextAnswer = false + + func receive(_ env: Envelope, reply given: @escaping (Envelope) -> Void) { + var reply = given + if loseNextAnswer { + loseNextAnswer = false + reply = { _ in } + } + let body = env.b ?? .emptyMap + let ok = { (b: CBOR) in reply(Envelope(t: AccessWire.msgOK, id: env.id, b: b)) } + let refuse = { (code: String, args: [(String, CBOR)]) in + reply(Envelope(t: "error", id: env.id, b: .map([("code", .text(code)), ("retryable", .bool(Contract.isRetryable(code))), ("args", .map(args))]))) + } + let now = Int64(nowMs()) + switch env.t { + case AccessWire.msgList: + ok(.map([ + ("agents", .array(rows.map(Self.cbor))), + ("requests", .array(requests.map(Self.cbor))), + ("keys", .array(keys.keys.sorted().map { .map([("deviceId", .text($0)), ("setAtMs", .int(now))]) })), + ])) + case AccessWire.msgAudit: + let before = body["beforeSeq"]?.int64.map(UInt64.init) ?? .max + let n = min(body["n"]?.int ?? 200, 200) + let page = audit.filter { $0.seq < before }.prefix(n) + ok(.map([("rows", .array(page.map(Self.cbor))), ("more", .bool(audit.filter { $0.seq < before }.count > n))])) + case AccessWire.msgKeySet: + guard let pub = body["pub"]?.byteString, let atMs = body["atMs"]?.int64, let sig = body["sig"]?.byteString else { return refuse("E_UNKNOWN_OP", [("field", .text("body"))]) } + let msg = Approval.keySetBytes(signedFor, atMs: atMs, pub: pub) + if let old = keys[signedFor.deviceID], !old.isEmpty { + guard let prev = body["prevSig"]?.byteString, Approval.verify(pub: old, message: msg, sig: prev) else { + return refuse("E_BAD_SIGNATURE", [("field", .text("prevSig"))]) + } + } + if let code = check(pub, msg, sig, atMs: atMs, now: now) { return refuse(code.0, code.1) } + keys[signedFor.deviceID] = pub + log("app:\(signedFor.deviceID)", what: AccessWire.msgKeySet, target: signedFor.deviceID, result: "ok") + ok(.map([("deviceId", .text(signedFor.deviceID)), ("setAtMs", .int(now))])) + case AccessWire.msgAdd: + guard let pub = keys[signedFor.deviceID], !pub.isEmpty else { return refuse("E_NO_APPROVAL_KEY", []) } + guard let key = body["key"]?.byteString, key.count == 32, let atMs = body["atMs"]?.int64, let sig = body["sig"]?.byteString else { + return refuse("E_UNKNOWN_OP", [("field", .text("body"))]) + } + var add = AgentAdd(key: key, role: body["role"]?.string ?? "", scopes: body["scopes"]?.stringArray ?? [], ops: body["ops"]?.stringArray ?? [], + caps: Caps(holdS: UInt64(body["caps"]?["holdS"]?.int ?? 0), maxW: UInt64(body["caps"]?["maxW"]?.int ?? 0), perMin: UInt64(body["caps"]?["perMin"]?.int ?? 0)), + expiresAtMs: body["expiresAtMs"]?.int64 ?? 0, + principal: Principal(client: body["principal"]?["client"]?.string ?? "", clientId: body["principal"]?["clientId"]?.string ?? "", ticket: body["principal"]?["ticket"]?.string ?? ""), + atMs: atMs) + add.sig = sig + guard let msg = try? Approval.addBytes(signedFor, add) else { return refuse("E_UNKNOWN_OP", [("field", .text("order"))]) } + if let code = check(pub, msg, sig, atMs: atMs, now: now) { return refuse(code.0, code.1) } + guard add.expiresAtMs > now, add.expiresAtMs <= now + AccessWire.longestMs(add.role) else { + return refuse("E_CAP_EXCEEDED", [("cap", .text("expiresAtMs")), ("max", .int(now + AccessWire.longestMs(add.role)))]) + } + let id = String(Base64url.encode(key).prefix(8)) + guard !rows.contains(where: { $0.id == id }) else { return refuse("E_UNKNOWN_OP", [("field", .text("key"))]) } + var caps = add.caps + if caps.perMin == 0 { caps.perMin = AccessWire.perMinDefault } + let row = AgentRow(id: id, key: key, role: add.role, scopes: add.scopes, ops: add.ops, caps: caps, expiresAtMs: add.expiresAtMs, + principal: add.principal, approvedBy: "app:\(signedFor.deviceID)", addedAtMs: now, lastSeenMs: 0, epoch: 1) + rows.append(row) + rows.sort { $0.id < $1.id } + log("app:\(signedFor.deviceID)", what: AccessWire.msgAdd, target: id, result: "ok") + ok(.map([("agent", Self.cbor(row))])) + case AccessWire.msgWiden: + guard let pub = keys[signedFor.deviceID], !pub.isEmpty else { return refuse("E_NO_APPROVAL_KEY", []) } + guard let id = body["id"]?.string, let i = rows.firstIndex(where: { $0.id == id }) else { return refuse("E_NOT_FOUND", []) } + guard let epoch = body["epoch"]?.int64, UInt64(epoch) == rows[i].epoch else { + return refuse("E_CONFLICT", [("expected", body["epoch"] ?? .null), ("actual", .unsigned(rows[i].epoch))]) + } + guard let atMs = body["atMs"]?.int64, let sig = body["sig"]?.byteString else { return refuse("E_UNKNOWN_OP", [("field", .text("body"))]) } + let m = AgentWiden(id: id, epoch: UInt64(epoch), scopes: body["scopes"]?.stringArray ?? [], ops: body["ops"]?.stringArray ?? [], + caps: Caps(holdS: UInt64(body["caps"]?["holdS"]?.int ?? 0), maxW: UInt64(body["caps"]?["maxW"]?.int ?? 0), perMin: UInt64(body["caps"]?["perMin"]?.int ?? 0)), + expiresAtMs: body["expiresAtMs"]?.int64 ?? 0, atMs: atMs) + guard let msg = try? Approval.widenBytes(signedFor, m, key: rows[i].key) else { return refuse("E_UNKNOWN_OP", [("field", .text("order"))]) } + if let code = check(pub, msg, sig, atMs: atMs, now: now) { return refuse(code.0, code.1) } + rows[i].scopes = m.scopes + rows[i].ops = m.ops + rows[i].caps = m.caps + rows[i].expiresAtMs = m.expiresAtMs + rows[i].epoch += 1 + log("app:\(signedFor.deviceID)", what: AccessWire.msgWiden, target: id, result: "ok") + ok(.map([("agent", Self.cbor(rows[i]))])) + case AccessWire.msgRevoke: + guard let id = body["id"]?.string, rows.contains(where: { $0.id == id }) else { return refuse("E_NOT_FOUND", []) } + rows.removeAll { $0.id == id } + requests.removeAll { $0.agent == id } + log("app:\(signedFor.deviceID)", what: AccessWire.msgRevoke, target: id, result: "ok") + ok(.map([("id", .text(id))])) + case AccessWire.msgConfirm: + guard let reqId = body["reqId"]?.string, let i = requests.firstIndex(where: { $0.reqId == reqId && $0.pending }) else { return refuse("E_NOT_FOUND", []) } + guard body["approve"]?.bool == true else { + requests[i].state = "declined" + log("app:\(signedFor.deviceID)", what: AccessWire.msgConfirm, target: reqId, result: "declined") + return ok(.map([("reqId", .text(reqId)), ("state", .text("declined"))])) + } + guard let pub = keys[signedFor.deviceID], !pub.isEmpty else { return refuse("E_NO_APPROVAL_KEY", []) } + guard let atMs = body["atMs"]?.int64, let sig = body["sig"]?.byteString else { return refuse("E_UNKNOWN_OP", [("field", .text("body"))]) } + let msg = Approval.confirmBytes(signedFor, reqId: reqId, atMs: atMs, request: requests[i]) + if let code = check(pub, msg, sig, atMs: atMs, now: now) { return refuse(code.0, code.1) } + requests[i].state = "approved" + log("app:\(signedFor.deviceID)", what: AccessWire.msgConfirm, target: reqId, result: "approved") + ok(.map([("reqId", .text(reqId)), ("state", .text("approved")), ("cmdId", .text("req:\(reqId)"))])) + default: + refuse("E_UNKNOWN_OP", [("t", .text(env.t))]) + } + } + + /// The box's checks on a signature: fresh, never seen, and over these + /// bytes. + private func check(_ pub: Bytes, _ msg: Bytes, _ sig: Bytes, atMs: Int64, now: Int64) -> (String, [(String, CBOR)])? { + guard abs(atMs - now) <= Approval.skewMs else { return ("E_CLOCK_SKEW", [("boxMs", .int(now))]) } + guard Approval.verify(pub: pub, message: msg, sig: sig) else { return ("E_BAD_SIGNATURE", []) } + let digest = Primitives.sha256(msg) + guard seen.insert(digest).inserted else { return ("E_BAD_SIGNATURE", [("field", .text("replay"))]) } + return nil + } + + private func log(_ actor: String, by: String = "", what: String, target: String = "", result: String, count: UInt64 = 0, atMs: Int64? = nil) { + seq += 1 + let role = actor.hasPrefix("agent:") ? (rows.first { "agent:\($0.id)" == actor }?.role ?? "") : Contract.roleOwner + audit.insert(AuditRow(seq: seq, atMs: atMs ?? Int64(nowMs()), actor: actor, role: role, by: by, what: what, target: target, result: result, count: count), at: 0) + } + + // MARK: Offers + + /// The demo's own offer key, made in this process. + public static let offerKey = SoftwareSigner() + + /// `kid:pub` for the demo's offer key, for the demo's `OfferKeys`. + public static var offerKeys: OfferKeys { + OfferKeys(pins: ["\(OfferKeys.kid(offerKey.publicKey)):\(Base64url.encode(offerKey.publicKey))"]) + } + + /// An offer as FTW Cloud's consent page makes one, signed by the demo's + /// key: Claude, asking to read and to control, or Sourceful support for + /// one ticket. + public static func offer(support: Bool = false, ticket: String = "FTW-1043", code: String = "K7QF2MXD", nowMs: Double) -> SignedOffer { + var o = GrantOffer() + o.v = GrantOffer.version + o.key = randomBytes(32) + if support { + o.role = Contract.roleSupport + o.client = "Sourceful support" + o.clientId = AccessWire.supportClientID + o.clientHost = "cloud.ftw.energy" + o.verified = true + o.scopes = ["ftw.config.read", "ftw.diag.run", "ftw.dispatch.hold", "ftw.events.read", "ftw.goals.request", "ftw.health.read", "ftw.history.read", "ftw.live.read", "ftw.logs.read", "ftw.plan.read"] + o.ticket = ticket + o.ttlS = UInt64(AccessWire.longestMs(Contract.roleSupport) / 1000) + } else { + o.role = Contract.roleAgent + o.client = "Claude" + o.clientId = "https://claude.ai/oauth/mcp-oauth-client-metadata" + o.clientHost = "claude.ai" + o.verified = true + o.scopes = ["ftw.dispatch.hold", "ftw.events.read", "ftw.goals.request", "ftw.health.read", "ftw.history.read", "ftw.live.read", "ftw.plan.read"] + o.ttlS = UInt64(AccessWire.longestMs(Contract.roleAgent) / 1000) + } + o.ops = ["car.goal.set", "loadpoint.hold", "planner.prefs.set", "site.mode.set"] + o.caps = Caps(holdS: 1_800, maxW: 11_000, perMin: 6) + o.code = code + o.expMs = Int64(nowMs) + 9 * 60_000 + let raw = encodeDeterministic(o.cbor) + return SignedOffer(o: raw, kid: OfferKeys.kid(offerKey.publicKey), sig: (try? offerKey.sign(SignedOffer.signedBytes(raw))) ?? []) + } + + /// The simulated consent page's code lookup: the demo's code finds a + /// fresh offer, any other none. + public static let lookup = OfferLookup(base: URL(string: "https://demo.invalid")!) { req in + let code = URLComponents(url: req.url!, resolvingAgainstBaseURL: false)?.queryItems?.first { $0.name == "code" }?.value + guard code == "K7QF2MXD" else { return (Data(), 404) } + let signed: String = await MainActor.run { () -> String in + SimulatedAccess.offer(nowMs: Date().timeIntervalSince1970 * 1000).base64url + } + return (try JSONSerialization.data(withJSONObject: ["offer": signed, "status": "pending"]), 200) + } + + // MARK: Encoding, as the box encodes + + static func cbor(_ r: AgentRow) -> CBOR { + .map([ + ("id", .text(r.id)), ("key", .bytes(r.key)), ("role", .text(r.role)), + ("scopes", .array(r.scopes.map { .text($0) })), ("ops", .array(r.ops.map { .text($0) })), + ("caps", .map([("holdS", .unsigned(r.caps.holdS)), ("maxW", .unsigned(r.caps.maxW)), ("perMin", .unsigned(r.caps.perMin))])), + ("expiresAtMs", .int(r.expiresAtMs)), + ("principal", .map([("client", .text(r.principal.client)), ("clientId", .text(r.principal.clientId)), ("ticket", .text(r.principal.ticket))])), + ("approvedBy", .text(r.approvedBy)), ("addedAtMs", .int(r.addedAtMs)), ("lastSeenMs", .int(r.lastSeenMs)), ("epoch", .unsigned(r.epoch)), + ]) + } + + static func cbor(_ r: AgentRequest) -> CBOR { + .map([ + ("reqId", .text(r.reqId)), ("agent", .text(r.agent)), ("op", .text(r.op)), ("args", r.args), ("why", .text(r.why)), + ("atMs", .int(r.atMs)), ("expiresAtMs", .int(r.expiresAtMs)), ("state", .text(r.state)), + ]) + } + + static func cbor(_ r: AuditRow) -> CBOR { + .map([ + ("seq", .unsigned(r.seq)), ("atMs", .int(r.atMs)), ("actor", .text(r.actor)), ("role", .text(r.role)), + ("principal", .text(r.principal)), ("by", .text(r.by)), ("what", .text(r.what)), ("target", .text(r.target)), + ("result", .text(r.result)), ("count", .unsigned(r.count)), + ]) + } +} + +/// The demo's approval keys: one software key per home, and no Face ID, so +/// anyone can try the demo. +@MainActor +public final class SimulatedKeyring: ApprovalKeyring, ApprovalUnlock { + private var kept: [String: SoftwareSigner] = [:] + private var waiting: [String: SoftwareSigner] = [:] + private var home = "" + /// How many times the demo would have asked for Face ID. + public private(set) var unlocks = 0 + /// How many approvals ended, as each must. + public private(set) var ends = 0 + /// For a test: keeping a key fails, as a full Keychain would. + public var keepFails = false + + public init() {} + + public func unlock(home: String, reason: String) async throws -> ApprovalUnlock { + self.home = home + unlocks += 1 + return self + } + + public func forget(home: String) { + kept[home] = nil + waiting[home] = nil + } + public func existing() throws -> ApprovalSigner? { kept[home] } + public func pending() throws -> ApprovalSigner? { waiting[home] } + public func make() throws -> ApprovalSigner { SoftwareSigner() } + public func keep(_ signer: ApprovalSigner) throws { + guard !keepFails, let s = signer as? SoftwareSigner else { throw ApprovalKeyNotKept() } + kept[home] = s + } + public func keepPending(_ signer: ApprovalSigner) throws { + guard !keepFails, let s = signer as? SoftwareSigner else { throw ApprovalKeyNotKept() } + waiting[home] = s + } + public func promote() throws { + guard let s = waiting.removeValue(forKey: home) else { return } + kept[home] = s + } + public func dropPending() { waiting[home] = nil } + public func end() { ends += 1 } +} + +/// The demo's help desk: a ticket number and a support offer signed by the +/// demo's offer key. +@MainActor +public final class SimulatedDesk: SupportDesk { + private var next = 1043 + + public init() {} + + public var isOpen: Bool { true } + + public func open(problem: String) async throws -> SignedOffer { + defer { next += 1 } + return SimulatedAccess.offer(support: true, ticket: "FTW-\(next)", nowMs: Date().timeIntervalSince1970 * 1000) + } +} diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift index 3530879..1c74974 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift @@ -72,6 +72,8 @@ public final class SimulatedBox { /// `price.get`, and each API request as "METHOD /path". public private(set) var asks: [String] = [] public private(set) var stepUpsSeen = 0 + /// Its assistants, support grants, requests and audit log. + public private(set) lazy var access = SimulatedAccess { [scheduler] in scheduler.nowMs } public nonisolated static let modes: [ModeInfo] = [ ModeInfo(key: "planner_passive_arbitrage", label: "Passive arbitrage", tooltip: "Charge from the cheapest available source (PV when sunny, grid during cheap hours). Never exports from battery.", tier: "primary"), @@ -87,7 +89,7 @@ public final class SimulatedBox { /// offers. Registry names only; `ContractTests` checks. public nonisolated static let caps = [ "status.core", "status.drivers", "history.5m", "history.1h", "history.etag", "cmd.lease", - "cmd.readback", "der.battery", "der.ev", "plan.dispatch", "price.spot", "api.passthrough", + "cmd.readback", "der.battery", "der.ev", "plan.dispatch", "price.spot", "api.passthrough", "access.agents", ] /// Capabilities this box offers beyond `caps`, for a test that needs a @@ -164,6 +166,9 @@ public final class SimulatedBox { sendBulk(Envelope(t: "plan", id: env.id, b: sessionPlan().wire)) case "api.req": onAPI(env.id, body) case "cmd": onCommand(body) + case AccessWire.msgList, AccessWire.msgAudit, AccessWire.msgKeySet, AccessWire.msgAdd, AccessWire.msgWiden, + AccessWire.msgRevoke, AccessWire.msgConfirm: + access.receive(env) { [weak self] in self?.sendBulk($0) } default: break } } @@ -829,6 +834,7 @@ extension SimulatedBox { /// take a moment to answer, as real ones do. public static func loopback(_ scheduler: Scheduler) -> Carrier { let box = SimulatedBox(scheduler: scheduler) + box.access.seed() box.resultDelayMs = 900 box.house.chargerRampMs = 4_000 #if DEBUG diff --git a/appleApp/FTWKit/Sources/FTWKit/Access/AccessModel.swift b/appleApp/FTWKit/Sources/FTWKit/Access/AccessModel.swift new file mode 100644 index 0000000..363a7c7 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Access/AccessModel.swift @@ -0,0 +1,563 @@ +import Foundation +import Observation + +/// The phone's approval key for one home, behind one Face ID. +/// +/// The app makes it in the Secure Enclave, usable only after Face ID with +/// the current enrolment (`biometryCurrentSet`), and keeps it in the +/// Keychain under the home. Each home has its own key, so replacing it at +/// one box leaves the others alone. +@MainActor +public protocol ApprovalKeyring: AnyObject { + /// One Face ID, with `reason` on the system's sheet. Every signature + /// through the answer is covered by it. Throws `ApprovalCancelled` when + /// the person backs out. + func unlock(home: String, reason: String) async throws -> ApprovalUnlock + /// Drop a home's key, when the phone forgets the home. + func forget(home: String) +} + +@MainActor +public protocol ApprovalUnlock: AnyObject { + /// This home's key, or nil when the phone has none. Throws + /// `ApprovalKeyLost` when the key is there but can no longer sign: a + /// new Face ID enrolment voids it. + func existing() throws -> ApprovalSigner? + /// A replacement made but not yet known to have reached the box, kept + /// beside the current key so neither is stranded. + func pending() throws -> ApprovalSigner? + /// A new key, not yet kept anywhere. + func make() throws -> ApprovalSigner + /// Keep `signer` as this home's key. Done before the box hears of it, + /// so a key the box took is never one the phone lost. + func keep(_ signer: ApprovalSigner) throws + /// Keep a replacement beside the current key. + func keepPending(_ signer: ApprovalSigner) throws + /// The replacement reached the box: it becomes this home's key. + func promote() throws + /// The box refused the replacement: it goes. + func dropPending() + /// The approval is over; the Face ID it was given covers nothing more. + func end() +} + +/// The person backed out of Face ID. Nothing was sent. +public struct ApprovalCancelled: Error, Equatable { + public init() {} +} + +/// The key is there but cannot sign: Face ID was enrolled again. +public struct ApprovalKeyLost: Error, Equatable { + public init() {} +} + +/// Face ID is locked after too many tries; the passcode opens it again. +public struct ApprovalLocked: Error, Equatable { + public init() {} +} + +/// The phone could not sign, for a reason that is not a lost key. +public struct ApprovalSigningFailed: Error, Equatable { + public init() {} +} + +/// The phone could not keep a key, so it sent nothing that would need it. +public struct ApprovalKeyNotKept: Error, Equatable { + public init() {} +} + +/// What the owner grants: a subset of what an offer asks, or a row's new +/// rights. +public struct Grant: Equatable, Sendable { + public var scopes: [String] + public var ops: [String] + public var caps: Caps + public var lengthMs: Int64 + + /// An end the grant keeps as it is, instead of a new length. + public var expiresAtMs: Int64? + + public init(scopes: [String], ops: [String], caps: Caps, lengthMs: Int64, expiresAtMs: Int64? = nil) { + self.scopes = Approval.sorted(scopes) + self.ops = Approval.sorted(ops) + self.caps = caps + self.lengthMs = lengthMs + self.expiresAtMs = expiresAtMs + } + + /// The lengths an owner may pick, longest last, within the role's. + public static func lengths(role: String, offeredS: UInt64? = nil) -> [Int64] { + let day: Int64 = 86_400_000 + var most = AccessWire.longestMs(role) + if let offeredS, offeredS > 0 { most = min(most, Int64(offeredS) * 1000) } + let picks = role == AccessWire.roleSupport ? [day, 3 * day, 7 * day, 14 * day] : [day, 7 * day, 30 * day] + let fit = picks.filter { $0 <= most } + return fit.isEmpty ? [most] : fit + } + + /// What the owner grants from an offer: what it reads, and control only + /// when ticked. Never more than it asks, and never past the box's own + /// limits. + public static func from(_ offer: GrantOffer, control: Bool, lengthMs: Int64) -> Grant { + let allowed = Set(Contract.agentRoleScopes[offer.role] ?? []) + let asked = Set(offer.scopes).intersection(allowed) + let scopes = control ? asked : asked.subtracting(AccessWire.controlScopes) + let ops = offer.ops.filter { op in Contract.agentOps[op].map(scopes.contains) ?? false } + var caps = Caps(perMin: min(offer.caps.perMin, 60)) + if scopes.contains(AccessWire.scopeHold) { + caps.holdS = min(offer.caps.holdS, AccessWire.holdSRange.upperBound) + caps.maxW = min(offer.caps.maxW, AccessWire.maxWRange.upperBound) + } + let longest = lengths(role: offer.role, offeredS: offer.ttlS).last ?? lengthMs + return Grant(scopes: Array(scopes), ops: ops, caps: caps, lengthMs: min(lengthMs, longest)) + } + + /// A row's rights as they stand, with a new length. + public static func from(_ row: AgentRow, control: Bool? = nil, lengthMs: Int64) -> Grant { + Grant(scopes: row.scopes, ops: row.ops, caps: row.caps, lengthMs: lengthMs) + } + + public var hasControl: Bool { !AccessWire.controlScopes.isDisjoint(with: scopes) } +} + +/// Who may reach this home other than its paired phones and watches: the +/// assistants and Sourceful support the owner let in, what they asked for, +/// and what they did (docs/agents.md in srcfl/ftw). +/// +/// The box decides everything; this asks it, shows what it said and sends +/// the owner's word. Granting, changing a grant and approving a request are +/// signed with this phone's approval key behind one Face ID. Removing access +/// and declining a request are not, so they take one tap. +@Observable +@MainActor +public final class AccessModel { + /// Nil until the box has answered once. + public private(set) var list: AgentList? + public private(set) var audit: [AuditRow] = [] + public private(set) var auditMore = false + /// What the owner wrote for each support ticket, on this phone only. + public private(set) var tickets: [TicketNote] = [] + /// Where each client lives, as the offer this phone approved said. The + /// row carries only the client's id, which for a registered client is + /// no address. + public private(set) var clients: [ClientNote] = [] + /// The box's answer to `agent.list` has arrived this visit, so `keys` + /// is the box's word and not a cache's: only then may this phone + /// decide it has no key there. + public private(set) var keysKnown = false + /// The row or request a change is on its way for. + public private(set) var working: String? + /// What went wrong, in words that say what happens now. + public private(set) var help: String? + + public let site: SiteModel + public let signedFor: SignedFor + @ObservationIgnored let keyring: ApprovalKeyring? + @ObservationIgnored let wallMs: @MainActor () -> Double + /// Box wall clock minus this phone's, once the box has said + /// `E_CLOCK_SKEW`. + @ObservationIgnored private var boxOffsetMs: Double = 0 + + /// For the simulated home: a box key and a device id the simulated box + /// knows too. + public static let demoFor = SignedFor(boxKey: Bytes(repeating: 0x5a, count: 32), deviceID: "demoPh01") + + public init(site: SiteModel, signedFor: SignedFor, keyring: ApprovalKeyring?, wallMs: @escaping @MainActor () -> Double = { Date().timeIntervalSince1970 * 1000 }) { + self.site = site + self.signedFor = signedFor + self.keyring = keyring + self.wallMs = wallMs + // The rows the last visit saw, so the list opens on them; the box's + // answer replaces them within a round trip. + if let rows = site.keptFile([AgentRow].self, "access") { list = AgentList(agents: rows) } + tickets = site.keptFile([TicketNote].self, "tickets") ?? [] + clients = site.keptFile([ClientNote].self, "clients") ?? [] + site.keepFile("access") { [weak self] in self?.list?.agents } + site.keepFile("tickets") { [weak self] in self?.tickets } + site.keepFile("clients") { [weak self] in self?.clients } + } + + /// Where a grant's client lives: the offer's host when this phone + /// approved it, else the host of a client id that is an address, else + /// nothing. + public func host(_ row: AgentRow) -> String { + clients.first { $0.id == row.id }?.host ?? AccessWords.host(row) + } + + /// What goes beside a grant's name: the ticket for support, else where + /// the client lives, else its id, so a name of its own choosing never + /// stands alone. + public func place(_ row: AgentRow) -> String { + if row.isSupport { return AccessWords.format("Ticket %@", AccessWords.clean(row.principal.ticket)) } + let host = host(row) + if !host.isEmpty { return host } + let idName = AccessWords.format("Assistant %@", row.id) + return AccessWords.name(row) == idName ? "" : idName + } + + /// What the client asked for when this phone approved it. + public func asked(_ row: AgentRow) -> ClientNote? { + clients.first { $0.id == row.id } + } + + /// A grant's name and where its client lives, for the system's Face ID + /// sheet: the name is the client's own word, the host is not. + public func who(_ row: AgentRow) -> (name: String, host: String) { + (AccessWords.name(row), host(row)) + } + + /// Whether FTW Cloud knew the client when this phone approved it. Nil + /// when another phone or the box's page did. + public func verified(_ row: AgentRow) -> Bool? { + clients.first { $0.id == row.id }?.verified + } + + /// Keep what the owner wrote for a ticket beside its id. + public func note(_ ticket: String, problem: String) { + tickets.removeAll { $0.ticket == ticket } + tickets.append(TicketNote(ticket: ticket, problem: problem, atMs: wallMs())) + site.persistNow() + } + + public func problem(_ row: AgentRow) -> String? { + tickets.first { $0.ticket == row.principal.ticket }?.problem + } + + /// The box takes agents and this phone is an owner there. + public var available: Bool { + site.heardFromBox && site.canConfigure && site.session.caps.contains(Contract.capAccessAgents) + } + + public var assistants: [AgentRow] { (list?.agents ?? []).filter { !$0.isSupport } } + public var support: [AgentRow] { (list?.agents ?? []).filter(\.isSupport) } + public var pending: [AgentRequest] { (list?.requests ?? []).filter(\.pending) } + + /// The box holds an approval key for this phone. + public var boxHasKey: Bool { list?.keys.contains { $0.deviceId == signedFor.deviceID } ?? false } + + public func row(_ id: String) -> AgentRow? { list?.agents.first { $0.id == id } } + + var nowMs: Int64 { Int64((wallMs() + boxOffsetMs).rounded()) } + + /// The home the approval key is kept under. The simulated home has no + /// id of its own. + var keyHome: String? { site.siteId ?? (site.isDemo ? "demo" : nil) } + + // MARK: Reading + + public func refresh() async { + guard available else { return } + do { + list = AgentList(try await site.ask(AccessWire.msgList, .emptyMap)) + keysKnown = true + help = nil + } catch { + help = words(error) + } + } + + /// The newest page of the audit log, or the next older one. + public func loadAudit(more: Bool = false) async { + guard available else { return } + var body: [(String, CBOR)] = [("n", .unsigned(100))] + if more, let last = audit.last { body.append(("beforeSeq", .unsigned(last.seq))) } + do { + let page = AuditPage(try await site.ask(AccessWire.msgAudit, .map(body))) + audit = more ? audit + page.rows : page.rows + auditMore = page.more + } catch { + help = words(error) + } + } + + // MARK: Granting + + /// Grant what the owner chose from an offer, behind one Face ID. A phone + /// whose key the box does not hold yet registers it under the same Face + /// ID first. + @discardableResult + public func approve(_ offer: GrantOffer, grant: Grant) async -> AgentRow? { + guard !grant.scopes.isEmpty else { + help = AccessWords.text("Choose at least one thing it may do.") + return nil + } + if list?.agents.contains(where: { $0.key == offer.key }) == true { + help = AccessWords.offer(.used) + return nil + } + let principal = Principal(client: Self.cut(offer.client), clientId: Self.cut(offer.clientId), ticket: Self.cut(offer.ticket)) + let reason = AccessWords.format("Let %@ from %@ reach your home", AccessWords.clientName(offer), offer.clientHost) + return await signed(reason, target: "offer") { signer in + var add = AgentAdd(key: offer.key, role: offer.role, scopes: grant.scopes, ops: grant.ops, caps: grant.caps, + expiresAtMs: self.expiry(grant.lengthMs), principal: principal, atMs: self.nowMs) + add.sig = try signer.sign(try Approval.addBytes(self.signedFor, add)) + let ok = try await self.site.ask(AccessWire.msgAdd, add.cbor) + guard let row = ok["agent"].flatMap(AgentRow.init) else { return nil } + self.clients.removeAll { $0.id == row.id } + self.clients.append(ClientNote(id: row.id, host: offer.clientHost, verified: offer.verified, + askedOps: offer.ops, askedCaps: offer.caps)) + self.site.persistNow() + return row + } + } + + /// Change a grant: wider, narrower or longer. Signed like a grant. + @discardableResult + public func widen(_ row: AgentRow, to grant: Grant) async -> Bool { + guard !grant.scopes.isEmpty else { + help = AccessWords.text("Choose at least one thing it may do.") + return false + } + let reason = reasonFor("Change what %@ may do", "Change what %@ from %@ may do", row) + let changed: AgentRow? = await signed(reason, target: row.id) { signer in + var m = AgentWiden(id: row.id, epoch: row.epoch, scopes: grant.scopes, ops: grant.ops, caps: grant.caps, + expiresAtMs: grant.expiresAtMs ?? self.expiry(grant.lengthMs), atMs: self.nowMs) + m.sig = try signer.sign(try Approval.widenBytes(self.signedFor, m, key: row.key)) + let ok = try await self.site.ask(AccessWire.msgWiden, m.cbor) + return ok["agent"].flatMap(AgentRow.init) ?? row + } + return changed != nil + } + + /// Remove access at once. No Face ID: removing is always safe. + @discardableResult + public func revoke(_ row: AgentRow) async -> Bool { + working = row.id + defer { working = nil } + do { + _ = try await site.ask(AccessWire.msgRevoke, .map([("id", .text(row.id))])) + if var l = list { + l.agents.removeAll { $0.id == row.id } + l.requests.removeAll { $0.agent == row.id } + list = l + } + help = nil + await refresh() + return true + } catch let r as BoxRefusal where r.detail.code == "E_NOT_FOUND" { + // Already gone, whoever removed it. + await refresh() + return true + } catch { + help = words(error) + return false + } + } + + // MARK: Requests + + /// Approve a request behind Face ID, signing exactly what was shown, or + /// decline it with one tap. + @discardableResult + public func answer(_ request: AgentRequest, approve: Bool) async -> Bool { + guard approve else { + working = request.reqId + defer { working = nil } + do { + _ = try await site.ask(AccessWire.msgConfirm, .map([("reqId", .text(request.reqId)), ("approve", .bool(false))])) + help = nil + await refresh() + return true + } catch { + await refresh() + help = words(error) + return false + } + } + guard AccessWords.approvable(request) else { + help = AccessWords.text("This request has something this app can't show you, so it can only be declined.") + return false + } + let reason = row(request.agent).map { reasonFor("Let %@ make this change", "Let %@ from %@ make this change", $0) } + ?? AccessWords.format("Let %@ make this change", agentName(request.agent)) + let done: Bool? = await signed(reason, target: request.reqId) { signer in + let atMs = self.nowMs + let sig = try signer.sign(Approval.confirmBytes(self.signedFor, reqId: request.reqId, atMs: atMs, request: request)) + _ = try await self.site.ask(AccessWire.msgConfirm, .map([ + ("reqId", .text(request.reqId)), ("approve", .bool(true)), ("atMs", .int(atMs)), ("sig", .bytes(sig)), + ]).deterministic) + return true + } + return done ?? false + } + + // MARK: The approval key + + /// A new approval key for this home, with the old key's signature so the + /// box takes it. One Face ID. The new key is kept beside the old one + /// before the box hears of it, so whichever the box ends up holding, + /// the phone still has. + @discardableResult + public func replaceKey() async -> Bool { + guard let keyring, let home = keyHome else { return false } + working = "key" + defer { working = nil } + do { + if !keysKnown { await refresh() } + guard keysKnown else { throw SessionError.noCarrier } + let unlock = try await keyring.unlock(home: home, reason: AccessWords.text("Replace the approval key on this phone")) + defer { unlock.end() } + guard let old = try unlock.existing() else { throw ApprovalKeyLost() } + let fresh = try unlock.make() + do { + try unlock.keepPending(fresh) + } catch { + throw ApprovalKeyNotKept() + } + do { + try await retryingSkew { + let atMs = self.nowMs + let msg = Approval.keySetBytes(self.signedFor, atMs: atMs, pub: fresh.publicKey) + _ = try await self.site.ask(AccessWire.msgKeySet, .map([ + ("pub", .bytes(fresh.publicKey)), ("atMs", .int(atMs)), ("sig", .bytes(try fresh.sign(msg))), + ("prevSig", .bytes(try old.sign(msg))), + ]).deterministic) + } + } catch let r as BoxRefusal { + // The box said no: it still holds the old key. + unlock.dropPending() + throw r + } + try? unlock.promote() + help = nil + await refresh() + return true + } catch { + help = words(error) + return false + } + } + + // MARK: Signing + + /// One Face ID, the key registered at the box if it has none for this + /// phone, then `work` with the key; again once with the box's time if + /// the box says this phone's clock is off. The box's own list of keys + /// decides whether this phone registers one: never a cached list. + private func signed(_ reason: String, target: String, _ work: @escaping @MainActor (ApprovalSigner) async throws -> T?) async -> T? { + guard let keyring, let home = keyHome else { + help = AccessWords.text("This phone can't approve on its own. Use your box's page.") + return nil + } + guard available else { + help = AccessWords.help(SessionError.noCarrier) + return nil + } + working = target + defer { working = nil } + if !keysKnown { await refresh() } + guard keysKnown else { + help = help ?? AccessWords.help(SessionError.noCarrier) + return nil + } + do { + let unlock = try await keyring.unlock(home: home, reason: reason) + defer { unlock.end() } + let keys = try await registered(unlock) + let out: T? + do { + out = try await retryingSkew { try await work(keys.signer) } + if keys.isPending { try? unlock.promote() } + } catch let r as BoxRefusal where r.detail.code == "E_BAD_SIGNATURE" && r.detail.args["field"] == nil && keys.spare != nil { + // A replacement the box took though its answer never came + // back: the spare is the key the box holds. + out = try await retryingSkew { try await work(keys.spare!) } + try? unlock.promote() + } + help = nil + await refresh() + return out + } catch { + await refresh() + help = words(error) + return nil + } + } + + /// This home's key, registered at the box if the box has none for this + /// phone: the one natural moment, under the Face ID the owner just gave. + /// A first key is kept before the box hears of it. With a replacement + /// waiting, the current key signs first and the replacement is the + /// spare. + private func registered(_ unlock: ApprovalUnlock) async throws -> (signer: ApprovalSigner, spare: ApprovalSigner?, isPending: Bool) { + let have = try unlock.existing() + let waiting = try? unlock.pending() + if boxHasKey { + if let have { return (have, waiting, false) } + if let waiting { return (waiting, nil, true) } + throw ApprovalKeyLost() + } + let signer: ApprovalSigner + if let have { + signer = have + } else { + signer = try unlock.make() + do { + try unlock.keep(signer) + } catch { + throw ApprovalKeyNotKept() + } + } + try await retryingSkew { + let atMs = self.nowMs + let msg = Approval.keySetBytes(self.signedFor, atMs: atMs, pub: signer.publicKey) + _ = try await self.site.ask(AccessWire.msgKeySet, .map([ + ("pub", .bytes(signer.publicKey)), ("atMs", .int(atMs)), ("sig", .bytes(try signer.sign(msg))), + ]).deterministic) + } + if var l = list { + l.keys.append(ApprovalKeyInfo(deviceId: signedFor.deviceID, setAtMs: nowMs)) + list = l + } + return (signer, nil, false) + } + + /// Once more with the box's clock, when the box says this phone's is + /// more than ten minutes off. The same Face ID covers it. + private func retryingSkew(_ attempt: @MainActor () async throws -> T) async throws -> T { + do { + return try await attempt() + } catch let r as BoxRefusal where r.detail.code == "E_CLOCK_SKEW" { + guard let boxMs = r.detail.args["boxMs"]?.double else { throw r } + boxOffsetMs = boxMs - wallMs() + return try await attempt() + } + } + + /// The expiry for a length, kept inside the role's longest even when + /// this phone's clock runs a little ahead of the box's. + func expiry(_ lengthMs: Int64) -> Int64 { + nowMs + lengthMs - Approval.skewMs + } + + // MARK: Words + + /// An agent row's name, or its id when the box no longer has it. + public func agentName(_ id: String) -> String { + row(id).map(AccessWords.name) ?? AccessWords.format("Assistant %@", AccessWords.clean(id)) + } + + /// The Face ID sheet's line: the name, and the host beside it when the + /// phone knows one. + func reasonFor(_ bare: String, _ withHost: String, _ row: AgentRow) -> String { + let (name, host) = who(row) + return host.isEmpty || row.isSupport ? AccessWords.format(bare, name) : AccessWords.format(withHost, name, host) + } + + /// A principal field cut to 256 bytes of UTF-8, at a character's edge. + static func cut(_ text: String, max: Int = 256) -> String { + var out = "" + var used = 0 + for ch in text { + let n = String(ch).utf8.count + if used + n > max { break } + out.append(ch) + used += n + } + return out + } + + func words(_ error: Error) -> String? { + AccessWords.help(error) + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Access/AccessWire.swift b/appleApp/FTWKit/Sources/FTWKit/Access/AccessWire.swift new file mode 100644 index 0000000..ef0dba6 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Access/AccessWire.swift @@ -0,0 +1,328 @@ +import Foundation + +/// The agent messages on the app session (docs/agents.md in srcfl/ftw, +/// Messages), in the shapes `go/protocol/agents.go` gives them. Every +/// request gets `agent.ok` with its id, or an `error` with that id. +public enum AccessWire { + public static let msgKeySet = Approval.keySet + public static let msgAdd = Approval.add + public static let msgWiden = Approval.widen + public static let msgConfirm = Approval.confirm + public static let msgRevoke = "agent.revoke" + public static let msgList = "agent.list" + public static let msgAudit = "audit.get" + public static let msgOK = "agent.ok" + + public static let roleAgent = Contract.roleAgent + public static let roleSupport = Contract.roleSupport + public static let roleRelease = Contract.roleRelease + + /// The one client a support offer may come from: the support tooling's + /// client at FTW Cloud. + public static let supportClientID = "https://cloud.ftw.energy/clients/sourceful-support" + + /// The longest grant each role may have, in ms. + public static func longestMs(_ role: String) -> Int64 { + switch role { + case roleSupport: return 14 * 86_400_000 + case roleRelease: return 90 * 86_400_000 + default: return 30 * 86_400_000 + } + } + + /// The scopes an owner ticks "control" to add. + public static let scopeHold = "ftw.dispatch.hold" + public static let scopeRequest = "ftw.goals.request" + public static let controlScopes: Set = [scopeHold, scopeRequest] + + /// The box's own limits on caps. + public static let holdSRange: ClosedRange = 60...1_800 + public static let maxWRange: ClosedRange = 1...100_000 + public static let perMinDefault: UInt64 = 6 +} + +public struct Caps: Equatable, Sendable, Codable { + public var holdS: UInt64 + public var maxW: UInt64 + public var perMin: UInt64 + + public init(holdS: UInt64 = 0, maxW: UInt64 = 0, perMin: UInt64 = 0) { + self.holdS = holdS + self.maxW = maxW + self.perMin = perMin + } + + init(_ c: CBOR?) { + holdS = c?["holdS"]?.uint ?? 0 + maxW = c?["maxW"]?.uint ?? 0 + perMin = c?["perMin"]?.uint ?? 0 + } + + var cbor: CBOR { .map([("holdS", .unsigned(holdS)), ("maxW", .unsigned(maxW)), ("perMin", .unsigned(perMin))]) } + /// `[holdS, maxW, perMin]`, as the signature carries them. + var array: CBOR { .array([.unsigned(holdS), .unsigned(maxW), .unsigned(perMin)]) } +} + +public struct Principal: Equatable, Sendable, Codable { + public var client: String + public var clientId: String + public var ticket: String + + public init(client: String = "", clientId: String = "", ticket: String = "") { + self.client = client + self.clientId = clientId + self.ticket = ticket + } + + init(_ c: CBOR?) { + client = c?["client"]?.string ?? "" + clientId = c?["clientId"]?.string ?? "" + ticket = c?["ticket"]?.string ?? "" + } + + var cbor: CBOR { .map([("client", .text(client)), ("clientId", .text(clientId)), ("ticket", .text(ticket))]) } +} + +/// `agent.add`: an owner grants access. +public struct AgentAdd: Equatable, Sendable { + public var key: Bytes + public var role: String + public var scopes: [String] + public var ops: [String] + public var caps: Caps + public var expiresAtMs: Int64 + public var principal: Principal + public var atMs: Int64 + public var sig: Bytes = [] + + public init(key: Bytes, role: String, scopes: [String], ops: [String], caps: Caps, expiresAtMs: Int64, principal: Principal, atMs: Int64) { + self.key = key + self.role = role + self.scopes = scopes + self.ops = ops + self.caps = caps + self.expiresAtMs = expiresAtMs + self.principal = principal + self.atMs = atMs + } + + public var cbor: CBOR { + .map([ + ("key", .bytes(key)), ("role", .text(role)), + ("scopes", .array(scopes.map { .text($0) })), ("ops", .array(ops.map { .text($0) })), + ("caps", caps.cbor), ("expiresAtMs", .int(expiresAtMs)), ("principal", principal.cbor), + ("atMs", .int(atMs)), ("sig", .bytes(sig)), + ]).deterministic + } +} + +/// `agent.widen`: the whole new grant for a row, signed. Any change but a +/// revoke is one, narrowing included. +public struct AgentWiden: Equatable, Sendable { + public var id: String + public var epoch: UInt64 + public var scopes: [String] + public var ops: [String] + public var caps: Caps + public var expiresAtMs: Int64 + public var atMs: Int64 + public var sig: Bytes = [] + + public init(id: String, epoch: UInt64, scopes: [String], ops: [String], caps: Caps, expiresAtMs: Int64, atMs: Int64) { + self.id = id + self.epoch = epoch + self.scopes = scopes + self.ops = ops + self.caps = caps + self.expiresAtMs = expiresAtMs + self.atMs = atMs + } + + public var cbor: CBOR { + .map([ + ("id", .text(id)), ("epoch", .unsigned(epoch)), + ("scopes", .array(scopes.map { .text($0) })), ("ops", .array(ops.map { .text($0) })), + ("caps", caps.cbor), ("expiresAtMs", .int(expiresAtMs)), ("atMs", .int(atMs)), ("sig", .bytes(sig)), + ]).deterministic + } +} + +/// One agent grant as the box holds it. +public struct AgentRow: Equatable, Sendable, Identifiable, Codable { + public var id: String + public var key: Bytes + public var role: String + public var scopes: [String] + public var ops: [String] + public var caps: Caps + public var expiresAtMs: Int64 + public var principal: Principal + /// `app:` or `lan`. + public var approvedBy: String + public var addedAtMs: Int64 + /// 0 when it has never called. + public var lastSeenMs: Int64 + public var epoch: UInt64 + + public init(id: String, key: Bytes, role: String, scopes: [String], ops: [String], caps: Caps, expiresAtMs: Int64, + principal: Principal, approvedBy: String = "", addedAtMs: Int64 = 0, lastSeenMs: Int64 = 0, epoch: UInt64 = 1) { + self.id = id + self.key = key + self.role = role + self.scopes = scopes + self.ops = ops + self.caps = caps + self.expiresAtMs = expiresAtMs + self.principal = principal + self.approvedBy = approvedBy + self.addedAtMs = addedAtMs + self.lastSeenMs = lastSeenMs + self.epoch = epoch + } + + init?(_ c: CBOR) { + guard let id = c["id"]?.string, let key = c["key"]?.byteString else { return nil } + self.init( + id: id, key: key, role: c["role"]?.string ?? "", scopes: c["scopes"]?.stringArray ?? [], + ops: c["ops"]?.stringArray ?? [], caps: Caps(c["caps"]), expiresAtMs: c["expiresAtMs"]?.int64 ?? 0, + principal: Principal(c["principal"]), approvedBy: c["approvedBy"]?.string ?? "", + addedAtMs: c["addedAtMs"]?.int64 ?? 0, lastSeenMs: c["lastSeenMs"]?.int64 ?? 0, epoch: c["epoch"]?.uint ?? 0 + ) + } + + public var isSupport: Bool { role == AccessWire.roleSupport } + /// Holds a charger or asks for a durable change. + public var hasControl: Bool { !AccessWire.controlScopes.isDisjoint(with: scopes) } +} + +/// A durable change an agent asked for, waiting for an owner. +public struct AgentRequest: Equatable, Sendable, Identifiable { + public var reqId: String + public var agent: String + public var op: String + /// As the box sent them: the hash of exactly these is what approving + /// signs. + public var args: CBOR + /// The agent's own words, never the box's. + public var why: String + public var atMs: Int64 + public var expiresAtMs: Int64 + /// pending, approved, declined or expired. + public var state: String + + public var id: String { reqId } + public var pending: Bool { state == "pending" } + + public init(reqId: String, agent: String, op: String, args: CBOR, why: String, atMs: Int64, expiresAtMs: Int64, state: String) { + self.reqId = reqId + self.agent = agent + self.op = op + self.args = args + self.why = why + self.atMs = atMs + self.expiresAtMs = expiresAtMs + self.state = state + } + + init?(_ c: CBOR) { + guard let reqId = c["reqId"]?.string, let agent = c["agent"]?.string, let op = c["op"]?.string else { return nil } + self.init(reqId: reqId, agent: agent, op: op, args: c["args"] ?? .emptyMap, why: c["why"]?.string ?? "", + atMs: c["atMs"]?.int64 ?? 0, expiresAtMs: c["expiresAtMs"]?.int64 ?? 0, state: c["state"]?.string ?? "") + } +} + +/// An owner phone that can approve, by its device id. +public struct ApprovalKeyInfo: Equatable, Sendable { + public var deviceId: String + public var setAtMs: Int64 + + init?(_ c: CBOR) { + guard let d = c["deviceId"]?.string else { return nil } + deviceId = d + setAtMs = c["setAtMs"]?.int64 ?? 0 + } + + public init(deviceId: String, setAtMs: Int64) { + self.deviceId = deviceId + self.setAtMs = setAtMs + } +} + +/// `agent.list` to an owner: every row on the box. +public struct AgentList: Equatable, Sendable { + public var agents: [AgentRow] + public var requests: [AgentRequest] + public var keys: [ApprovalKeyInfo] + + public init(agents: [AgentRow] = [], requests: [AgentRequest] = [], keys: [ApprovalKeyInfo] = []) { + self.agents = agents + self.requests = requests + self.keys = keys + } + + init(_ c: CBOR) { + agents = (c["agents"]?.array ?? []).compactMap(AgentRow.init) + requests = (c["requests"]?.array ?? []).compactMap(AgentRequest.init) + keys = (c["keys"]?.array ?? []).compactMap(ApprovalKeyInfo.init) + } +} + +/// One line of the box's audit log. +public struct AuditRow: Equatable, Sendable, Identifiable { + public var seq: UInt64 + public var atMs: Int64 + /// `agent:`, `app:`, `lan:
` or `box`. + public var actor: String + public var role: String + public var principal: String + /// Who made a support call, as FTW Cloud named them: `staff:` or + /// `bot:`. The box cannot check it. + public var by: String + public var what: String + public var target: String + /// `ok` or an error code. + public var result: String + /// Folded reads and refusals: how many of `what` in that minute. + public var count: UInt64 + + public var id: UInt64 { seq } + + public init(seq: UInt64, atMs: Int64, actor: String, role: String = "", principal: String = "", by: String = "", + what: String, target: String = "", result: String = "ok", count: UInt64 = 0) { + self.seq = seq + self.atMs = atMs + self.actor = actor + self.role = role + self.principal = principal + self.by = by + self.what = what + self.target = target + self.result = result + self.count = count + } + + init?(_ c: CBOR) { + guard let seq = c["seq"]?.uint else { return nil } + self.init(seq: seq, atMs: c["atMs"]?.int64 ?? 0, actor: c["actor"]?.string ?? "", role: c["role"]?.string ?? "", + principal: c["principal"]?.string ?? "", by: c["by"]?.string ?? "", what: c["what"]?.string ?? "", + target: c["target"]?.string ?? "", result: c["result"]?.string ?? "", count: c["count"]?.uint ?? 0) + } +} + +public struct AuditPage: Equatable, Sendable { + public var rows: [AuditRow] + public var more: Bool + + init(_ c: CBOR) { + rows = (c["rows"]?.array ?? []).compactMap(AuditRow.init) + more = c["more"]?.bool ?? false + } +} + +extension CBOR { + /// A non-negative integer. + var uint: UInt64? { + if case .unsigned(let u) = self { return u } + return nil + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift b/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift new file mode 100644 index 0000000..7b5d779 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift @@ -0,0 +1,663 @@ +import Foundation + +/// The words of Access, Get help and the approval sheets, in English or +/// Swedish, with the language `AppWords` uses. A scope, an op or an argument +/// is named by what it lets someone do, never by its registry name. +public enum AccessWords { + /// What this phone unlocks with, as the system names it: "Face ID" or + /// "Touch ID". The app sets it once at launch. + nonisolated(unsafe) public static var biometry = "Face ID" + + public static func text(_ en: String) -> String { + AppWords.language == .sv ? (sv[en] ?? en) : en + } + + public static func format(_ en: String, _ args: String...) -> String { + var out = text(en) + for a in args { + guard let r = out.range(of: "%@") else { break } + out.replaceSubrange(r, with: a) + } + return out + } + + // MARK: Who + + /// The name a client gives itself, quoted: its own word, never checked. + /// Only a support grant, which the box takes from Sourceful's client + /// alone, is called Sourceful support. A name that claims Sourceful or + /// FTW is no name. + public static func clientName(_ offer: GrantOffer) -> String { + if offer.isSupport { return text("Sourceful support") } + return ownName(offer.client) ?? format("Assistant at %@", offer.clientHost) + } + + public static func name(_ row: AgentRow) -> String { + if row.isSupport { return text("Sourceful support") } + return ownName(row.principal.client) ?? format("Assistant %@", row.id) + } + + /// The client's name, cleaned and quoted, or nil when it has none it + /// may use: empty, or one that names Sourceful or FTW. + public static func ownName(_ raw: String) -> String? { + let name = clean(raw).trimmingCharacters(in: .whitespaces) + let folded = name.folding(options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive], locale: nil) + guard !name.isEmpty, !folded.contains("sourceful"), !folded.contains("ftw") else { return nil } + return "“\(name.prefix(64))”" + } + + /// Whether a client's own name was set aside because it named Sourceful + /// or FTW. + public static func nameHidden(_ raw: String) -> Bool { + !clean(raw).trimmingCharacters(in: .whitespaces).isEmpty && ownName(raw) == nil + } + + /// Text from outside, with control and format characters taken out: + /// no bidi overrides, no zero-width joiners, no line breaks. + public static func clean(_ raw: String) -> String { + String(String.UnicodeScalarView(raw.unicodeScalars.map { s -> Unicode.Scalar in + switch s.properties.generalCategory { + case .control, .format, .lineSeparator, .paragraphSeparator: return " " + default: return s + } + })) + } + + /// A whole number from outside, or nil when it is none or too large to + /// mean anything here. + static func whole(_ d: Double) -> Int? { + guard d.isFinite, d.rounded() == d, abs(d) < 1e12 else { return nil } + return Int(d) + } + + /// Where the client lives, when its id is an address: the host of its + /// metadata document. A registered client's id is no address, and + /// names nothing. + public static func host(_ row: AgentRow) -> String { + guard let url = URL(string: row.principal.clientId), url.scheme == "https", let host = url.host else { return "" } + return host + } + + // MARK: Rights + + /// What a scope lets an agent do, in plain words. + public static func scope(_ name: String) -> String { + text(scopeWords[name] ?? Contract.scopeDescs[name] ?? name) + } + + static let scopeWords: [String: String] = [ + "ftw.live.read": "See live readings", + "ftw.history.read": "See history", + "ftw.plan.read": "See the plan and prices", + "ftw.health.read": "See how the box and its devices are doing", + "ftw.events.read": "See events", + "ftw.logs.read": "Read the box's log, with names taken out", + "ftw.config.read": "Read the settings, with names and secrets taken out", + "ftw.diag.run": "Run checks that change nothing", + "ftw.dispatch.hold": "Charge the car for a short while", + "ftw.goals.request": "Ask you for lasting changes", + ] + + /// The scopes an owner reads in a list: what it reads first, control + /// after, in the registry's order. + public static func ordered(_ scopes: [String]) -> [String] { + let order = Contract.agentRoleScopes[Contract.roleAgent] ?? [] + return scopes.sorted { (order.firstIndex(of: $0) ?? 99) < (order.firstIndex(of: $1) ?? 99) } + } + + /// One line for a grant: "Reads" or "Reads and controls". + public static func summary(_ scopes: [String]) -> String { + AccessWire.controlScopes.isDisjoint(with: scopes) ? text("Reads") : text("Reads and controls") + } + + /// What control means for these caps: how long and how hard it may + /// charge, and that lasting changes wait for the owner. + public static func control(_ caps: Caps, scopes: [String]) -> String { + var parts = [String]() + if scopes.contains(AccessWire.scopeHold) { + parts.append(format("It may charge the car for up to %@ at a time, at most %@.", minutes(Double(caps.holdS)), AppWords.power(Double(caps.maxW)))) + } + if scopes.contains(AccessWire.scopeRequest) { + parts.append(format("Lasting changes, such as the mode or a goal, wait for your %@.", biometry)) + } + return parts.joined(separator: " ") + } + + /// What an op does, in plain words. + public static func op(_ name: String) -> String { + text(opWords[name] ?? Contract.opDescs[name] ?? name) + } + + static let opWords: [String: String] = [ + "site.mode.set": "Change how the home is run", + "planner.prefs.set": "Change the plan's settings", + "loadpoint.hold": "Charge the car now", + "loadpoint.soc.set": "Correct the car's level", + "loadpoint.surplus_only.set": "Charge the car from solar only", + "car.goal.set": "Change when and how far the car charges", + "car.soc.set": "Set the car's level", + ] + + /// Whether the app can show every part of a request as the registry + /// names it: a known op open to agents, every key one the registry + /// lists for it, each value of its kind. Anything else may only be + /// declined. + public static func approvable(_ request: AgentRequest) -> Bool { + guard Contract.agentOps[request.op] != nil, let defs = Contract.agentArgs[request.op], + let given = request.args.entries ?? (request.args.isNull ? [] : nil) else { return false } + for e in given { + guard let key = e.key.string, let def = defs.first(where: { $0.name == key }), fits(e.value, def) else { return false } + } + return true + } + + static func fits(_ v: CBOR, _ def: Contract.AgentArg) -> Bool { + switch def.kind { + case "mode", "loadpoint", "car": + return v.string.map { !$0.isEmpty && $0.utf8.count <= 64 } ?? false + case "enum": + return v.string.map(def.values.contains) ?? false + case "bool": + return v.bool != nil + default: + guard let d = v.double, d.isFinite else { return false } + if let min = def.min, d < min { return false } + if let max = def.max, d > max { return false } + if def.name == "ready_by_min" || def.name == "days" { return whole(d) != nil } + return abs(d) < 1e12 + } + } + + /// A request's arguments, each by its registry label, in the registry's + /// order, then anything the registry does not name. + public static func arguments(_ request: AgentRequest, modes: [ModeInfo] = []) -> [(label: String, value: String)] { + let defs = Contract.agentArgs[request.op] ?? [] + let given = request.args.entries ?? [] + var out = [(label: String, value: String)]() + for def in defs { + guard let v = given.first(where: { $0.key.string == def.name })?.value else { continue } + out.append((text(def.label), value(v, def, modes: modes))) + } + for e in given where !defs.contains(where: { $0.name == e.key.string }) { + out.append((clean(e.key.string ?? "?"), raw(e.value))) + } + return out + } + + static func value(_ v: CBOR, _ def: Contract.AgentArg, modes: [ModeInfo]) -> String { + // A number from outside that is no number here is shown as sent. + if let d = v.double, !d.isFinite || abs(d) >= 1e12 { return raw(v) } + switch def.kind { + case "mode": + guard let key = v.string else { return raw(v) } + if let m = modes.first(where: { $0.key == key }) { return ModeWords.name(m) } + return ModeWords.names[key].map(NodeWords.text) ?? key + case "enum": + guard let s = v.string else { return raw(v) } + return text(enumWords[s] ?? s) + case "bool": + guard let b = v.bool else { return raw(v) } + return text(b ? "Yes" : "No") + case "ratio": + guard let d = v.double else { return raw(v) } + return AppWords.percent(d * 100) + case "power": + guard let d = v.double else { return raw(v) } + return AppWords.power(d) + case "duration": + guard let d = v.double else { return raw(v) } + return minutes(d) + case "loadpoint", "car": + // The agent's own id for it. Only the box can tell which one. + return raw(v) + default: + guard let d = v.double else { return raw(v) } + switch def.name { + case "ready_by_min": return whole(d).map { AppWords.clock(minute: $0) } ?? raw(v) + case "days": return whole(d).map(EVText.days) ?? raw(v) + default: return AppWords.decimal(whole(d).map(String.init) ?? String(d)) + } + } + } + + static let enumWords: [String: String] = [ + "unknown": "Not set", + "not_allowed": "No", + "allowed": "Yes", + "auto": "Automatic", + "1p": "One phase", + "3p": "Three phases", + ] + + static func raw(_ v: CBOR) -> String { + switch v { + case .text(let s): return String(clean(s).prefix(80)) + case .bool(let b): return b ? "true" : "false" + case .unsigned, .negative, .double: return v.double.map { whole($0).map(String.init) ?? String($0) } ?? "?" + case .bytes(let b): return Base64url.encode(b) + default: return "…" + } + } + + /// "30 min", "1 h", "1 h 30 min". + public static func minutes(_ seconds: Double) -> String { + guard let m = whole((seconds / 60).rounded()) else { return "?" } + if m < 60 { return "\(max(1, m))\u{00A0}min" } + let h = m / 60, rest = m % 60 + return rest == 0 ? "\(h)\u{00A0}h" : "\(h)\u{00A0}h \(rest)\u{00A0}min" + } + + // MARK: Time + + /// "1 day", "7 days", "30 days". + public static func length(_ ms: Int64) -> String { + let days = Int((Double(ms) / 86_400_000).rounded()) + return days == 1 ? text("1 day") : format("%@ days", String(days)) + } + + /// "Until 15 Nov", or "Ends today 18:00" when it ends within a day. + public static func until(_ ms: Int64, nowMs: Double, zone: TimeZone = .current) -> String { + if Double(ms) <= nowMs { return text("Ended") } + if Double(ms) - nowMs < 86_400_000 { return format("Ends %@", AppWords.day(Double(ms), nowMs: nowMs, zone: zone)) } + return format("Until %@", dayOnly(ms, zone: zone)) + } + + static func dayOnly(_ ms: Int64, zone: TimeZone) -> String { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = zone + let c = cal.dateComponents([.day, .month], from: Date(timeIntervalSince1970: Double(ms) / 1000)) + return "\(c.day ?? 1) \(AppWords.text(AppWords.monthLabels[((c.month ?? 1) + 11) % 12]))" + } + + /// "Never used" or "Last used today 14:03". + public static func lastUsed(_ ms: Int64, nowMs: Double, zone: TimeZone = .current) -> String { + ms <= 0 ? text("Never used") : format("Last used %@", AppWords.day(Double(ms), nowMs: nowMs, zone: zone)) + } + + /// The end as a value under "Ends": "8 Nov", "today 18:00" or "Ended". + public static func ends(_ ms: Int64, nowMs: Double, zone: TimeZone = .current) -> String { + if Double(ms) <= nowMs { return text("Ended") } + if Double(ms) - nowMs < 86_400_000 { return AppWords.day(Double(ms), nowMs: nowMs, zone: zone) } + return dayOnly(ms, zone: zone) + } + + /// The last call as a value under "Last used": "today 08:12" or "Never". + public static func lastCall(_ ms: Int64, nowMs: Double, zone: TimeZone = .current) -> String { + ms <= 0 ? text("Never") : AppWords.day(Double(ms), nowMs: nowMs, zone: zone) + } + + /// Where it was approved, as a value under "Approved". + public static func approvedWhere(_ row: AgentRow, me: String) -> String { + if row.approvedBy == "lan" { return text("On the box's page") } + let id = row.approvedBy.hasPrefix("app:") ? String(row.approvedBy.dropFirst(4)) : row.approvedBy + return id == me ? text("On this phone") : format("On phone %@", id) + } + + // MARK: The audit log + + /// Who did it, by name where this phone knows one. + public static func actor(_ row: AuditRow, me: String, agents: [AgentRow]) -> String { + let (kind, id) = split(row.actor) + switch kind { + case "agent": return agents.first { $0.id == id }.map(name) ?? format("Assistant %@", clean(id)) + case "app": return id == me ? text("This phone") : format("Phone %@", id) + case "lan": return id.isEmpty ? text("The box's page") : format("The box's page, %@", id) + case "box": return text("The box") + default: return row.actor + } + } + + /// The person at Sourceful behind a support call, as FTW Cloud named + /// them. The box cannot check it, and the line says so. + public static func by(_ row: AuditRow) -> String? { + guard !row.by.isEmpty else { return nil } + let (kind, raw) = split(row.by) + let id = clean(raw) + let who = kind == "bot" ? format("%@ (a Sourceful tool)", id) : id + return format("%@, named by FTW Cloud", who) + } + + /// What was done, in words, with how many times for folded reads. + public static func what(_ row: AuditRow) -> String { + let base = auditWords[row.what].map(text) ?? (Contract.opDescs[row.what] != nil ? op(row.what) : row.what) + return row.count > 1 ? "\(base) ×\(row.count)" : base + } + + /// Nil for a call that went through; otherwise what the box said. + public static func result(_ row: AuditRow) -> String? { + switch row.result { + case "", "ok", "approved": return nil + case "declined": return text("Declined") + default: return format("Refused (%@)", row.result) + } + } + + static let auditWords: [String: String] = [ + "agent.add": "Granted access", + "agent.widen": "Changed access", + "agent.revoke": "Removed access", + "agent.list": "Read who has access", + "agent.request": "Asked for a change", + "agent.request.run": "Ran an approved change", + "agent.confirm": "Answered a request", + "approval.key.set": "Set an approval key", + "audit.get": "Read the activity", + "session": "Session ended", + "hello": "Connected", + "cmd": "Sent a command", + "live.get": "Read live readings", + "loadpoints.get": "Read the chargers", + "cars.get": "Read the cars", + "plan.get": "Read the plan", + "price.get": "Read prices", + "hist.query": "Read history", + "health.get": "Read the health summary", + "drivers.get": "Read the devices' state", + "events.get": "Read events", + "logs.get": "Read the log", + "config.get": "Read the settings", + "diag.run": "Ran a check", + ] + + static func split(_ s: String) -> (String, String) { + guard let i = s.firstIndex(of: ":") else { return (s, "") } + return (String(s[.. String? { + switch error { + case is ApprovalCancelled: + return nil + case is ApprovalLocked: + return format("%@ is locked after too many tries. Unlock the phone with its passcode, then try again.", biometry) + case is ApprovalSigningFailed: + return text("Signing on this phone didn't work. Nothing was sent.") + case is ApprovalKeyNotKept: + return text("This phone couldn't keep its approval key. Nothing was sent.") + case is ApprovalKeyLost: + return format("The approval key on this phone stopped working when %@ changed. Replace it on your box's page with the house password.", biometry) + case let e as OfferError: + return offer(e) + case let r as BoxRefusal: + return refusal(r.detail) + case let e as HelpfulError: + return e.help + case is SessionError: + return text("Your box didn't answer. Nothing has changed. Try again when the home is live.") + default: + return text("That didn't work. Nothing on your box has changed.") + } + } + + public static func offer(_ e: OfferError) -> String { + switch e { + case .noPinnedKey: return text("This version of the app can't check offers from FTW Cloud yet.") + case .unsigned: return text("This offer isn't signed by FTW Cloud. Nothing was granted.") + case .expired: return text("This offer has run out. Start again on the page that showed it.") + case .malformed: return text("This isn't an offer this app can read. Nothing was granted.") + case .notFromSourceful: return text("This offer claims to come from Sourceful but comes from someone else. Nothing was granted.") + case .strangeHost: return text("This offer names its client in letters that could imitate another address. Nothing was granted.") + case .used: return text("This offer has been answered already. Start again on the page that showed it.") + case .unknownHome: return text("This offer is for a home this phone doesn't have.") + case .wrongCode: return text("That isn't the code on the page. Check it and type it again.") + case .notFound: return text("FTW Cloud has no offer under that code. It may have been used or run out.") + case .unreachable: return text("FTW Cloud couldn't be reached. Try again in a moment.") + } + } + + static func refusal(_ e: ErrorMsg) -> String { + switch e.code { + case "E_SCOPE_DENIED": return text("Only an owner of this home can do that.") + case "E_BAD_SIGNATURE": + if e.args["field"]?.string == "prevSig" { + return text("Your box holds an older approval key for this phone. Replace it on your box's page with the house password.") + } + return text("Your box didn't accept the approval. Nothing has changed.") + case "E_NO_APPROVAL_KEY": return text("Your box has no approval key for this phone yet. Try again.") + case "E_CLOCK_SKEW": return text("This phone's clock is far from your box's. Set the time automatically and try again.") + case "E_UNAVAILABLE": + if e.args["subsystem"]?.string == "clock" { + return text("Your box doesn't know the time yet, so it lets no assistant in. It will once it is online.") + } + return text("Your box is busy. Try again in a moment.") + case "E_CAP_EXCEEDED": + if e.args["cap"]?.string == "agents" { return text("Your box already lets 16 assistants in. Remove one first.") } + return text("That is more than your box allows. Choose less.") + case "E_NOT_FOUND": return text("That is no longer on your box.") + case "E_CONFLICT": return text("Someone changed this meanwhile. Look again and try once more.") + case "E_OP_NOT_GRANTED": return text("It may not ask for that.") + case "E_UNKNOWN_OP": + if e.args["field"]?.string == "key" { return text("Your box already has this assistant.") } + return text("Your box can't do that yet. It may need an update.") + default: return format("Your box said no (%@). Nothing has changed.", e.code) + } + } + + // MARK: Swedish + + static let sv: [String: String] = [ + // Who + "Sourceful support": "Sourceful support", + "Assistant %@": "Assistent %@", + "Assistant at %@": "Assistent på %@", + "The name it gives mentions Sourceful or FTW, so it is not shown.": "Namnet den ger nämner Sourceful eller FTW och visas därför inte.", + "I started this myself and trust %@.": "Jag började själv och litar på %@.", + "With control it may": "Med styrning får den", + "Add it again from its page to let it control.": "Lägg till den igen från dess sida för att låta den styra.", + "This request has something this app can't show you, so it can only be declined.": + "Begäran innehåller något som appen inte kan visa, så den kan bara avböjas.", + "%@ is locked after too many tries. Unlock the phone with its passcode, then try again.": + "%@ är låst efter för många försök. Lås upp telefonen med koden och försök igen.", + "Signing on this phone didn't work. Nothing was sent.": "Signeringen på telefonen gick inte. Inget skickades.", + "This phone couldn't keep its approval key. Nothing was sent.": "Telefonen kunde inte spara sin godkännandenyckel. Inget skickades.", + "Let %@ from %@ reach your home": "Låt %@ från %@ nå ditt hem", + "Change what %@ from %@ may do": "Ändra vad %@ från %@ får göra", + "Let %@ from %@ make this change": "Låt %@ från %@ göra ändringen", + "Access": "Åtkomst", + "Assistants and support": "Assistenter och support", + "Assistants": "Assistenter", + "Get help": "Få hjälp", + "Activity": "Aktivitet", + "Waiting for you": "Väntar på dig", + "%@ waiting": "%@ väntar", + "No assistant can reach your home.": "Ingen assistent når ditt hem.", + "Add an assistant": "Lägg till en assistent", + "Assistants such as Claude or ChatGPT reach your home through FTW Cloud, with what you let them. Your box checks every call.": + "Assistenter som Claude eller ChatGPT når ditt hem genom FTW Cloud, med det du låter dem. Boxen kontrollerar varje anrop.", + "Verified by FTW Cloud": "Verifierad av FTW Cloud", + "Not verified": "Inte verifierad", + "FTW Cloud doesn't know this client. Check that the address is the one you expect.": + "FTW Cloud känner inte den här klienten. Kontrollera att adressen är den du väntar dig.", + "Calls itself %@": "Kallar sig %@", + "Home": "Hem", + // Rights + "It may": "Den får", + "May": "Får", + "Reads": "Läser", + "Reads and controls": "Läser och styr", + "Control": "Styra", + "Also let it control": "Låt den också styra", + "See live readings": "Se mätvärden just nu", + "See history": "Se historik", + "See the plan and prices": "Se planen och priserna", + "See how the box and its devices are doing": "Se hur boxen och enheterna mår", + "See events": "Se händelser", + "Read the box's log, with names taken out": "Läsa boxens logg, utan namn", + "Read the settings, with names and secrets taken out": "Läsa inställningarna, utan namn och hemligheter", + "Run checks that change nothing": "Köra kontroller som inte ändrar något", + "Charge the car for a short while": "Ladda bilen en kort stund", + "Ask you for lasting changes": "Be dig om varaktiga ändringar", + "It may charge the car for up to %@ at a time, at most %@.": "Den får ladda bilen i högst %@ åt gången, med högst %@.", + "Lasting changes, such as the mode or a goal, wait for your %@.": "Varaktiga ändringar, som läget eller ett mål, väntar på ditt %@.", + "Off: it reads and changes nothing.": "Av: den läser och ändrar ingenting.", + "For": "Hur länge", + "1 day": "1 dag", + "%@ days": "%@ dagar", + "Until %@": "Till %@", + "Ends %@": "Slutar %@", + "Ended": "Slutat", + "Never used": "Aldrig använd", + "Last used %@": "Senast använd %@", + "Never": "Aldrig", + "On the box's page": "På boxens sida", + "On this phone": "På den här telefonen", + "On phone %@": "På telefon %@", + "Choose at least one thing it may do.": "Välj minst en sak den får göra.", + "Ticket %@": "Ärende %@", + // Offer + "Type the code on the page": "Skriv koden från sidan", + "The page that asked for access shows a code. Type it here only if you started this yourself, on a page you trust.": + "Sidan som bad om åtkomst visar en kod. Skriv den här bara om du själv började, på en sida du litar på.", + "Continue": "Fortsätt", + "Cancel": "Avbryt", + "Approve with %@": "Godkänn med %@", + "Let %@ reach your home": "Låt %@ nå ditt hem", + "%@ can now reach your home.": "%@ når nu ditt hem.", + "Your box checks every call, and you can remove the access at any time.": + "Boxen kontrollerar varje anrop, och du kan ta bort åtkomsten när du vill.", + "Done": "Klar", + "Checking the offer": "Kontrollerar erbjudandet", + // One grant + "Save with %@": "Spara med %@", + "Remove access": "Ta bort åtkomst", + "Change what %@ may do": "Ändra vad %@ får göra", + "Approved": "Godkänd", + "Last used": "Senast använd", + "Ends": "Slutar", + "Renew for": "Förnya i", + // Requests + "%@ asks to": "%@ ber om att", + "In its own words": "Med egna ord", + "Asked %@": "Frågade %@", + "Approve": "Godkänn", + "Decline": "Avböj", + "Let %@ make this change": "Låt %@ göra ändringen", + "Approving runs it on your box now, as if you had made the change yourself.": + "Godkänner du körs ändringen på boxen nu, som om du hade gjort den själv.", + "Charger": "Laddare", + "Car": "Bil", + "Operating mode": "Driftläge", + "How cautious the plan is": "Hur försiktig planen är", + "Whether the battery may sell to the grid": "Om batteriet får sälja till nätet", + "Charging power": "Laddeffekt", + "For how long": "Hur länge", + "Phases": "Faser", + "Stop the agent's own hold": "Avsluta assistentens egen laddning", + "The car's charge level": "Bilens laddnivå", + "Charge from solar only": "Ladda bara med sol", + "The level to reach": "Nivå att nå", + "Ready by, in minutes after midnight": "Klar till", + "Weekdays": "Veckodagar", + "Charge on to the car's own limit": "Ladda vidare till bilens egen gräns", + "Remove the goal": "Ta bort målet", + "Yes": "Ja", + "No": "Nej", + "Not set": "Inte valt", + "Automatic": "Automatiskt", + "One phase": "En fas", + "Three phases": "Tre faser", + "Change how the home is run": "Ändra hur hemmet körs", + "Change the plan's settings": "Ändra planens inställningar", + "Charge the car now": "Ladda bilen nu", + "Correct the car's level": "Rätta bilens nivå", + "Charge the car from solar only": "Ladda bilen bara med sol", + "Change when and how far the car charges": "Ändra när och hur långt bilen laddar", + "Set the car's level": "Ange bilens nivå", + "Waits %@": "Väntar till %@", + // Activity + "This phone": "Den här telefonen", + "Phone %@": "Telefon %@", + "The box's page": "Boxens sida", + "The box's page, %@": "Boxens sida, %@", + "The box": "Boxen", + "%@ (a Sourceful tool)": "%@ (ett verktyg hos Sourceful)", + "%@, named by FTW Cloud": "%@, enligt FTW Cloud", + "Declined": "Avböjd", + "Refused (%@)": "Nekad (%@)", + "Load older": "Visa äldre", + "Nothing has happened yet.": "Inget har hänt än.", + "Granted access": "Gav åtkomst", + "Changed access": "Ändrade åtkomst", + "Removed access": "Tog bort åtkomst", + "Read who has access": "Läste vem som har åtkomst", + "Asked for a change": "Bad om en ändring", + "Ran an approved change": "Körde en godkänd ändring", + "Answered a request": "Svarade på en begäran", + "Set an approval key": "Satte en godkännandenyckel", + "Read the activity": "Läste aktiviteten", + "Session ended": "Sessionen slutade", + "Connected": "Anslöt", + "Sent a command": "Skickade ett kommando", + "Read live readings": "Läste mätvärden", + "Read the chargers": "Läste laddarna", + "Read the cars": "Läste bilarna", + "Read the plan": "Läste planen", + "Read prices": "Läste priser", + "Read history": "Läste historik", + "Read the health summary": "Läste hälsan", + "Read the devices' state": "Läste enheternas läge", + "Read events": "Läste händelser", + "Read the log": "Läste loggen", + "Read the settings": "Läste inställningarna", + "Ran a check": "Körde en kontroll", + // The key + "Approvals on this phone use %@.": "Godkännanden på den här telefonen använder %@.", + "This phone makes its approval key the first time you approve, under the same %@.": + "Telefonen gör sin godkännandenyckel första gången du godkänner, med samma %@.", + "Replace the approval key": "Byt godkännandenyckel", + "Replace the approval key on this phone": "Byt godkännandenyckeln på den här telefonen", + "The approval key on this phone stopped working when %@ changed. Replace it on your box's page with the house password.": + "Godkännandenyckeln på telefonen slutade fungera när %@ ändrades. Byt den på boxens sida med husets lösenord.", + "This phone can't approve on its own. Use your box's page.": "Telefonen kan inte godkänna själv. Använd boxens sida.", + // Get help + "Describe the problem": "Beskriv problemet", + "What happened, and when?": "Vad hände, och när?", + "Let Sourceful look": "Låt Sourceful titta", + "Your words go to Sourceful's help desk with the ticket. Your box never sees them.": + "Orden går till Sourcefuls helpdesk med ärendet. Boxen ser dem aldrig.", + "Next you see what Sourceful asks to do, and approve it with %@.": + "Sedan ser du vad Sourceful ber om att få göra, och godkänner med %@.", + "Every call names the person at Sourceful who made it. You can remove the access at any time, and it ends when the ticket closes.": + "Varje anrop säger vem hos Sourceful som gjorde det. Du kan ta bort åtkomsten när du vill, och den slutar när ärendet stängs.", + "Sourceful can't open a ticket from the app yet.": "Sourceful kan inte öppna ett ärende från appen än.", + // Refusals + "Set up %@ on this phone to approve from it, or approve on your box's page.": "Ställ in %@ på telefonen för att godkänna från den, eller godkänn på boxens sida.", + "This version of the app can't check offers from FTW Cloud yet.": "Den här versionen av appen kan inte kontrollera erbjudanden från FTW Cloud än.", + "This offer isn't signed by FTW Cloud. Nothing was granted.": "Erbjudandet är inte signerat av FTW Cloud. Inget har getts.", + "This offer has run out. Start again on the page that showed it.": "Erbjudandet har gått ut. Börja om på sidan som visade det.", + "This isn't an offer this app can read. Nothing was granted.": "Det här är inget erbjudande appen kan läsa. Inget har getts.", + "This offer claims to come from Sourceful but comes from someone else. Nothing was granted.": + "Erbjudandet säger sig komma från Sourceful men kommer från någon annan. Inget har getts.", + "This offer names its client in letters that could imitate another address. Nothing was granted.": + "Erbjudandet skriver klientens adress med tecken som kan härma en annan adress. Inget har getts.", + "This offer has been answered already. Start again on the page that showed it.": + "Erbjudandet är redan besvarat. Börja om på sidan som visade det.", + "This offer is for a home this phone doesn't have.": "Erbjudandet gäller ett hem som telefonen inte har.", + "That isn't the code on the page. Check it and type it again.": "Det är inte koden på sidan. Kontrollera och skriv den igen.", + "FTW Cloud has no offer under that code. It may have been used or run out.": "FTW Cloud har inget erbjudande med den koden. Den kan vara använd eller ha gått ut.", + "FTW Cloud couldn't be reached. Try again in a moment.": "FTW Cloud gick inte att nå. Försök igen om en stund.", + "Only an owner of this home can do that.": "Bara en ägare av hemmet kan göra det.", + "Your box holds an older approval key for this phone. Replace it on your box's page with the house password.": + "Boxen har en äldre godkännandenyckel för telefonen. Byt den på boxens sida med husets lösenord.", + "Your box didn't accept the approval. Nothing has changed.": "Boxen tog inte emot godkännandet. Inget har ändrats.", + "Your box has no approval key for this phone yet. Try again.": "Boxen har ingen godkännandenyckel för telefonen än. Försök igen.", + "This phone's clock is far from your box's. Set the time automatically and try again.": "Telefonens klocka går långt från boxens. Ställ tiden automatiskt och försök igen.", + "Your box doesn't know the time yet, so it lets no assistant in. It will once it is online.": + "Boxen vet inte vad klockan är än, så den släpper inte in någon assistent. Det gör den när den är online.", + "Your box is busy. Try again in a moment.": "Boxen är upptagen. Försök igen om en stund.", + "Your box already lets 16 assistants in. Remove one first.": "Boxen släpper redan in 16 assistenter. Ta bort en först.", + "That is more than your box allows. Choose less.": "Det är mer än boxen tillåter. Välj mindre.", + "That is no longer on your box.": "Det finns inte längre på boxen.", + "Someone changed this meanwhile. Look again and try once more.": "Någon ändrade det här under tiden. Titta igen och försök en gång till.", + "It may not ask for that.": "Den får inte be om det.", + "Your box already has this assistant.": "Boxen har redan den här assistenten.", + "Your box can't do that yet. It may need an update.": "Boxen kan inte göra det än. Den kan behöva en uppdatering.", + "Your box said no (%@). Nothing has changed.": "Boxen sa nej (%@). Inget har ändrats.", + "Your box didn't answer. Nothing has changed. Try again when the home is live.": "Boxen svarade inte. Inget har ändrats. Försök igen när hemmet är live.", + "That didn't work. Nothing on your box has changed.": "Det gick inte. Inget på din box har ändrats.", + ] +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Access/Approval.swift b/appleApp/FTWKit/Sources/FTWKit/Access/Approval.swift new file mode 100644 index 0000000..9b92288 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Access/Approval.swift @@ -0,0 +1,176 @@ +import CryptoKit +import Foundation + +/// The approval signature (docs/agents.md in srcfl/ftw, The approval +/// signature). +/// +/// An owner phone approves an agent grant, a change to one, or an agent's +/// request with a P-256 key in its Secure Enclave, behind Face ID. What it +/// signs is the deterministic CBOR (RFC 8949, section 4.2.1) of one array: +/// +/// ["ftw.approval.v1", what, boxKey, deviceId, atMs, ...fields] +/// +/// built here exactly as `go/protocol/agents.go` builds it, so the box can +/// check it without trusting the app or FTW Cloud. `ApprovalTests` holds the +/// bytes to the box's golden vectors. +public enum Approval { + public static let tag = "ftw.approval.v1" + /// r ‖ s, 32 bytes each. + public static let sigBytes = 64 + /// A SEC1 uncompressed P-256 point. + public static let pubBytes = 65 + /// How far `atMs` may stray from the box's wall clock. + public static let skewMs: Int64 = 10 * 60 * 1000 + + public static let keySet = "approval.key.set" + public static let add = "agent.add" + public static let widen = "agent.widen" + public static let confirm = "agent.confirm" + + /// A signed list must be sorted, with no repeats. + public struct Unsorted: Error, Equatable { + public let list: [String] + } + + /// The bytes for `what`: the tag, the box, the phone, its clock, then + /// the fields. + public static func bytes(_ what: String, _ f: SignedFor, atMs: Int64, _ fields: [CBOR]) -> Bytes { + let head: [CBOR] = [.text(tag), .text(what), .bytes(f.boxKey), .text(f.deviceID), .int(atMs)] + return encodeDeterministic(.array(head + fields)) + } + + /// What `approval.key.set` signs: the key itself, as proof the phone + /// holds its private half. A replacement's `prevSig` is the old key's + /// signature over the same bytes. + public static func keySetBytes(_ f: SignedFor, atMs: Int64, pub: Bytes) -> Bytes { + bytes(keySet, f, atMs: atMs, [.bytes(pub)]) + } + + public static func addBytes(_ f: SignedFor, _ m: AgentAdd) throws -> Bytes { + try bytes(add, f, atMs: m.atMs, [ + .bytes(m.key), .text(m.role), sortedList(m.scopes), sortedList(m.ops), m.caps.array, + .int(m.expiresAtMs), .text(m.principal.client), .text(m.principal.clientId), .text(m.principal.ticket), + ]) + } + + /// `key` is the row's whole key from `agent.list`: an id and an epoch + /// alone could name another row that took the same id. + public static func widenBytes(_ f: SignedFor, _ m: AgentWiden, key: Bytes) throws -> Bytes { + try bytes(widen, f, atMs: m.atMs, [ + .text(m.id), .bytes(key), .unsigned(m.epoch), sortedList(m.scopes), sortedList(m.ops), m.caps.array, + .int(m.expiresAtMs), + ]) + } + + /// What approving a request signs: the request, the agent that asked, + /// the op and the hash of its args as the phone received and showed them. + public static func confirmBytes(_ f: SignedFor, reqId: String, atMs: Int64, request: AgentRequest) -> Bytes { + bytes(confirm, f, atMs: atMs, [.text(reqId), .text(request.agent), .text(request.op), .bytes(argsHash(request.args))]) + } + + /// SHA-256 over the deterministic CBOR of a request's args. + public static func argsHash(_ args: CBOR) -> Bytes { + Primitives.sha256(encodeDeterministic(args.isNull ? .emptyMap : args)) + } + + static func sortedList(_ list: [String]) throws -> CBOR { + guard isSortedUnique(list) else { throw Unsorted(list: list) } + return .array(list.map { .text($0) }) + } + + /// Sorted as Go sorts strings, by their bytes, with no repeats. + public static func isSortedUnique(_ list: [String]) -> Bool { + zip(list, list.dropFirst()).allSatisfy { Array($0.utf8).lexicographicallyPrecedes(Array($1.utf8)) } + } + + /// Sorted as Go sorts strings, with repeats gone. + public static func sorted(_ list: some Sequence) -> [String] { + Array(Set(list)).sorted { Array($0.utf8).lexicographicallyPrecedes(Array($1.utf8)) } + } + + /// Whether `sig`, r ‖ s, is `pub`'s ECDSA P-256 signature over SHA-256 + /// of `message`. + public static func verify(pub: Bytes, message: Bytes, sig: Bytes) -> Bool { + guard pub.count == pubBytes, sig.count == sigBytes, + let key = try? P256.Signing.PublicKey(x963Representation: pub), + let signature = try? P256.Signing.ECDSASignature(rawRepresentation: sig) else { return false } + return key.isValidSignature(signature, for: Data(message)) + } +} + +/// The box and the phone an approval belongs to: the box's X25519 static +/// key, as in the pairing QR code, and the signing phone's device id on that +/// box. A signature for one box is worth nothing at another. +public struct SignedFor: Equatable, Sendable { + public var boxKey: Bytes + public var deviceID: String + + public init(boxKey: Bytes, deviceID: String) { + self.boxKey = boxKey + self.deviceID = deviceID + } +} + +/// Something that signs approvals: the phone's Secure Enclave key behind +/// Face ID in the app, a software key in tests. `sign` returns r ‖ s and +/// hashes the bytes itself. +@MainActor +public protocol ApprovalSigner: AnyObject { + /// SEC1 uncompressed, 65 bytes. + var publicKey: Bytes { get } + func sign(_ message: Bytes) throws -> Bytes +} + +/// A P-256 key in memory. Tests, previews and the simulated box use it; +/// the app's own key lives in the Secure Enclave. +@MainActor +public final class SoftwareSigner: ApprovalSigner { + let key: P256.Signing.PrivateKey + + public init(_ key: P256.Signing.PrivateKey = P256.Signing.PrivateKey()) { + self.key = key + } + + public convenience init(raw: Bytes) throws { + self.init(try P256.Signing.PrivateKey(rawRepresentation: raw)) + } + + public var publicKey: Bytes { key.publicKey.x963Representation.byteArray } + + /// The private scalar, for a store that keeps a software key. + public var rawKey: Bytes { key.rawRepresentation.byteArray } + + public func sign(_ message: Bytes) throws -> Bytes { + try key.signature(for: Data(message)).rawRepresentation.byteArray + } +} + +// MARK: - Deterministic CBOR + +/// RFC 8949 section 4.2.1, as the box's encoder writes it: every map's keys +/// in the bytewise order of their own encoding, at every depth. The rest of +/// core deterministic encoding (shortest integers and lengths, the shortest +/// float that keeps the value, no indefinite lengths) is how `encodeCBOR` +/// always writes. +public func encodeDeterministic(_ value: CBOR) -> Bytes { + encodeCBOR(value.deterministic) +} + +extension CBOR { + /// The same value with every map in deterministic order. + public var deterministic: CBOR { + switch self { + case .array(let items): + return .array(items.map(\.deterministic)) + case .map(let entries): + let sorted = entries + .map { (key: encodeCBOR($0.key.deterministic), entry: Entry($0.key.deterministic, $0.value.deterministic)) } + .sorted { $0.key.lexicographicallyPrecedes($1.key) } + return .map(sorted.map(\.entry)) + case .tagged(let tag, let inner): + return .tagged(tag, inner.deterministic) + default: + return self + } + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Access/Offer.swift b/appleApp/FTWKit/Sources/FTWKit/Access/Offer.swift new file mode 100644 index 0000000..8acafd7 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Access/Offer.swift @@ -0,0 +1,365 @@ +import Foundation + +/// The grant offer (docs/agents.md in srcfl/ftw, The grant offer): what FTW +/// Cloud's consent page asks the owner to grant, signed by FTW Cloud's offer +/// key, which this app pins. The universal link carries it in its fragment; +/// the short code on the page finds it once at FTW Cloud. +public struct GrantOffer: Equatable, Sendable { + public static let version: UInt64 = 1 + /// The longest an offer may live. + public static let ttlMs: Int64 = 10 * 60 * 1000 + + public var v: UInt64 = 0 + /// Empty: the owner picks the home. + public var home = "" + /// The agent's X25519 key, made by FTW Cloud's keys part. + public var key: Bytes = [] + public var role = "" + /// The name the client gives itself. Its own word, never checked. + public var client = "" + public var clientId = "" + /// The host of the client's metadata document or redirect URI. + public var clientHost = "" + /// FTW Cloud knows the client from its own list. + public var verified = false + public var scopes: [String] = [] + public var ops: [String] = [] + public var caps = Caps() + public var ttlS: UInt64 = 0 + public var ticket = "" + /// What the consent page shows, for the owner to type or compare. + public var code = "" + /// Wall-clock ms when the offer stops being good. + public var expMs: Int64 = 0 + + public init() {} + + /// The offer's own deterministic CBOR, as FTW Cloud signs it. + public var cbor: CBOR { + .map([ + ("v", .unsigned(v)), ("home", .text(home)), ("key", .bytes(key)), ("role", .text(role)), + ("client", .text(client)), ("clientId", .text(clientId)), ("clientHost", .text(clientHost)), + ("verified", .bool(verified)), ("scopes", .array(scopes.map { .text($0) })), + ("ops", .array(ops.map { .text($0) })), ("caps", caps.cbor), ("ttlS", .unsigned(ttlS)), + ("ticket", .text(ticket)), ("code", .text(code)), ("exp", .int(expMs)), + ]).deterministic + } + + /// Strict, as the box reads it: a key it does not know, a key twice or + /// a value of the wrong kind is no offer. + init(strict c: CBOR) throws { + guard let entries = c.entries else { throw OfferError.malformed } + for e in entries { + guard let k = e.key.string else { throw OfferError.malformed } + let v = e.value + switch k { + case "v": self.v = try Self.need(v.uint) + case "home": home = try Self.need(v.string) + case "key": key = try Self.need(v.byteString) + case "role": role = try Self.need(v.string) + case "client": client = try Self.need(v.string) + case "clientId": clientId = try Self.need(v.string) + case "clientHost": clientHost = try Self.need(v.string) + case "verified": verified = try Self.need(v.bool) + case "scopes": scopes = try Self.texts(v) + case "ops": ops = try Self.texts(v) + case "caps": caps = try Self.caps(v) + case "ttlS": ttlS = try Self.need(v.uint) + case "ticket": ticket = try Self.need(v.string) + case "code": code = try Self.need(v.string) + case "exp": expMs = try Self.need(v.int64) + default: throw OfferError.malformed + } + } + } + + private static func need(_ v: T?) throws -> T { + guard let v else { throw OfferError.malformed } + return v + } + + private static func texts(_ v: CBOR) throws -> [String] { + if v.isNull { return [] } + guard let items = v.array else { throw OfferError.malformed } + return try items.map { try need($0.string) } + } + + private static func caps(_ v: CBOR) throws -> Caps { + guard let entries = v.entries else { throw OfferError.malformed } + var c = Caps() + for e in entries { + switch e.key.string { + case "holdS": c.holdS = try need(e.value.uint) + case "maxW": c.maxW = try need(e.value.uint) + case "perMin": c.perMin = try need(e.value.uint) + default: throw OfferError.malformed + } + } + return c + } + + public var isSupport: Bool { role == AccessWire.roleSupport } + /// It asks to hold a charger or to ask for durable changes. + public var asksControl: Bool { !AccessWire.controlScopes.isDisjoint(with: scopes) } +} + +/// Why an offer does not reach Face ID. Each says what happens now. +public enum OfferError: Error, Equatable, Sendable { + /// This build pins no offer key: it opens no offer. + case noPinnedKey + /// No pinned key signed it. + case unsigned + case expired + case malformed + /// A support or release offer from a client that is not Sourceful's. + case notFromSourceful + /// The client's host is written in letters that could imitate another + /// address. + case strangeHost + /// The owner already answered this offer, here or elsewhere. + case used + /// For a home this phone does not have. + case unknownHome + /// The code typed is not the offer's. + case wrongCode + /// FTW Cloud has no offer under that code or link, or it was used. + case notFound + /// FTW Cloud could not be reached. + case unreachable +} + +/// An offer as it travels: `o` is the offer's deterministic CBOR, `sig` FTW +/// Cloud's offer key's signature over `["ftw.offer.v1", o]`, and `kid` +/// names that key. +public struct SignedOffer: Equatable, Sendable { + public static let tag = "ftw.offer.v1" + + public var o: Bytes + public var kid: String + public var sig: Bytes + + public init(o: Bytes, kid: String, sig: Bytes) { + self.o = o + self.kid = kid + self.sig = sig + } + + /// Base64url without padding, as the link's fragment and the code + /// lookup carry it. + public init(base64url text: String) throws { + guard let raw = try? Base64url.decode(text), let c = try? decodeCBOR(raw), + let entries = c.entries, entries.count == 3, + let o = c["o"]?.byteString, let kid = c["kid"]?.string, let sig = c["sig"]?.byteString else { + throw OfferError.malformed + } + self.init(o: o, kid: kid, sig: sig) + } + + public var base64url: String { + Base64url.encode(encodeDeterministic(.map([("o", .bytes(o)), ("kid", .text(kid)), ("sig", .bytes(sig))]))) + } + + /// What the offer key signs. + public static func signedBytes(_ o: Bytes) -> Bytes { + encodeDeterministic(.array([.text(tag), .bytes(o)])) + } + + /// The offer, if a pinned key signed it and it is still good. A support + /// offer counts only from Sourceful's own client, and a release offer + /// from none yet: no release client is pinned. A client host must be + /// plain ASCII, so no look-alike letter can pass for a known address. + public func open(keys: OfferKeys, nowMs: Int64) throws -> GrantOffer { + guard !keys.isEmpty else { throw OfferError.noPinnedKey } + guard let pub = keys.pub(kid), Approval.verify(pub: pub, message: Self.signedBytes(o), sig: sig) else { + throw OfferError.unsigned + } + guard let c = try? decodeCBOR(o) else { throw OfferError.malformed } + let offer = try GrantOffer(strict: c) + guard offer.v == GrantOffer.version, offer.key.count == 32, + [AccessWire.roleAgent, AccessWire.roleSupport, AccessWire.roleRelease].contains(offer.role) else { + throw OfferError.malformed + } + guard nowMs < offer.expMs, offer.expMs <= nowMs + GrantOffer.ttlMs else { throw OfferError.expired } + if offer.isSupport, offer.clientId != AccessWire.supportClientID { throw OfferError.notFromSourceful } + if offer.role == AccessWire.roleRelease { throw OfferError.notFromSourceful } + guard Self.plainHost(offer.clientHost) else { throw OfferError.strangeHost } + return offer + } + + /// ASCII letters, digits, hyphens and dots, and a port: a host no + /// look-alike letter can hide in. Punycode stays as it is, `xn--`. + static func plainHost(_ host: String) -> Bool { + !host.isEmpty && host.utf8.count <= 253 && host.utf8.allSatisfy { c in + (0x41...0x5a).contains(c) || (0x61...0x7a).contains(c) || (0x30...0x39).contains(c) || c == 0x2d || c == 0x2e || c == 0x3a + } + } +} + +/// The offer keys this build pins, kid → SEC1 P-256 point, as it pins the +/// service's Noise key. A build that pins none opens no offer. An entry +/// whose kid is not its key's is dropped, as the box drops one. +/// +/// FTW Cloud's keys part makes the key on its host and publishes it at +/// `https://cloud.ftw.energy/.well-known/ftw-offer-key`; a release pins it +/// here. None is pinned yet: the keys part is not running. Only a debug +/// build may pin another, for a local `ftwcloudd`: +/// +/// -FTWOfferKeys :[,:] +public struct OfferKeys: Equatable, Sendable { + /// Release pins, `kid:pub`. Empty until FTW Cloud's offer key exists. + static let releasePins: [String] = [] + + let keys: [String: Bytes] + + public init(pins: [String]) { + var out = [String: Bytes]() + for pin in pins { + let parts = pin.split(separator: ":", maxSplits: 1).map(String.init) + guard parts.count == 2, let pub = try? Base64url.decode(parts[1]), pub.count == Approval.pubBytes, + Self.kid(pub) == parts[0] else { continue } + out[parts[0]] = pub + } + keys = out + } + + public var isEmpty: Bool { keys.isEmpty } + func pub(_ kid: String) -> Bytes? { keys[kid] } + + /// The first 16 characters of base64url of SHA-256 over the SEC1 point. + public static func kid(_ pub: Bytes) -> String { + String(Base64url.encode(Primitives.sha256(pub)).prefix(16)) + } + + public static var current: OfferKeys { + #if DEBUG + resolve(defaults: .standard, debug: true) + #else + resolve(defaults: .standard, debug: false) + #endif + } + + static func resolve(defaults: UserDefaults, debug: Bool) -> OfferKeys { + if debug, let raw = defaults.string(forKey: "FTWOfferKeys") { + return OfferKeys(pins: raw.split(separator: ",").map(String.init)) + } + return OfferKeys(pins: releasePins) + } +} + +/// A link from the consent page, as FTW Cloud makes it and nothing else: +/// `https://app.ftw.energy/a#.`. Both ride in the fragment, +/// which no server sees; a request id in a path would reach a server's log, +/// and alone it is worth asking FTW Cloud about. +public struct OfferLink: Hashable, Sendable, Identifiable { + public var request: String + /// Base64url of the signed offer. + public var offer: String + + public var id: String { request } + + public init(request: String, offer: String) { + self.request = request + self.offer = offer + } + + public static let host = "app.ftw.energy" + public static let path = "/a" + + public init?(_ text: String) { + guard let url = URLComponents(string: text), url.scheme == "https", url.host == Self.host else { return nil } + guard url.path == Self.path, url.percentEncodedQuery == nil else { return nil } + let parts = (url.percentEncodedFragment ?? "").split(separator: ".", maxSplits: 1).map(String.init) + guard parts.count == 2, Self.isToken(parts[0]), Self.isToken(parts[1]) else { return nil } + self.init(request: parts[0], offer: parts[1]) + } + + /// Base64url's alphabet, which has no dot. + static func isToken(_ s: String) -> Bool { + !s.isEmpty && s.utf8.allSatisfy { c in + (0x41...0x5a).contains(c) || (0x61...0x7a).contains(c) || (0x30...0x39).contains(c) || c == 0x2d || c == 0x5f + } + } +} + +/// The short code on the consent page: eight letters and digits from an +/// alphabet with no look-alikes, shown as `ABCD-EFGH`. +public enum OfferCode { + public static let length = 8 + static let alphabet = Set("ABCDEFGHJKMNPQRSTUVWXYZ23456789") + + /// The code as an owner may type it, or nil when it cannot be one. + public static func normalize(_ typed: String) -> String? { + let s = typed.uppercased().filter { $0 != "-" && $0 != " " } + guard s.count == length, s.allSatisfy(alphabet.contains) else { return nil } + return s + } + + /// `ABCD-EFGH`. + public static func show(_ code: String) -> String { + guard code.count == length else { return code } + return "\(code.prefix(length / 2))-\(code.suffix(length / 2))" + } + + /// Whether what was typed is the offer's own code. + public static func matches(_ typed: String, _ offer: GrantOffer) -> Bool { + guard let t = normalize(typed), let o = normalize(offer.code) else { return false } + return constantTimeEqual(Bytes(t.utf8), Bytes(o.utf8)) + } +} + +/// Where a typed code finds its signed offer: FTW Cloud's code lookup +/// (`GET /oauth/requests?code=`, once per code). An offer that is no longer +/// waiting for the owner is none. A debug build may point at a local +/// `ftwcloudd` with `-FTWOfferURL https://127.0.0.1:18443`. +public struct OfferLookup: Sendable { + public typealias Fetch = @Sendable (URLRequest) async throws -> (Data, Int) + + public var base: URL + var fetch: Fetch + + public init(base: URL, fetch: @escaping Fetch) { + self.base = base + self.fetch = fetch + } + + public static let releaseBase = URL(string: "https://cloud.ftw.energy")! + + public static func current(session: URLSession = .shared) -> OfferLookup { + var base = releaseBase + #if DEBUG + if let raw = UserDefaults.standard.string(forKey: "FTWOfferURL"), let url = URL(string: raw), url.scheme == "https" || url.scheme == "http" { + base = url + } + #endif + return OfferLookup(base: base) { req in + let (data, response) = try await session.data(for: req) + return (data, (response as? HTTPURLResponse)?.statusCode ?? 0) + } + } + + public func byCode(_ code: String) async throws -> SignedOffer { + var c = URLComponents(url: base.appendingPathComponent("oauth/requests"), resolvingAgainstBaseURL: false)! + c.queryItems = [URLQueryItem(name: "code", value: code)] + return try await get(c.url!) + } + + private func get(_ url: URL) async throws -> SignedOffer { + var req = URLRequest(url: url, timeoutInterval: 15) + req.setValue("application/json", forHTTPHeaderField: "Accept") + req.setValue("https://\(OfferLink.host)", forHTTPHeaderField: "Origin") + let data: Data + let status: Int + do { + (data, status) = try await fetch(req) + } catch { + throw OfferError.unreachable + } + guard status == 200 else { throw status == 404 || status == 410 ? OfferError.notFound : OfferError.unreachable } + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let offer = json["offer"] as? String else { + throw OfferError.malformed + } + // Approved, denied or expired at FTW Cloud: answered already. + if let status = json["status"] as? String, status != "pending" { throw OfferError.used } + return try SignedOffer(base64url: offer) + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Access/OfferFlow.swift b/appleApp/FTWKit/Sources/FTWKit/Access/OfferFlow.swift new file mode 100644 index 0000000..776fd8e --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Access/OfferFlow.swift @@ -0,0 +1,250 @@ +import Foundation +import Observation + +/// One offer from a consent page, from the link or the typed code to Face +/// ID (docs/agents.md in srcfl/ftw, The grant offer). +/// +/// A link someone else sent never reaches Face ID on its own: the owner +/// types the code the page in front of them shows, and it must be the +/// offer's. Typed without a link, the code fetches the offer from FTW Cloud, +/// once. Either way the offer is checked against the pinned offer keys +/// before anything is shown, and refused when it is unsigned, expired, for +/// a home this phone does not have, or support from anyone but Sourceful. +/// Approving checks it again, against the home open at that moment and the +/// clock then, from the signed bytes rather than what was shown. +@Observable +@MainActor +public final class OfferFlow { + public enum Step: Equatable { + /// Type the code from the page. + case code + case checking + /// The offer, verified, for the owner to choose from. + case review(GrantOffer) + case approving(GrantOffer) + /// Granted: the box's row. + case done(AgentRow) + case refused(OfferError) + } + + public private(set) var step: Step = .code + /// The code as typed. + public var typed = "" + /// Control is a separate switch, off until ticked. + public var control = false + /// FTW Cloud does not know this client: the owner says they started + /// this and trust its address before Face ID. + public var trustsUnknown = false + public var lengthMs: Int64 = 30 * 86_400_000 + /// Why the code did not go through, under the field. + public private(set) var codeHelp: String? + + public let link: OfferLink? + @ObservationIgnored let lookup: OfferLookup + @ObservationIgnored let keys: OfferKeys + @ObservationIgnored let wallMs: @MainActor () -> Double + /// Opened before the code: a link's offer is checked at once, so a bad + /// one is refused without asking for anything. + @ObservationIgnored private var opened: GrantOffer? + /// The offer as signed, checked again when the owner approves. + @ObservationIgnored private var signed: SignedOffer? + /// Told of the row once the box granted it. + @ObservationIgnored public var onDone: (@MainActor (AgentRow) -> Void)? + + public init(link: OfferLink?, lookup: OfferLookup, keys: OfferKeys, wallMs: @escaping @MainActor () -> Double = { Date().timeIntervalSince1970 * 1000 }) { + self.link = link + self.lookup = lookup + self.keys = keys + self.wallMs = wallMs + } + + var nowMs: Int64 { Int64(wallMs()) } + + /// The offer, when the flow has one. + public var offer: GrantOffer? { + switch step { + case .review(let o), .approving(let o): return o + default: return nil + } + } + + /// For a link: check its offer before asking for the code. + public func start(home: String?) async { + guard let link, opened == nil, step == .code else { return } + guard !keys.isEmpty else { return refuse(.noPinnedKey) } + do { + let carried = try SignedOffer(base64url: link.offer) + opened = try check(carried, home: home) + signed = carried + } catch let e as OfferError { + refuse(e) + } catch { + refuse(.malformed) + } + } + + /// The owner typed the code: compare it with the link's offer, or fetch + /// the offer it names. + public func submit(home: String?) async { + codeHelp = nil + guard let code = OfferCode.normalize(typed) else { + codeHelp = AccessWords.offer(.wrongCode) + return + } + if let opened { + guard OfferCode.matches(code, opened) else { + codeHelp = AccessWords.offer(.wrongCode) + return + } + return review(opened) + } + guard !keys.isEmpty else { return refuse(.noPinnedKey) } + step = .checking + do { + let fetched = try await lookup.byCode(code) + let offer = try check(fetched, home: home) + guard OfferCode.matches(code, offer) else { return refuse(.wrongCode) } + signed = fetched + review(offer) + } catch let e as OfferError where e == .notFound { + step = .code + codeHelp = AccessWords.offer(e) + } catch let e as OfferError { + refuse(e) + } catch { + refuse(.malformed) + } + } + + /// An offer that came another way, such as Get help: checked like any. + public func present(_ given: SignedOffer, home: String?) { + do { + let offer = try check(given, home: home) + signed = given + review(offer) + } catch let e as OfferError { + refuse(e) + } catch { + refuse(.malformed) + } + } + + /// Grant what the owner chose, behind Face ID, at the home open now. + /// The signed offer is checked again first: the home may have changed + /// and the clock moved since it was shown. + public func approve(_ access: AccessModel, home: String?) async { + guard case .review(let shown) = step, let signed else { return } + guard shown.verified || trustsUnknown else { return } + let offer: GrantOffer + do { + offer = try check(signed, home: home) + } catch let e as OfferError { + return refuse(e) + } catch { + return refuse(.malformed) + } + guard offer == shown else { return refuse(.malformed) } + step = .approving(offer) + if let row = await access.approve(offer, grant: grant(offer)) { + step = .done(row) + onDone?(row) + } else { + step = .review(offer) + } + } + + /// What the owner has chosen so far. + public func grant(_ offer: GrantOffer) -> Grant { + Grant.from(offer, control: control, lengthMs: lengthMs) + } + + public var lengths: [Int64] { + guard let offer else { return [] } + return Grant.lengths(role: offer.role, offeredS: offer.ttlS) + } + + func check(_ signed: SignedOffer, home: String?) throws -> GrantOffer { + let offer = try signed.open(keys: keys, nowMs: nowMs) + // FTW Cloud leaves the home empty and the owner picks it. One that + // names a home must name this one. + if !offer.home.isEmpty, offer.home != home { throw OfferError.unknownHome } + return offer + } + + private func review(_ offer: GrantOffer) { + let picks = Grant.lengths(role: offer.role, offeredS: offer.ttlS) + lengthMs = picks.last ?? lengthMs + if offer.isSupport, let week = picks.last(where: { $0 <= 7 * 86_400_000 }) { lengthMs = week } + control = false + trustsUnknown = false + step = .review(offer) + } + + private func refuse(_ e: OfferError) { + step = .refused(e) + } +} + +/// Where Get help gets a support offer: Sourceful's help desk opens a +/// ticket and FTW Cloud's support tooling signs an offer for it, with the +/// ticket id and the support key FTW Cloud's keys part made. There is no +/// desk yet: a release build has `NoSupportDesk`, and the simulated home +/// has one of its own. +@MainActor +public protocol SupportDesk: AnyObject { + /// Whether a ticket can be opened from the app. + var isOpen: Bool { get } + /// Opens a ticket for what the owner wrote, which goes to the desk, + /// and returns Sourceful's signed offer for it. + func open(problem: String) async throws -> SignedOffer +} + +/// No help desk behind the app yet. Get help says so. +@MainActor +public final class NoSupportDesk: SupportDesk { + public init() {} + public var isOpen: Bool { false } + public func open(problem: String) async throws -> SignedOffer { throw OfferError.unreachable } +} + +/// What the owner wrote for each ticket. The help desk has the words; this +/// phone keeps a copy beside the ticket id, and the box never sees them. +public struct TicketNote: Codable, Equatable, Sendable { + public var ticket: String + public var problem: String + public var atMs: Double + + public init(ticket: String, problem: String, atMs: Double) { + self.ticket = ticket + self.problem = problem + self.atMs = atMs + } +} + +/// Where a client lives and what it asked for, as the offer the owner +/// approved on this phone said, by its row's id: so control can be turned +/// on later with what the client asked, never more. +public struct ClientNote: Codable, Equatable, Sendable { + public var id: String + public var host: String + public var verified: Bool + public var askedOps: [String] + public var askedCaps: Caps + + public init(id: String, host: String, verified: Bool, askedOps: [String] = [], askedCaps: Caps = Caps()) { + self.id = id + self.host = host + self.verified = verified + self.askedOps = askedOps + self.askedCaps = askedCaps + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + host = try c.decode(String.self, forKey: .host) + verified = try c.decode(Bool.self, forKey: .verified) + askedOps = try c.decodeIfPresent([String].self, forKey: .askedOps) ?? [] + askedCaps = try c.decodeIfPresent(Caps.self, forKey: .askedCaps) ?? Caps() + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift index 2ba2986..ae0ae1a 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift @@ -47,6 +47,48 @@ public enum Contract { roleOwner: "Owner", roleViewer: "Viewer", roleWatch: "Watch", + roleAgent: "Assistant", + roleSupport: "Sourceful support", + roleRelease: "Release check", + ] + + /// Grants an owner gives to something other than a paired device + /// (docs/agents.md in srcfl/ftw). A role is the most a grant of that + /// kind may carry; each grant names a subset. + public static let roleAgent = "agent" + public static let roleSupport = "support" + public static let roleRelease = "release" + public static let agentRoleScopes: [String: [String]] = [ + roleAgent: agentScopes, + roleSupport: agentScopes, + roleRelease: ["ftw.health.read"], + ] + static let agentScopes = [ + "ftw.live.read", "ftw.history.read", "ftw.plan.read", "ftw.health.read", "ftw.events.read", + "ftw.logs.read", "ftw.config.read", "ftw.diag.run", "ftw.dispatch.hold", "ftw.goals.request", + ] + + /// Each scope's description, from the registry. Words for an owner come + /// from `AccessWords`; this is what they translate. + public static let scopeDescs: [String: String] = [ + "ftw.live.read": "Current readings and freshness", + "ftw.history.read": "Stored history", + "ftw.plan.read": "Dispatch plan and its reasoning", + "ftw.health.read": "Box and driver health", + "ftw.assets.read": "Devices and their capabilities", + "ftw.events.read": "Events behind notifications", + "ftw.dispatch.write": "Control energy assets", + "ftw.mode.write": "Change site operating mode", + "ftw.members.read": "See who has access", + "ftw.members.write": "Invite and revoke", + "ftw.logs.read": "Log excerpts the box redacts", + "ftw.config.read": "Configuration the box redacts", + "ftw.diag.run": "Checks that change nothing", + "ftw.dispatch.hold": "Hold energy assets for a capped time", + "ftw.goals.request": "Ask an owner to change a durable goal", + "ftw.update.request": "Ask an owner to install a version", + "ftw.cars.read": "See the owner's cars with their VINs and identifiers and sources", + "ftw.cars.write": "Keep the owner's list of cars", ] public static let scopeModeWrite = "ftw.mode.write" @@ -73,6 +115,9 @@ public enum Contract { public static let capCloudTun = "cloud.tun" /// The box can take a watch. Absent, the phone offers no Add watch. public static let capCmdExchange = "cmd.exchange" + /// The box takes agents and answers the agent messages. Absent, the app + /// offers no Access list. + public static let capAccessAgents = "access.agents" /// The scopes an FTW Cloud grant may name, and what its data may be /// used for: the registry's `cloud_scopes` and `cloud_purposes`. @@ -100,6 +145,101 @@ public enum Contract { opCarGoalSet, opCarSocSet, opSessionCarSet, opCarAdd, opCarUpdate, opCarRemove, ] + /// The scope that opens each op to an agent, from the registry's `agent` + /// on each op. `ftw.dispatch.hold` runs as a `cmd` within the grant's + /// caps; `ftw.goals.request` waits as an `agent.request` for an owner. + /// An op not listed is never open to an agent. + public static let agentOps: [String: String] = [ + opSetMode: "ftw.goals.request", + opPlannerPrefsSet: "ftw.goals.request", + opLoadpointHold: "ftw.dispatch.hold", + opLoadpointSocSet: "ftw.goals.request", + opLoadpointSurplusOnlySet: "ftw.goals.request", + opCarGoalSet: "ftw.goals.request", + opCarSocSet: "ftw.goals.request", + ] + + /// Each op's description, from the registry. + public static let opDescs: [String: String] = [ + opSetMode: "Change the site operating mode", + opPlannerPrefsSet: "Set household planner preferences", + opBatteryHold: "Hold the battery at a fixed setpoint", + opLoadpointHold: "Charge the car now at a fixed current", + opLoadpointBoost: "Boost the car from the house battery", + opLoadpointSocSet: "Correct the car's current charge level", + opLoadpointSurplusOnlySet: "Charge the car from surplus PV only", + opCarGoalSet: "Set how and when a car charges", + opCarSocSet: "Set a car's level by hand", + opSessionCarSet: "Say which car is on a charger", + opCarAdd: "Add a car to the owner's list", + opCarUpdate: "Change what the box knows about a car", + opCarRemove: "Remove a car from the owner's list", + ] + + /// One argument an agent may put in an op: the registry's `agent_args`. + /// `label` is the words the app shows an owner for the value. + public struct AgentArg: Equatable, Sendable { + public var name: String + public var kind: String + public var unit: String? + public var min: Double? + public var max: Double? + public var values: [String] + public var required: Bool + public var cap: String? + public var label: String + + init(_ name: String, _ kind: String, unit: String? = nil, min: Double? = nil, max: Double? = nil, values: [String] = [], required: Bool = false, cap: String? = nil, label: String) { + self.name = name + self.kind = kind + self.unit = unit + self.min = min + self.max = max + self.values = values + self.required = required + self.cap = cap + self.label = label + } + } + + public static let agentArgs: [String: [AgentArg]] = [ + opSetMode: [AgentArg("mode", "mode", required: true, label: "Operating mode")], + opPlannerPrefsSet: [ + AgentArg("safety_k", "number", min: 0, max: 2, label: "How cautious the plan is"), + AgentArg("battery_export", "enum", values: ["unknown", "not_allowed", "allowed"], label: "Whether the battery may sell to the grid"), + ], + opLoadpointHold: [ + AgentArg("id", "loadpoint", required: true, label: "Charger"), + AgentArg("power_w", "power", unit: "W", min: 0, cap: "maxW", label: "Charging power"), + AgentArg("hold_s", "duration", unit: "s", min: 1, cap: "holdS", label: "For how long"), + AgentArg("phase_mode", "enum", values: ["auto", "1p", "3p"], label: "Phases"), + AgentArg("clear", "bool", label: "Stop the agent's own hold"), + ], + opLoadpointSocSet: [ + AgentArg("id", "loadpoint", required: true, label: "Charger"), + AgentArg("soc", "ratio", min: 0, max: 1, required: true, label: "The car's charge level"), + ], + opLoadpointSurplusOnlySet: [ + AgentArg("id", "loadpoint", required: true, label: "Charger"), + AgentArg("surplus_only", "bool", required: true, label: "Charge from solar only"), + ], + opCarGoalSet: [ + AgentArg("id", "car", label: "Car"), + AgentArg("charger_id", "loadpoint", label: "Charger"), + AgentArg("target", "ratio", min: 0, max: 1, label: "The level to reach"), + AgentArg("ready_by_min", "number", min: 0, max: 1439, label: "Ready by, in minutes after midnight"), + AgentArg("days", "number", min: 0, max: 127, label: "Weekdays"), + AgentArg("finish_at_limit", "bool", label: "Charge on to the car's own limit"), + AgentArg("clear", "bool", label: "Remove the goal"), + AgentArg("surplus_only", "bool", label: "Charge from solar only"), + ], + opCarSocSet: [ + AgentArg("id", "car", label: "Car"), + AgentArg("charger_id", "loadpoint", label: "Charger"), + AgentArg("soc", "ratio", min: 0, max: 1, required: true, label: "The car's charge level"), + ], + ] + /// The box keeps cars apart from chargers. Absent, the charger is the /// car, as before. public static let capDerCar = "der.car" diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift index 6e755e1..3bdca5d 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/Session.swift @@ -180,6 +180,7 @@ public final class Session { private var pendingPlan: [UInt32: PendingSingle] = [:] private var pendingAPI: [UInt32: PendingAPI] = [:] private var pendingCommands: [String: PendingCommand] = [:] + private var pendingAsks: [UInt32: PendingSingle] = [:] // The api queue: one call on the wire at a time, because that is how // many the box serves. Waiting calls go in `ReadOrder`, then in turn. @@ -351,6 +352,28 @@ public final class Session { } } + /// One of the box's own messages that answers in kind: the agent + /// messages, which get `agent.ok` with the request's id, or an `error` + /// with it (`BoxRefusal`). Bulk lane, since a signed body or a list of + /// grants varies in length with what it says. + public func ask(_ t: String, _ body: CBOR, timeoutMs: Double = Session.planTimeoutMs) async throws -> CBOR { + guard carrier != nil else { throw SessionError.noCarrier } + let id = takeRequestID() + let frame = try Frame.encodeBulk(envelope: Envelope(t: t, id: id, b: body)) + return try await withCheckedThrowingContinuation { cont in + let timer = scheduler.after(timeoutMs) { [weak self] in + guard let p = self?.pendingAsks.removeValue(forKey: id) else { return } + p.continuation.resume(throwing: SessionError.timedOut(t)) + } + pendingAsks[id] = PendingSingle(continuation: cont, timer: timer) + sent(t) + if carrier?.send(frame) != true, let p = pendingAsks.removeValue(forKey: id) { + p.timer?.cancel() + p.continuation.resume(throwing: SessionError.carrierClosed) + } + } + } + /// Call the box's own HTTP API. A 404 is an answer here, not a failure; /// `BoxRefusal` means the passthrough refused and no handler ran. /// @@ -582,6 +605,10 @@ public final class Session { p.ackTimer.cancel() p.confirmTimer.cancel() p.continuation.resume(returning: result) + case "agent.ok": + guard let id = env.id, let pending = pendingAsks.removeValue(forKey: id) else { return } + pending.timer?.cancel() + pending.continuation.resume(returning: body) case "error": onError(ErrorMsg(body), id: env.id) case "session.terminate": var s = state @@ -725,6 +752,11 @@ public final class Session { p.continuation.resume(throwing: BoxRefusal(detail: b)) return } + if let p = pendingAsks.removeValue(forKey: id) { + p.timer?.cancel() + p.continuation.resume(throwing: BoxRefusal(detail: b)) + return + } } var s = state s.lastError = b @@ -799,6 +831,8 @@ public final class Session { pendingHistory = [:] for (_, p) in pendingPlan { p.timer?.cancel(); p.continuation.resume(throwing: SessionError.carrierClosed) } pendingPlan = [:] + for (_, p) in pendingAsks { p.timer?.cancel(); p.continuation.resume(throwing: SessionError.carrierClosed) } + pendingAsks = [:] let commands = pendingCommands pendingCommands = [:] for (cmdId, p) in commands { diff --git a/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift index f51c80b..bb839d6 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift @@ -7,6 +7,9 @@ import Observation @MainActor public final class HomeModels { public let site: SiteModel + /// Assistants and support, for an owner whose box takes them. Nil when + /// this phone cannot sign for the home. + public internal(set) var access: AccessModel? public let prices: PriceModel public let status: StatusWatch public let charging: ChargingWatch @@ -72,6 +75,16 @@ public final class AppModel { /// The demo keeps its sealed cache like a paired home, so a cold start /// can be recorded and timed. Only a Debug build sets it. public var demoKeepsCache = false + /// A link from an assistant's consent page, shown before anything is + /// granted. + public var accessLink: OfferLink? + /// This phone's approval keys, behind Face ID. The app supplies them. + @ObservationIgnored public var approvalKeys: ApprovalKeyring? + /// Where Get help opens a ticket. There is no help desk behind the app + /// yet. + @ObservationIgnored public var supportDesk: SupportDesk = NoSupportDesk() + /// The simulated home's keys, offer key and desk, from FTWDemo. + @ObservationIgnored public var demoAccess: (keys: ApprovalKeyring, offers: OfferKeys, desk: SupportDesk, lookup: OfferLookup)? @ObservationIgnored public let vault: Vault @ObservationIgnored public let sites: SiteList @@ -145,7 +158,12 @@ public final class AppModel { site.learnedHome = { [weak self] home in self?.sites.update(siteId) { $0.cloudHome = home } } - home = HomeModels(site: site, cache: shared.map { CacheReader(sources: [$0]) }) + let models = HomeModels(site: site, cache: shared.map { CacheReader(sources: [$0]) }) + if let stored = sites.get(siteId), let device = vault.deviceIDOnBox { + models.access = AccessModel(site: site, signedFor: SignedFor(boxKey: stored.boxStaticKey.byteArray, deviceID: device), keyring: approvalKeys, + wallMs: { [scheduler] in scheduler.nowMs }) + } + home = models sites.setCurrent(siteId) site.start() recovering = false @@ -288,6 +306,11 @@ public final class AppModel { /// can send a link, so it is shown with the box it names first. A link /// to the box this phone already has is a leftover and is dropped. public func offer(_ link: String) { + // An assistant's consent page, not a box: its own sheet. + if let access = OfferLink(link) { + accessLink = access + return + } guard let enrollment = try? Enrollment.parse(scanned: link) else { offeredLink = link return @@ -304,6 +327,25 @@ public final class AppModel { open(siteId) } + // MARK: Access + + /// The offer keys a flow checks against: the pinned ones, or the + /// simulated home's own in the demo, where an offer reaches only the + /// simulated box. + public var offerKeys: OfferKeys { isDemo ? (demoAccess?.offers ?? OfferKeys(pins: [])) : .current } + + public var desk: SupportDesk { isDemo ? (demoAccess?.desk ?? supportDesk) : supportDesk } + + /// Where a typed code finds its offer: FTW Cloud, or the simulated + /// home's own page in the demo. + public var offerLookup: OfferLookup { isDemo ? (demoAccess?.lookup ?? .current()) : .current() } + + /// The open home's id at FTW Cloud, which an offer that names a home + /// must match. + public var cloudHome: String? { + site?.session.cloud?.home ?? site?.siteId.flatMap { sites.get($0)?.cloudHome } + } + // MARK: The demo /// Whether this build carries the simulated home. @@ -319,7 +361,11 @@ public final class AppModel { let site = SiteModel(siteId: shares || keeps ? "demo" : nil, build: build, ua: ua, scheduler: scheduler, files: keeps ? files : nil, shared: shares ? shared : nil, isDemo: true, demoShares: shares) if shares { site.sharedWritten = sharedWritten } site.ceilingW = 11_000 - home = HomeModels(site: site, cache: shares ? shared.map { CacheReader(sources: [$0]) } : nil) + let models = HomeModels(site: site, cache: shares ? shared.map { CacheReader(sources: [$0]) } : nil) + if let demoAccess { + models.access = AccessModel(site: site, signedFor: AccessModel.demoFor, keyring: demoAccess.keys, wallMs: { [scheduler] in scheduler.nowMs }) + } + home = models isDemo = true recovering = false offeredLink = nil @@ -348,6 +394,7 @@ public final class AppModel { home?.loadpoints.endLiveActivities() home?.site.destroy() home = nil + for h in sites.all() { approvalKeys?.forget(home: h.siteId) } vault.reset() sites.clear() files?.clear() diff --git a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift index ee734a4..a02b161 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift @@ -832,6 +832,11 @@ public final class SiteModel { try await core.api(req) } + /// One of the box's agent messages, answered with `agent.ok`. + public func ask(_ t: String, _ body: CBOR) async throws -> CBOR { + try await core.ask(t, body) + } + // MARK: What widgets read /// A rewrite of the same car is skipped for this long. The charger is diff --git a/appleApp/FTWKit/Sources/FTWKit/Store/SecureStore.swift b/appleApp/FTWKit/Sources/FTWKit/Store/SecureStore.swift index a65ee79..a8922f4 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Store/SecureStore.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Store/SecureStore.swift @@ -17,6 +17,9 @@ public protocol SecureStore: AnyObject { /// not mean the value is gone. func read(_ key: String) throws -> Bytes? func put(_ key: String, _ value: Bytes) + /// As `put`, and an error when the store did not keep it: anything a + /// box will hold this phone to, such as an approval key, must know. + func write(_ key: String, _ value: Bytes) throws func remove(_ key: String) } @@ -46,6 +49,9 @@ extension SecureStore { /// A store that cannot fail to answer, such as `MemoryStore`. public func read(_ key: String) throws -> Bytes? { get(key) } + /// A store that cannot fail to keep, such as `MemoryStore`. + public func write(_ key: String, _ value: Bytes) throws { put(key, value) } + func getJSON(_ type: T.Type, _ key: String) -> T? { guard let raw = get(key) else { return nil } return try? JSONDecoder().decode(T.self, from: Data(raw)) diff --git a/appleApp/FTWKit/Tests/FTWKitTests/AccessFlowTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/AccessFlowTests.swift new file mode 100644 index 0000000..b4ca595 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/AccessFlowTests.swift @@ -0,0 +1,414 @@ +import Foundation +import Testing +@testable import FTWKit +import FTWDemo + +/// Access against the simulated box, which checks every approval signature +/// over the bytes the box would build: the first grant registers the key +/// under the same Face ID, a change is signed, a removal is one tap, and an +/// assistant's request is approved over exactly what was shown. +@MainActor +@Suite struct AccessFlowTests { + @MainActor final class World { + let scheduler = ManualScheduler(nowMs: 1_792_000_000_000) + let box: SimulatedBox + let site: SiteModel + let keyring = SimulatedKeyring() + let access: AccessModel + + init(seed: Bool = true, phoneClockOffMs: Double = 0) { + box = SimulatedBox(scheduler: scheduler) + if seed { box.access.seed() } + site = SiteModel(siteId: "home1", build: "t", ua: "t", scheduler: scheduler, files: nil) + let s = scheduler + access = AccessModel(site: site, signedFor: AccessModel.demoFor, keyring: keyring, wallMs: { s.nowMs + phoneClockOffMs }) + site.start() + site.connect(LoopbackCarrier(box: box, scheduler: scheduler, latencyMs: 20)) + } + + func run(_ ms: Double = 1_000) async { + var left = ms + while left > 0 { + for _ in 0..<20 { await Task.yield() } + scheduler.advance(min(10, left)) + left -= 10 + } + for _ in 0..<20 { await Task.yield() } + } + + /// Run `work` while the clock moves, as the box answers. + func during(_ work: @escaping @MainActor () async -> T) async -> T { + let task = Task { await work() } + await run(2_000) + return await task.value + } + + func offer(support: Bool = false) throws -> GrantOffer { + try SimulatedAccess.offer(support: support, nowMs: scheduler.nowMs).open(keys: SimulatedAccess.offerKeys, nowMs: Int64(scheduler.nowMs)) + } + } + + func world(seed: Bool = true, phoneClockOffMs: Double = 0) async -> World { + let w = World(seed: seed, phoneClockOffMs: phoneClockOffMs) + await w.run(500) + return w + } + + @Test func theBoxSaysWhoHasAccess() async { + let w = await world() + #expect(w.access.available) + await w.during { await w.access.refresh() } + #expect(w.access.assistants.map(\.principal.client) == ["Claude"]) + #expect(w.access.support.map(\.principal.ticket) == ["FTW-1042"]) + #expect(w.access.pending.map(\.op) == ["site.mode.set"]) + #expect(!w.access.boxHasKey) + } + + /// The first approval registers this phone's key and grants, under one + /// Face ID; the box checked both signatures. + @Test func theFirstGrantRegistersTheKeyUnderTheSameFaceID() async throws { + let w = await world() + let offer = try w.offer() + let row = await w.during { await w.access.approve(offer, grant: Grant.from(offer, control: false, lengthMs: 30 * 86_400_000)) } + #expect(w.access.help == nil) + let added = try #require(row) + #expect(w.keyring.unlocks == 1) + #expect(w.box.access.keys[AccessModel.demoFor.deviceID] != nil) + #expect(w.access.boxHasKey) + #expect(added.scopes == ["ftw.events.read", "ftw.health.read", "ftw.history.read", "ftw.live.read", "ftw.plan.read"]) + #expect(added.ops.isEmpty) + #expect(added.principal.clientId == "https://claude.ai/oauth/mcp-oauth-client-metadata") + // Within the role's longest, as the box holds it. + #expect(added.expiresAtMs <= Int64(w.scheduler.nowMs) + 30 * 86_400_000) + #expect(w.access.assistants.contains { $0.id == added.id }) + #expect(w.box.access.audit.prefix(2).map(\.what) == ["agent.add", "approval.key.set"]) + // The row names its client by id; the phone keeps where it lives. + #expect(w.access.host(added) == "claude.ai") + #expect(w.access.verified(added) == true) + var registered = added + registered.principal.clientId = "ftwc_zXEOM7hsgr" + registered.id = "someoneE" + #expect(w.access.host(registered).isEmpty) + #expect(w.access.place(registered) == AccessWords.format("Assistant %@", "someoneE")) + + // The next grant needs no new key: one more Face ID, nothing else. + let second = try w.offer() + _ = await w.during { await w.access.approve(second, grant: Grant.from(second, control: true, lengthMs: 86_400_000)) } + #expect(w.keyring.unlocks == 2) + #expect(w.box.access.audit.first?.what == "agent.add") + let controlled = try #require(w.access.list?.agents.first { $0.key == second.key }) + #expect(controlled.ops == ["car.goal.set", "loadpoint.hold", "planner.prefs.set", "site.mode.set"]) + #expect(controlled.caps.holdS == 1_800 && controlled.caps.maxW == 11_000) + } + + /// A support grant carries its ticket, and what the owner wrote stays on + /// the phone. + @Test func supportIsTiedToItsTicket() async throws { + let w = await world(seed: false) + let offer = try w.offer(support: true) + let grant = Grant.from(offer, control: false, lengthMs: 7 * 86_400_000) + #expect(!grant.scopes.contains(AccessWire.scopeHold)) + let row = try #require(await w.during { await w.access.approve(offer, grant: grant) }) + #expect(row.role == Contract.roleSupport) + #expect(row.principal.ticket == "FTW-1043") + w.access.note(row.principal.ticket, problem: "The battery stops at 80 %.") + #expect(w.access.problem(row) == "The battery stops at 80 %.") + } + + /// Any change but a removal is signed, the row's key and epoch with it. + @Test func aChangeIsSigned() async throws { + let w = await world() + await w.during { await w.access.refresh() } + let claude = try #require(w.access.assistants.first) + let narrower = Grant(scopes: ["ftw.live.read"], ops: [], caps: Caps(perMin: 6), lengthMs: 86_400_000) + // The key is registered under this change's Face ID too. + let ok = await w.during { await w.access.widen(claude, to: narrower) } + #expect(ok) + let now = try #require(w.access.row(claude.id)) + #expect(now.scopes == ["ftw.live.read"]) + #expect(now.ops.isEmpty) + #expect(now.epoch == claude.epoch + 1) + #expect(w.keyring.unlocks == 1) + // A stale epoch is the box's conflict, in words. + let stale = await w.during { await w.access.widen(claude, to: narrower) } + #expect(!stale) + #expect(w.access.help == AccessWords.text("Someone changed this meanwhile. Look again and try once more.")) + } + + /// Removing access takes one tap and no Face ID. + @Test func removingIsOneTap() async throws { + let w = await world() + await w.during { await w.access.refresh() } + let claude = try #require(w.access.assistants.first) + let ok = await w.during { await w.access.revoke(claude) } + #expect(ok) + #expect(w.keyring.unlocks == 0) + #expect(w.access.assistants.isEmpty) + #expect(w.box.access.rows.allSatisfy { $0.id != claude.id }) + // Its request went with it. + #expect(w.access.pending.isEmpty) + } + + /// Approving signs the request, the agent, the op and the hash of the + /// args exactly as received; declining is one tap. + @Test func aRequestIsApprovedOverWhatWasShown() async throws { + let w = await world() + await w.during { await w.access.refresh() } + let req = try #require(w.access.pending.first) + let lines = AppWords.$language.withValue(.en) { AccessWords.arguments(req, modes: SimulatedBox.modes) } + #expect(lines.map(\.label) == ["Operating mode"]) + #expect(lines.first?.value == ModeWords.name(SimulatedBox.modes.first { $0.key == "self_consumption" })) + let ok = await w.during { await w.access.answer(req, approve: true) } + #expect(ok) + #expect(w.box.access.requests.first?.state == "approved") + #expect(w.access.pending.isEmpty) + #expect(w.keyring.unlocks == 1) + } + + @Test func declineIsOneTap() async throws { + let w = await world() + await w.during { await w.access.refresh() } + let req = try #require(w.access.pending.first) + let ok = await w.during { await w.access.answer(req, approve: false) } + #expect(ok) + #expect(w.keyring.unlocks == 0) + #expect(w.box.access.requests.first?.state == "declined") + } + + /// A phone whose clock is off signs again with the box's time, under + /// the same Face ID. + @Test func aClockOffByAnHourSignsAgainWithTheBoxs() async throws { + let w = await world(phoneClockOffMs: 3_600_000) + let offer = try w.offer() + let row = await w.during { await w.access.approve(offer, grant: Grant.from(offer, control: false, lengthMs: 30 * 86_400_000)) } + #expect(row != nil) + #expect(w.keyring.unlocks == 1) + #expect(row!.expiresAtMs <= Int64(w.scheduler.nowMs) + 30 * 86_400_000) + } + + /// A new key needs the old key's signature; the box takes no other. + @Test func replacingTheKeyNeedsTheOldOne() async throws { + let w = await world() + let offer = try w.offer() + _ = await w.during { await w.access.approve(offer, grant: Grant.from(offer, control: false, lengthMs: 86_400_000)) } + let before = try #require(w.box.access.keys[AccessModel.demoFor.deviceID]) + let ok = await w.during { await w.access.replaceKey() } + #expect(ok) + let after = try #require(w.box.access.keys[AccessModel.demoFor.deviceID]) + #expect(after != before) + #expect(after == (try w.keyring.existing())?.publicKey) + } + + /// A box that holds a key this phone lost: say where to replace it. + @Test func aLostKeySaysWhereToReplaceIt() async throws { + let w = await world() + let offer = try w.offer() + _ = await w.during { await w.access.approve(offer, grant: Grant.from(offer, control: false, lengthMs: 86_400_000)) } + let fresh = AccessModel(site: w.site, signedFor: AccessModel.demoFor, keyring: SimulatedKeyring(), wallMs: { [s = w.scheduler] in s.nowMs }) + await w.during { await fresh.refresh() } + let again = try w.offer() + let row = await w.during { await fresh.approve(again, grant: Grant.from(again, control: false, lengthMs: 86_400_000)) } + #expect(row == nil) + #expect(fresh.help == AccessWords.help(ApprovalKeyLost())) + } + + /// The audit log names who did what, and a support call's person as + /// FTW Cloud named them. + @Test func theAuditSaysWhoDidWhat() async throws { + let w = await world() + await w.during { await w.access.refresh() } + await w.during { await w.access.loadAudit() } + #expect(!w.access.audit.isEmpty) + let support = try #require(w.access.audit.first { !$0.by.isEmpty }) + AppWords.$language.withValue(.en) { + #expect(AccessWords.actor(support, me: AccessModel.demoFor.deviceID, agents: w.access.list?.agents ?? []) == "Sourceful support") + #expect(AccessWords.by(support) == "anna.lindqvist, named by FTW Cloud") + #expect(AccessWords.what(support) == "Read the log ×3") + } + AppWords.$language.withValue(.sv) { + #expect(AccessWords.by(support) == "anna.lindqvist, enligt FTW Cloud") + } + // Newest first, in seq. + #expect(w.access.audit.map(\.seq) == w.access.audit.map(\.seq).sorted(by: >)) + } + + /// The simulated home has no id; its key is kept under one anyway. + @Test func theDemoApproves() async throws { + let scheduler = ManualScheduler(nowMs: 1_792_000_000_000) + let app = AppModel(store: MemoryStore(), files: nil, passkeys: nil, scheduler: scheduler, build: "t", ua: "t", demo: SimulatedBox.loopback) + let keyring = SimulatedKeyring() + app.demoAccess = (keyring, SimulatedAccess.offerKeys, SimulatedDesk(), SimulatedAccess.lookup) + app.startDemo() + let access = try #require(app.home?.access) + func run(_ ms: Double) async { + var left = ms + while left > 0 { + for _ in 0..<20 { await Task.yield() } + scheduler.advance(10) + left -= 10 + } + } + await run(1_000) + #expect(access.available) + let offer = try SimulatedAccess.offer(nowMs: scheduler.nowMs).open(keys: app.offerKeys, nowMs: Int64(scheduler.nowMs)) + let task = Task { await access.approve(offer, grant: Grant.from(offer, control: false, lengthMs: 86_400_000)) } + await run(3_000) + let row = await task.value + #expect(row != nil, "\(access.help ?? "")") + #expect(keyring.unlocks == 1) + } + + /// A cached list knows no keys: the box's own list decides, and with + /// no answer nothing is signed. + @Test func aCachedListNeverRegistersAKey() async throws { + let w = await world() + let offer = try w.offer() + _ = await w.during { await w.access.approve(offer, grant: Grant.from(offer, control: false, lengthMs: 86_400_000)) } + let key = try #require(w.box.access.keys[AccessModel.demoFor.deviceID]) + let sets = w.box.access.audit.filter { $0.what == "approval.key.set" }.count + + // The next visit opens on the kept rows, which carry no keys. + let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let files = SealedFiles(directory: dir, store: MemoryStore()) + files.writeJSON("access-home1", w.access.list?.agents ?? []) + let site = SiteModel(siteId: "home1", build: "t", ua: "t", scheduler: w.scheduler, files: files) + let again = AccessModel(site: site, signedFor: AccessModel.demoFor, keyring: w.keyring, wallMs: { [s = w.scheduler] in s.nowMs }) + #expect(!(again.list?.agents.isEmpty ?? true)) + #expect(!again.keysKnown && !again.boxHasKey) + site.start() + site.connect(LoopbackCarrier(box: w.box, scheduler: w.scheduler, latencyMs: 20)) + await w.run(500) + let second = try w.offer() + let row = await w.during { await again.approve(second, grant: Grant.from(second, control: false, lengthMs: 86_400_000)) } + #expect(row != nil, "\(again.help ?? "")") + #expect(w.box.access.audit.filter { $0.what == "approval.key.set" }.count == sets) + #expect(w.box.access.keys[AccessModel.demoFor.deviceID] == key) + + // No answer to agent.list: no Face ID and nothing sent. + let mute = AccessModel(site: site, signedFor: AccessModel.demoFor, keyring: w.keyring, wallMs: { [s = w.scheduler] in s.nowMs }) + w.box.mute = true + let unlocks = w.keyring.unlocks + let third = try w.offer() + let waiting = Task { await mute.approve(third, grant: Grant.from(third, control: false, lengthMs: 86_400_000)) } + // Past the box's answer deadline. + await w.run(Session.planTimeoutMs + 1_000) + let none = await waiting.value + #expect(none == nil) + #expect(w.keyring.unlocks == unlocks) + w.box.mute = false + } + + /// A first key is kept before the box hears of it; one that cannot be + /// kept is never sent. + @Test func aKeyThatCannotBeKeptIsNeverSent() async throws { + let w = await world() + w.keyring.keepFails = true + let offer = try w.offer() + let row = await w.during { await w.access.approve(offer, grant: Grant.from(offer, control: false, lengthMs: 86_400_000)) } + #expect(row == nil) + #expect(w.box.access.keys.isEmpty) + #expect(w.access.help == AccessWords.help(ApprovalKeyNotKept())) + } + + /// A replacement the box took though its answer was lost stays with the + /// phone, and the next approval finds the key the box holds. + @Test func aReplacementIsNeverStranded() async throws { + let w = await world() + let offer = try w.offer() + _ = await w.during { await w.access.approve(offer, grant: Grant.from(offer, control: false, lengthMs: 86_400_000)) } + let old = try #require(w.box.access.keys[AccessModel.demoFor.deviceID]) + w.box.access.loseNextAnswer = true + let task = Task { await w.access.replaceKey() } + await w.run(10_000) + #expect(await task.value == false) + let taken = try #require(w.box.access.keys[AccessModel.demoFor.deviceID]) + #expect(taken != old) + #expect(try w.keyring.pending()?.publicKey == taken) + + let next = try w.offer() + let row = await w.during { await w.access.approve(next, grant: Grant.from(next, control: false, lengthMs: 86_400_000)) } + #expect(row != nil, "\(w.access.help ?? "")") + #expect(try w.keyring.existing()?.publicKey == taken) + #expect(try w.keyring.pending() == nil) + // Every Face ID given was ended. + #expect(w.keyring.ends == w.keyring.unlocks) + } + + /// A widen that does not renew keeps the end exactly. + @Test func aChangeWithoutRenewingKeepsTheEnd() async throws { + let w = await world() + await w.during { await w.access.refresh() } + let claude = try #require(w.access.assistants.first) + let same = Grant(scopes: ["ftw.live.read"], ops: [], caps: Caps(perMin: 6), lengthMs: 0, expiresAtMs: claude.expiresAtMs) + #expect(await w.during { await w.access.widen(claude, to: same) }) + #expect(w.access.row(claude.id)?.expiresAtMs == claude.expiresAtMs) + } + + /// Only what the app can show may be approved; the rest is declined. + @Test func aStrangeRequestCanOnlyBeDeclined() async throws { + func request(_ op: String, _ args: CBOR) -> AgentRequest { + AgentRequest(reqId: "r", agent: "a", op: op, args: args, why: "", atMs: 0, expiresAtMs: 0, state: "pending") + } + #expect(AccessWords.approvable(request("site.mode.set", .map([("mode", .text("self_consumption"))])))) + #expect(!AccessWords.approvable(request("site.mode.set", .map([("mode", .text("x")), ("admin", .bool(true))])))) + #expect(!AccessWords.approvable(request("site.mode.set", .map([("mode", .int(3))])))) + #expect(!AccessWords.approvable(request("car.add", .emptyMap))) + #expect(!AccessWords.approvable(request("planner.prefs.set", .map([("battery_export", .text("sometimes"))])))) + #expect(!AccessWords.approvable(request("car.goal.set", .map([("ready_by_min", .double(.infinity))])))) + #expect(!AccessWords.approvable(request("car.goal.set", .map([("ready_by_min", .double(1e300))])))) + // Never a crash on a number too large to mean anything. + let huge = request("car.goal.set", .map([("ready_by_min", .double(1e300)), ("days", .double(-.infinity)), ("target", .double(.nan))])) + #expect(AccessWords.arguments(huge).count == 3) + + let w = await world() + await w.during { await w.access.refresh() } + let strange = request("site.mode.set", .map([("mode", .text("idle")), ("extra", .text("x"))])) + #expect(await w.during { await w.access.answer(strange, approve: true) } == false) + #expect(w.keyring.unlocks == 0) + } + + /// An assistant may not call itself Sourceful or FTW; every name is its + /// own word, quoted, with no hidden characters. + @Test func noAssistantPassesForSourceful() { + AppWords.$language.withValue(.en) { + var row = AgentRow(id: "q7Xn2vKd", key: [], role: Contract.roleAgent, scopes: [], ops: [], caps: Caps(), expiresAtMs: 0, + principal: Principal(client: "Sourceful support", clientId: "https://evil.example/c")) + #expect(AccessWords.name(row) == "Assistant q7Xn2vKd") + row.principal.client = "FTW Helper" + #expect(AccessWords.name(row) == "Assistant q7Xn2vKd") + row.principal.client = "Sourceful" + #expect(AccessWords.name(row) == "Assistant q7Xn2vKd") + row.principal.client = "Claude\u{202E}evil" + #expect(AccessWords.name(row) == "“Claude evil”") + row.role = Contract.roleSupport + #expect(AccessWords.name(row) == "Sourceful support") + var o = GrantOffer() + o.role = Contract.roleAgent + o.client = "Sourceful" + o.clientHost = "evil.example" + #expect(AccessWords.clientName(o) == "Assistant at evil.example") + #expect(AccessWords.nameHidden(o.client)) + #expect(AccessModel.cut(String(repeating: "å", count: 200)).utf8.count == 256) + #expect(AccessModel.cut(String(repeating: "å", count: 200)).count == 128) + } + } + + /// Every word an owner reads has its Swedish. + @Test func everyWordHasItsSwedish() { + for scope in Contract.agentRoleScopes[Contract.roleAgent]! { + #expect(AccessWords.sv[AccessWords.scopeWords[scope] ?? ""] != nil, "\(scope)") + } + for op in Contract.agentOps.keys { + #expect(AccessWords.sv[AccessWords.opWords[op] ?? ""] != nil, "\(op)") + } + for args in Contract.agentArgs.values { + for a in args { #expect(AccessWords.sv[a.label] != nil, "\(a.label)") } + } + for (_, en) in AccessWords.auditWords { #expect(AccessWords.sv[en] != nil, "\(en)") } + for (_, en) in AccessWords.enumWords { #expect(AccessWords.sv[en] != nil, "\(en)") } + let errors: [OfferError] = [.noPinnedKey, .unsigned, .expired, .malformed, .notFromSourceful, .strangeHost, .used, .unknownHome, .wrongCode, .notFound, .unreachable] + AppWords.$language.withValue(.sv) { + for e in errors { #expect(AccessWords.offer(e) != AppWords.$language.withValue(.en) { AccessWords.offer(e) }, "\(e)") } + } + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/ApprovalTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/ApprovalTests.swift new file mode 100644 index 0000000..a85f804 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/ApprovalTests.swift @@ -0,0 +1,159 @@ +import CryptoKit +import Foundation +import Testing +@testable import FTWKit + +/// srcfl/ftw `go/protocol/testdata/approval-vectors.json`, copied byte for +/// byte. The box made these with the code it checks approvals with, so +/// building the same bytes here is what lets a phone's signature verify at +/// a box. +struct ApprovalVectors: Decodable { + struct Case: Decodable { + let what: String + let bodyHex: String + let contextHex: String? + let signedHex: String + let sha256Hex: String + let sigHex: String + } + + struct Offer: Decodable { + let kid: String + let pubHex: String + let nowMs: Int64 + let signedOfferHex: String + let oHex: String + let signedBytesHex: String + } + + let pubHex: String + let boxKeyHex: String + let deviceId: String + let cases: [Case] + let offer: Offer + + static func load() throws -> ApprovalVectors { + guard let url = Bundle.module.url(forResource: "approval-vectors", withExtension: "json", subdirectory: "Resources") else { + throw CocoaError(.fileNoSuchFile) + } + return try JSONDecoder().decode(ApprovalVectors.self, from: Data(contentsOf: url)) + } + + var signedFor: SignedFor { SignedFor(boxKey: Bytes(hex: boxKeyHex), deviceID: deviceId) } +} + +@MainActor +@Suite struct ApprovalTests { + let v: ApprovalVectors + + init() throws { + v = try ApprovalVectors.load() + } + + /// The bytes this phone would sign for each body the box signed, the + /// box's own context added: equal to the box's, byte for byte. + @Test func theSignedBytesAreTheBoxs() throws { + #expect(v.cases.count == 5) + for c in v.cases { + let body = try decodeCBOR(Bytes(hex: c.bodyHex)) + let built = try signedBytes(c, body) + #expect(built.hex == c.signedHex, "\(c.what)") + #expect(Primitives.sha256(built).hex == c.sha256Hex, "\(c.what)") + } + } + + /// The box's signatures verify here over those bytes, and nothing else + /// does. + @Test func theBoxsSignaturesVerify() throws { + let pub = Bytes(hex: v.pubHex) + for c in v.cases { + let signed = Bytes(hex: c.signedHex) + var sig = Bytes(hex: c.sigHex) + #expect(Approval.verify(pub: pub, message: signed, sig: sig), "\(c.what)") + sig[10] ^= 1 + #expect(!Approval.verify(pub: pub, message: signed, sig: sig), "\(c.what)") + #expect(!Approval.verify(pub: pub, message: signed + [0], sig: Bytes(hex: c.sigHex)), "\(c.what)") + } + } + + /// A body this app sends is the box's bytes when it says the same. + @Test func theBodiesAreTheBoxs() throws { + let keySet = try #require(v.cases.first { $0.what == Approval.keySet }) + let ks = try decodeCBOR(Bytes(hex: keySet.bodyHex)) + let sent = CBOR.map([("pub", ks["pub"]!), ("atMs", ks["atMs"]!), ("sig", ks["sig"]!)]).deterministic + #expect(encodeCBOR(sent).hex == keySet.bodyHex) + + let addCase = try #require(v.cases.first { $0.what == Approval.add }) + let b = try decodeCBOR(Bytes(hex: addCase.bodyHex)) + var add = try agentAdd(b) + add.sig = b["sig"]!.byteString! + #expect(encodeCBOR(add.cbor).hex == addCase.bodyHex) + } + + /// A signature made here verifies under the same rules: r ‖ s over + /// SHA-256 of the bytes, as CryptoKit hashes them itself. + @Test func aSignatureMadeHereVerifies() throws { + let signer = SoftwareSigner() + let msg = Approval.keySetBytes(v.signedFor, atMs: 1_792_000_000_000, pub: signer.publicKey) + let sig = try signer.sign(msg) + #expect(sig.count == Approval.sigBytes) + #expect(signer.publicKey.count == Approval.pubBytes && signer.publicKey[0] == 4) + #expect(Approval.verify(pub: signer.publicKey, message: msg, sig: sig)) + // Bound to the box and the phone. + var other = v.signedFor + other.deviceID = "otherPh1" + #expect(!Approval.verify(pub: signer.publicKey, message: Approval.keySetBytes(other, atMs: 1_792_000_000_000, pub: signer.publicKey), sig: sig)) + } + + /// The box refuses a list that is not sorted or repeats; so does the + /// phone, before Face ID. + @Test func anUnsortedListIsNoApproval() { + let add = AgentAdd(key: Bytes(repeating: 1, count: 32), role: "agent", scopes: ["ftw.plan.read", "ftw.live.read"], ops: [], + caps: Caps(), expiresAtMs: 2, principal: Principal(), atMs: 1) + #expect(throws: Approval.Unsorted.self) { try Approval.addBytes(v.signedFor, add) } + #expect(!Approval.isSortedUnique(["a", "a"])) + #expect(Approval.isSortedUnique(["ftw.live.read", "ftw.plan.read"])) + #expect(Approval.sorted(["b", "a", "b"]) == ["a", "b"]) + } + + /// Deterministic CBOR orders a map's keys by their encoding: shorter + /// text first, then bytewise, at every depth. + @Test func mapsAreInTheBoxsOrder() { + let m = CBOR.map([("args", .map([("zz", .int(1)), ("a", .int(2))])), ("op", .text("x")), ("why", .text(""))]) + let out = m.deterministic.entries!.map { $0.key.string! } + #expect(out == ["op", "why", "args"]) + #expect(m.deterministic["args"]!.entries!.map { $0.key.string! } == ["a", "zz"]) + // Floats in their shortest exact form, as the box writes them. + #expect(encodeDeterministic(.double(0.5)).hex == "f93800") + #expect(encodeDeterministic(.double(0.1)).hex == "fb3fb999999999999a") + } + + // MARK: Building the bytes from a body, as the phone does + + func signedBytes(_ c: ApprovalVectors.Case, _ b: CBOR) throws -> Bytes { + let f = v.signedFor + let atMs = b["atMs"]!.int64! + switch c.what { + case Approval.keySet: + return Approval.keySetBytes(f, atMs: atMs, pub: b["pub"]!.byteString!) + case Approval.add: + return try Approval.addBytes(f, try agentAdd(b)) + case Approval.widen: + let m = AgentWiden(id: b["id"]!.string!, epoch: UInt64(b["epoch"]!.int64!), scopes: b["scopes"]?.stringArray ?? [], + ops: b["ops"]?.stringArray ?? [], caps: Caps(b["caps"]), expiresAtMs: b["expiresAtMs"]!.int64!, atMs: atMs) + return try Approval.widenBytes(f, m, key: Bytes(hex: c.contextHex!)) + case Approval.confirm: + let req = try #require(AgentRequest(try decodeCBOR(Bytes(hex: c.contextHex!)))) + return Approval.confirmBytes(f, reqId: b["reqId"]!.string!, atMs: atMs, request: req) + default: + Issue.record("unknown case \(c.what)") + return [] + } + } + + func agentAdd(_ b: CBOR) throws -> AgentAdd { + AgentAdd(key: b["key"]!.byteString!, role: b["role"]!.string!, scopes: b["scopes"]?.stringArray ?? [], + ops: b["ops"]?.stringArray ?? [], caps: Caps(b["caps"]), expiresAtMs: b["expiresAtMs"]!.int64!, + principal: Principal(b["principal"]), atMs: b["atMs"]!.int64!) + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/ContractTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/ContractTests.swift index dd39e56..6258fd0 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/ContractTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/ContractTests.swift @@ -64,6 +64,69 @@ import FTWDemo #expect(yaml.contains("car_identified_by: [\(Contract.carIdentifiedBy.joined(separator: ", "))]")) } + /// The agents' names (docs/agents.md in srcfl/ftw): what each role may + /// carry, which scope opens each op, and what an agent may put in it, + /// with the label an owner reads. + @Test func agentNamesAreTheRegistrys() { + let roles = section("roles") + for role in [Contract.roleAgent, Contract.roleSupport, Contract.roleRelease] { + let start = roles.firstIndex { $0.trimmingCharacters(in: .whitespaces) == "\(role):" }! + let label = roles[roles.index(after: start)].trimmingCharacters(in: .whitespaces) + let scopes = roles[roles.index(start, offsetBy: 2)].trimmingCharacters(in: .whitespaces) + #expect(label == "label: \(Contract.roleLabels[role]!)", "\(role)") + #expect(scopes == "scopes: [\(Contract.agentRoleScopes[role]!.joined(separator: ", "))]", "\(role)") + } + for line in section("scopes") where line.contains("{ name:") { + let name = values("scopes", "name").first { line.contains("name: \($0),") }! + #expect(line.contains("desc: \(Contract.scopeDescs[name]!) }"), "\(name)") + } + #expect(Set(Contract.scopeDescs.keys) == Set(Contract.scopes)) + var agentOps = [String: String]() + for line in section("ops") where line.contains("{ name:") { + let name = String(line.split(separator: "name: ")[1].prefix { $0 != "," }) + #expect(line.contains("desc: \(Contract.opDescs[name]!) }"), "\(name)") + if let r = line.range(of: "agent: ") { agentOps[name] = String(line[r.upperBound...].prefix { $0 != "," }) } + } + #expect(agentOps == Contract.agentOps) + // agent_args: an op's name on its own line, then one line per argument. + var args = [String: [Contract.AgentArg]]() + var op = "" + for line in section("agent_args") { + let t = line.trimmingCharacters(in: .whitespaces) + if t.hasSuffix(":"), !t.hasPrefix("-") { op = String(t.dropLast()); continue } + guard t.hasPrefix("- {") else { continue } + let body = t.dropFirst(3).dropLast(1).trimmingCharacters(in: .whitespaces) + var fields = [String: String]() + var rest = Substring(body) + while let colon = rest.range(of: ": ") { + let key = rest[.. GrantOffer { + var o = GrantOffer() + o.v = 1 + o.key = Bytes(repeating: 0xa0, count: 32) + o.role = Contract.roleAgent + o.client = "Claude" + o.clientId = "https://claude.ai/oauth/mcp-oauth-client-metadata" + o.clientHost = "claude.ai" + o.verified = true + o.scopes = ["ftw.live.read", "ftw.plan.read"] + o.ttlS = 30 * 86_400 + o.code = "K7QF2MXD" + o.expMs = 1_000 + 9 * 60_000 + return o + } + + func sign(_ o: GrantOffer, _ key: SoftwareSigner) throws -> SignedOffer { + let raw = encodeDeterministic(o.cbor) + return SignedOffer(o: raw, kid: OfferKeys.kid(key.publicKey), sig: try key.sign(SignedOffer.signedBytes(raw))) + } + + func lookup(_ answer: @escaping @Sendable (URLRequest) throws -> (Data, Int)) -> OfferLookup { + OfferLookup(base: URL(string: "https://cloud.test")!) { try answer($0) } + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/Resources/approval-vectors.json b/appleApp/FTWKit/Tests/FTWKitTests/Resources/approval-vectors.json new file mode 100644 index 0000000..74caff7 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/Resources/approval-vectors.json @@ -0,0 +1,54 @@ +{ + "note": "docs/agents.md, The approval signature. signedHex is what the phone signs; sigHex is ECDSA P-256 over SHA-256 of it, r||s.", + "pubHex": "0494bd59f0454b6dffdb14ceb8fb777884a5b85fb3087458ab665a9e43c1943a0b2640997629d88c605987519a24a822fa1787ad19970d6e76761360872f833d2c", + "boxKeyHex": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + "deviceId": "ownerPh1", + "cases": [ + { + "what": "approval.key.set", + "bodyHex": "a36370756258410494bd59f0454b6dffdb14ceb8fb777884a5b85fb3087458ab665a9e43c1943a0b2640997629d88c605987519a24a822fa1787ad19970d6e76761360872f833d2c637369675840572e0e9343114001fe0e08e2b6803944572a07ce868ba32ab5e50ea534287d2688df2d87def4b16f612ded77f2ca199591efe6a05c9b9cf50cb9e08a7fe40a6a6461744d731b000001a13b860000", + "signedHex": "866f6674772e617070726f76616c2e763170617070726f76616c2e6b65792e73657458200102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20686f776e65725068311b000001a13b86000058410494bd59f0454b6dffdb14ceb8fb777884a5b85fb3087458ab665a9e43c1943a0b2640997629d88c605987519a24a822fa1787ad19970d6e76761360872f833d2c", + "sha256Hex": "c1b1e8e41f10d4fb9bf01e62f679c7dab38f157beafc9206732cd50f9ae4e19c", + "sigHex": "572e0e9343114001fe0e08e2b6803944572a07ce868ba32ab5e50ea534287d2688df2d87def4b16f612ded77f2ca199591efe6a05c9b9cf50cb9e08a7fe40a6a" + }, + { + "what": "agent.add", + "bodyHex": "a9636b65795820a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf636f7073816e6c6f6164706f696e742e686f6c646373696758403747ea567ad51a14821b73bb13cfd10ba9a2b6ba22b5018f36fe989ba8a7ec10a9bf0ed986f3d54d3890972d7224ffa6f8037558fd355b02419cc953a1f8fa636461744d731b000001a13b8600006463617073a3646d617857192af865686f6c6453190384667065724d696e0664726f6c65656167656e746673636f70657383716674772e64697370617463682e686f6c646d6674772e6c6976652e726561646d6674772e706c616e2e72656164697072696e636970616ca366636c69656e7466436c61756465667469636b65746068636c69656e744964782b68747470733a2f2f636c617564652e61692f6f617574682f6d63702d636c69656e742d6d657461646174616b6578706972657341744d731b000001a1d604c800", + "signedHex": "8e6f6674772e617070726f76616c2e7631696167656e742e61646458200102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20686f776e65725068311b000001a13b8600005820a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf656167656e7483716674772e64697370617463682e686f6c646d6674772e6c6976652e726561646d6674772e706c616e2e72656164816e6c6f6164706f696e742e686f6c6483190384192af8061b000001a1d604c80066436c61756465782b68747470733a2f2f636c617564652e61692f6f617574682f6d63702d636c69656e742d6d6574616461746160", + "sha256Hex": "638de060149662d97f7ee71bd30c3d6a76e010171ed21fb874c9d3ea589bfc74", + "sigHex": "3747ea567ad51a14821b73bb13cfd10ba9a2b6ba22b5018f36fe989ba8a7ec10a9bf0ed986f3d54d3890972d7224ffa6f8037558fd355b02419cc953a1f8fa63" + }, + { + "what": "agent.add", + "bodyHex": "a9636b65795820a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf636f7073f663736967584029023e8a6d2fc4fa27f32cffaf314f65666d5acf45aa8d20b89a32ea9de75e6cb6aaecf84ce2736bda5c7f19849406d8f02c1ed1ef9d79e9965f0cb3fc59b50e6461744d731b000001a13b8600006463617073a3646d6178570065686f6c645300667065724d696e0064726f6c6567737570706f72746673636f706573826f6674772e6865616c74682e726561646d6674772e6c6f67732e72656164697072696e636970616ca366636c69656e7471536f7572636566756c20737570706f7274667469636b6574674654572d31323368636c69656e744964606b6578706972657341744d731b000001a1839f0800", + "signedHex": "8e6f6674772e617070726f76616c2e7631696167656e742e61646458200102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20686f776e65725068311b000001a13b8600005820a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf67737570706f7274826f6674772e6865616c74682e726561646d6674772e6c6f67732e7265616480830000001b000001a1839f080071536f7572636566756c20737570706f727460674654572d313233", + "sha256Hex": "cb6c7351948afb36746cc96b2cbd4111bc19bfbad7f361a743ca6a0615a812eb", + "sigHex": "29023e8a6d2fc4fa27f32cffaf314f65666d5acf45aa8d20b89a32ea9de75e6cb6aaecf84ce2736bda5c7f19849406d8f02c1ed1ef9d79e9965f0cb3fc59b50e" + }, + { + "what": "agent.widen", + "bodyHex": "a8626964686f4b47696f36536c636f7073f663736967584007a9d60a120b6fd2e4009024dc71a463404a5696db357e0eec9de0364bd97eb72339cb0784afca9572a662d2f807b504c5f801c56f9851e36fd937223d92260e6461744d731b000001a13b8600006463617073a3646d6178570065686f6c645300667065724d696e066565706f6368026673636f706573826d6674772e6c6976652e726561646d6674772e706c616e2e726561646b6578706972657341744d731b000001a1d604c800", + "contextHex": "a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf", + "signedHex": "8c6f6674772e617070726f76616c2e76316b6167656e742e776964656e58200102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20686f776e65725068311b000001a13b860000686f4b47696f36536c5820a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf02826d6674772e6c6976652e726561646d6674772e706c616e2e7265616480830000061b000001a1d604c800", + "sha256Hex": "f4c10ed374e6d4d814fa45bce11232c5644e2d398d9ccf80cc7125bb9f851ffe", + "sigHex": "07a9d60a120b6fd2e4009024dc71a463404a5696db357e0eec9de0364bd97eb72339cb0784afca9572a662d2f807b504c5f801c56f9851e36fd937223d92260e" + }, + { + "what": "agent.confirm", + "bodyHex": "a463736967584096b5a9c1c2280fd23e59d72a6a976831afff61db35367146436f5d1b92f94de3ad4aace2fdc82787a61e52b1fdf676895c2787d8448360febff79e3db7e27ef66461744d731b000001a13b8600006572657149647641414543417751464267634943516f4c4441304f447767617070726f7665f5", + "contextHex": "a8626f706d736974652e6d6f64652e736574637768796f4368656170657220746f6e696768746461726773a1646d6f64657073656c665f636f6e73756d7074696f6e6461744d731b000001a13b860000656167656e74686f4b47696f36536c6572657149647641414543417751464267634943516f4c4441304f44776573746174656770656e64696e676b6578706972657341744d731b000001a13e192e00", + "signedHex": "896f6674772e617070726f76616c2e76316d6167656e742e636f6e6669726d58200102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20686f776e65725068311b000001a13b8600007641414543417751464267634943516f4c4441304f4477686f4b47696f36536c6d736974652e6d6f64652e7365745820a2149767a52b4bd6163c900c759658ec707ff59838a9898538d2c0e3fe79cefe", + "sha256Hex": "81a8677e665648b6f96454b19dee4c5b2a728d6c0a12b346ee18b9315c02133e", + "sigHex": "96b5a9c1c2280fd23e59d72a6a976831afff61db35367146436f5d1b92f94de3ad4aace2fdc82787a61e52b1fdf676895c2787d8448360febff79e3db7e27ef6" + } + ], + "offer": { + "note": "docs/agents.md, The grant offer. The link fragment is base64url of signedOfferHex.", + "kid": "yvJTP04C98wsViEa", + "pubHex": "047b0b548a5dba713577235bdfc95cb2de5231c2cf856adf6112f7a248617f2fb4236ea44a16d109c9f265569c1a735b766437fae63a6afcee3da61dafd3169042", + "nowMs": 1792000000000, + "signedOfferHex": "a3616f590110af617601636578701b000001a13b8a93e0636b65795820a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf636f7073806463617073a3646d6178570065686f6c645300667065724d696e0064636f6465694b3751462d324d584464686f6d656064726f6c65656167656e746474746c531a00278d0066636c69656e7466436c617564656673636f706573826d6674772e6c6976652e726561646d6674772e706c616e2e72656164667469636b65746068636c69656e744964782b68747470733a2f2f636c617564652e61692f6f617574682f6d63702d636c69656e742d6d65746164617461687665726966696564f56a636c69656e74486f737469636c617564652e6169636b69647079764a545030344339387773566945616373696758407ab7ea5341bd741de5d72129948cea0171de7bb2ce88232f15834401ee771e54673f6025cc5876a330b45e7d4bf3671bada1c0416c1ddc347e42087048669269", + "oHex": "af617601636578701b000001a13b8a93e0636b65795820a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf636f7073806463617073a3646d6178570065686f6c645300667065724d696e0064636f6465694b3751462d324d584464686f6d656064726f6c65656167656e746474746c531a00278d0066636c69656e7466436c617564656673636f706573826d6674772e6c6976652e726561646d6674772e706c616e2e72656164667469636b65746068636c69656e744964782b68747470733a2f2f636c617564652e61692f6f617574682f6d63702d636c69656e742d6d65746164617461687665726966696564f56a636c69656e74486f737469636c617564652e6169", + "signedBytesHex": "826c6674772e6f666665722e7631590110af617601636578701b000001a13b8a93e0636b65795820a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf636f7073806463617073a3646d6178570065686f6c645300667065724d696e0064636f6465694b3751462d324d584464686f6d656064726f6c65656167656e746474746c531a00278d0066636c69656e7466436c617564656673636f706573826d6674772e6c6976652e726561646d6674772e706c616e2e72656164667469636b65746068636c69656e744964782b68747470733a2f2f636c617564652e61692f6f617574682f6d63702d636c69656e742d6d65746164617461687665726966696564f56a636c69656e74486f737469636c617564652e6169" + } +} diff --git a/appleApp/FTWUITests/AccessE2E.swift b/appleApp/FTWUITests/AccessE2E.swift new file mode 100644 index 0000000..8dcb215 --- /dev/null +++ b/appleApp/FTWUITests/AccessE2E.swift @@ -0,0 +1,114 @@ +import XCTest + +/// One assistant approved end to end against a real box and a local FTW +/// Cloud, then removed. Skipped unless the rig is up: +/// +/// TEST_RUNNER_FTW_E2E_PAIR= +/// TEST_RUNNER_FTW_E2E_LINK= +/// TEST_RUNNER_FTW_E2E_CODE= +/// TEST_RUNNER_FTW_E2E_CLOUD=wss://127.0.0.1:18943/v1/app +/// TEST_RUNNER_FTW_E2E_CLOUD_KEY= +/// TEST_RUNNER_FTW_E2E_OFFER_KEYS=: +/// xcodebuild test … -only-testing:FTWUITests/AccessE2E +/// +/// The simulator must trust the rig's CA (`simctl keychain add-root-cert`), +/// have Face ID enrolled, and be told to match while the test waits for it +/// (`notifyutil -p com.apple.BiometricKit_Sim.pearl.match`): the rig's +/// script does both. The box's pairing code names no home yet, so the +/// first session goes through the relay, as a newly paired phone's does. +final class AccessE2E: XCTestCase { + @MainActor + func testApproveThenRemove() throws { + let env = ProcessInfo.processInfo.environment + guard let pair = env["FTW_E2E_PAIR"], let link = env["FTW_E2E_LINK"], let code = env["FTW_E2E_CODE"], + let cloud = env["FTW_E2E_CLOUD"], let key = env["FTW_E2E_CLOUD_KEY"], let offers = env["FTW_E2E_OFFER_KEYS"] else { + throw XCTSkip("Set the TEST_RUNNER_FTW_E2E_* variables to run against the rig.") + } + let app = XCUIApplication() + app.launchArguments = [ + "-FTWPairLink", pair, "-FTWAccessLink", link, + "-FTWCloudURL", cloud, "-FTWCloudKey", key, + "-FTWOfferKeys", offers, "-FTWOfferURL", "https://127.0.0.1:18943", + "-AppleLanguages", "(en)", "-AppleLocale", "en_US", + ] + app.launch() + + // Pair with the box. Where the simulator offers a passkey, add it; + // the rig's script matches the Face ID that follows. Without one the + // phone keeps its key on the device, as a phone without one does. + let pairButton = app.buttons["pair.primary"] + XCTAssertTrue(pairButton.waitForExistence(timeout: 20)) + pairButton.tap() + if app.staticTexts["Securing this phone"].waitForExistence(timeout: 5) { + let add = XCUIApplication(bundleIdentifier: "com.apple.springboard").buttons["Add Passkey"] + if add.waitForExistence(timeout: 5) { + add.tap() + } else { + // The system's sheet, outside the app: its button's place. + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.875)).tap() + } + } + // FTW Cloud's one screen at first start, over the consent link's + // page, which waits under it. + let carryOn = app.buttons.matching(NSPredicate(format: "label == 'Continue' AND identifier != 'offer.continue'")).firstMatch + let field = app.textFields["offer.code"] + for _ in 0..<60 where !field.isHittable { + if carryOn.exists, carryOn.isHittable { carryOn.tap() } + Thread.sleep(forTimeInterval: 1) + } + // The code from the page, then the offer. + XCTAssertTrue(field.waitForExistence(timeout: 5)) + field.tap() + shoot(app, "e2e-code") + field.typeText(code) + app.buttons["offer.continue"].tap() + XCTAssertTrue(app.staticTexts["offer.host"].waitForExistence(timeout: 15)) + shoot(app, "e2e-offer") + let control = app.switches["offer.control"] + if control.waitForExistence(timeout: 3) { + control.switches.firstMatch.exists ? control.switches.firstMatch.tap() : control.tap() + } + // The local consent page is no client FTW Cloud knows: the owner + // says they started it. + let trust = app.switches["offer.trust"] + XCTAssertTrue(trust.waitForExistence(timeout: 5)) + XCTAssertFalse(app.buttons["offer.approve"].isEnabled) + trust.switches.firstMatch.exists ? trust.switches.firstMatch.tap() : trust.tap() + app.buttons["offer.approve"].tap() + // Face ID: the rig's script matches it. + XCTAssertTrue(app.buttons["offer.done"].waitForExistence(timeout: 60)) + shoot(app, "e2e-done") + app.buttons["offer.done"].tap() + + // The assistant in the roster, as the box lists it. + app.buttons["now.title"].tap() + app.buttons["menu.settings"].tap() + let access = app.buttons["settings.access"] + let screen = app.windows.firstMatch.frame + for _ in 0..<8 where !(access.exists && access.isHittable && screen.contains(access.frame)) { + app.collectionViews.firstMatch.swipeUp(velocity: .slow) + } + access.tap() + let row = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH 'access.grant.'")).firstMatch + XCTAssertTrue(row.waitForExistence(timeout: 15)) + shoot(app, "e2e-roster") + // Let the assistant call its home a few times. + Thread.sleep(forTimeInterval: Double(env["FTW_E2E_HOLD_S"] ?? "") ?? 20) + + // Removed with one tap, no Face ID. + row.swipeLeft() + let remove = app.buttons["Remove access"] + XCTAssertTrue(remove.waitForExistence(timeout: 5)) + remove.tap() + XCTAssertTrue(row.waitForNonExistence(timeout: 15)) + shoot(app, "e2e-removed") + Thread.sleep(forTimeInterval: 8) + } + + private func shoot(_ app: XCUIApplication, _ name: String) { + let shot = XCTAttachment(screenshot: app.screenshot()) + shot.name = name + shot.lifetime = .keepAlways + add(shot) + } +} diff --git a/appleApp/FTWUITests/AccessTour.swift b/appleApp/FTWUITests/AccessTour.swift new file mode 100644 index 0000000..6106d36 --- /dev/null +++ b/appleApp/FTWUITests/AccessTour.swift @@ -0,0 +1,135 @@ +import XCTest + +/// Photographs Access in the demo: Settings, the list, a request, one +/// grant, the activity, Get help and an assistant's offer from code to +/// done. Skipped unless asked for: +/// +/// TEST_RUNNER_FTW_ACCESS_SHOTS=1 TEST_RUNNER_FTW_LANG=sv xcodebuild test … \ +/// -only-testing:FTWUITests/AccessTour -resultBundlePath access.xcresult +/// xcrun xcresulttool export attachments --path access.xcresult --output-path shots +/// +/// Light or dark is the simulator's own setting. +final class AccessTour: XCTestCase { + @MainActor + func testTour() throws { + let env = ProcessInfo.processInfo.environment + try XCTSkipUnless(env["FTW_ACCESS_SHOTS"] == "1", "Set TEST_RUNNER_FTW_ACCESS_SHOTS=1 to photograph Access.") + let lang = env["FTW_LANG"] ?? "en" + let app = XCUIApplication() + app.launchArguments = [ + "-FTWDemo", "YES", "-FTWSheet", "settings", + "-AppleLanguages", "(\(lang))", "-AppleLocale", lang == "sv" ? "sv_SE" : "en_US", + ] + app.launch() + + XCTAssertTrue(app.buttons["mode.close"].waitForExistence(timeout: 15)) + let access = reveal(app, app.buttons["settings.access"]) + XCTAssertTrue(access.waitForExistence(timeout: 5)) + Thread.sleep(forTimeInterval: 1) + shoot(app, "settings") + + access.tap() + XCTAssertTrue(app.buttons["access.add"].waitForExistence(timeout: 5)) + Thread.sleep(forTimeInterval: 1.5) + shoot(app, "access") + + // What waits for the owner. + let request = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH 'access.request.'")).firstMatch + XCTAssertTrue(request.waitForExistence(timeout: 5)) + request.tap() + XCTAssertTrue(app.buttons["request.approve"].waitForExistence(timeout: 5)) + Thread.sleep(forTimeInterval: 1) + shoot(app, "request") + back(app) + Thread.sleep(forTimeInterval: 1) + + // One grant. + app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH 'access.grant.'")).firstMatch.tap() + XCTAssertTrue(app.descendants(matching: .any)["grant.ends"].waitForExistence(timeout: 5)) + Thread.sleep(forTimeInterval: 1) + shoot(app, "grant") + back(app) + Thread.sleep(forTimeInterval: 1) + + // The activity. + reveal(app, app.buttons["access.activity"]).tap() + Thread.sleep(forTimeInterval: 1.5) + shoot(app, "activity") + back(app) + Thread.sleep(forTimeInterval: 1) + + // Get help. + reveal(app, app.buttons["access.help"]).tap() + let problem = app.textFields["help.problem"] + XCTAssertTrue(problem.waitForExistence(timeout: 5)) + problem.tap() + problem.typeText(lang == "sv" ? "Batteriet slutar ladda vid 80 % sedan i tisdags." : "The battery stops charging at 80 % since Tuesday.") + let written = app.buttons["help.written"] + if written.waitForExistence(timeout: 3) { written.tap() } + Thread.sleep(forTimeInterval: 1) + shoot(app, "help") + app.buttons["help.look"].tap() + // Sourceful's offer, as it will be signed. + XCTAssertTrue(app.buttons["offer.approve"].waitForExistence(timeout: 10)) + Thread.sleep(forTimeInterval: 1) + shoot(app, "help-review") + app.buttons["offer.approve"].tap() + XCTAssertTrue(app.buttons["offer.done"].waitForExistence(timeout: 10)) + Thread.sleep(forTimeInterval: 1) + shoot(app, "help-done") + app.buttons["offer.done"].tap() + Thread.sleep(forTimeInterval: 1) + back(app) + Thread.sleep(forTimeInterval: 1) + + // An assistant's offer: the code, the offer, control, done. + reveal(app, app.buttons["access.add"], down: true).tap() + let code = app.textFields["offer.code"] + XCTAssertTrue(code.waitForExistence(timeout: 5)) + Thread.sleep(forTimeInterval: 1) + shoot(app, "offer-code") + code.typeText("K7QF-2MXD") + app.buttons["offer.continue"].tap() + XCTAssertTrue(app.staticTexts["offer.host"].waitForExistence(timeout: 10)) + Thread.sleep(forTimeInterval: 1) + shoot(app, "offer") + let control = app.switches["offer.control"] + if control.waitForExistence(timeout: 3) { + control.switches.firstMatch.exists ? control.switches.firstMatch.tap() : control.tap() + Thread.sleep(forTimeInterval: 1) + shoot(app, "offer-control") + } + app.buttons["offer.approve"].tap() + XCTAssertTrue(app.buttons["offer.done"].waitForExistence(timeout: 10)) + Thread.sleep(forTimeInterval: 1) + shoot(app, "offer-done") + app.buttons["offer.done"].tap() + Thread.sleep(forTimeInterval: 1.5) + shoot(app, "access-after") + } + + /// A row a lazy list has not made yet: scroll until it is there. + @discardableResult + private func reveal(_ app: XCUIApplication, _ element: XCUIElement, down: Bool = false) -> XCUIElement { + let screen = app.windows.firstMatch.frame + let list = app.collectionViews.firstMatch + for _ in 0..<8 where !(element.exists && element.isHittable && screen.contains(element.frame)) { + let surface = list.exists ? list : app + if down { surface.swipeDown(velocity: .slow) } else { surface.swipeUp(velocity: .slow) } + } + return element + } + + /// iOS 26 draws the back button as glass, outside a navigation bar. + private func back(_ app: XCUIApplication) { + let button = app.buttons["BackButton"] + if button.exists { button.tap() } else { app.navigationBars.buttons.element(boundBy: 0).tap() } + } + + private func shoot(_ app: XCUIApplication, _ name: String) { + let shot = XCTAttachment(screenshot: app.screenshot()) + shot.name = name + shot.lifetime = .keepAlways + add(shot) + } +}