From a42a202dc893cd95c8f10f268d7658372f066d7f Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 16 Oct 2026 08:09:53 +0200 Subject: [PATCH 1/8] FTWKit: the approval signature, signed offers and the access model Deterministic CBOR and the approval bytes as go/protocol/agents.go builds them, held to the box's approval-vectors.json byte for byte; the signed offer checked against pinned offer keys; agent.* on the session; the simulated box answers them and checks every signature. Co-Authored-By: Claude Opus 5.5 --- .../Sources/FTWDemo/SimulatedAccess.swift | 290 +++++++++ .../FTWKit/Sources/FTWDemo/SimulatedBox.swift | 8 +- .../Sources/FTWKit/Access/AccessModel.swift | 396 +++++++++++++ .../Sources/FTWKit/Access/AccessWire.swift | 328 +++++++++++ .../Sources/FTWKit/Access/AccessWords.swift | 550 ++++++++++++++++++ .../Sources/FTWKit/Access/Approval.swift | 173 ++++++ .../FTWKit/Sources/FTWKit/Access/Offer.swift | 357 ++++++++++++ .../Sources/FTWKit/Access/OfferFlow.swift | 200 +++++++ .../Sources/FTWKit/Protocol/Contract.swift | 140 +++++ .../Sources/FTWKit/Protocol/Session.swift | 34 ++ .../Sources/FTWKit/State/AppModel.swift | 43 +- .../Sources/FTWKit/State/SiteModel.swift | 5 + .../Tests/FTWKitTests/AccessFlowTests.swift | 245 ++++++++ .../Tests/FTWKitTests/ApprovalTests.swift | 159 +++++ .../Tests/FTWKitTests/ContractTests.swift | 63 ++ .../FTWKit/Tests/FTWKitTests/OfferTests.swift | 238 ++++++++ .../Resources/approval-vectors.json | 54 ++ 17 files changed, 3280 insertions(+), 3 deletions(-) create mode 100644 appleApp/FTWKit/Sources/FTWDemo/SimulatedAccess.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Access/AccessModel.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Access/AccessWire.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Access/Approval.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Access/Offer.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Access/OfferFlow.swift create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/AccessFlowTests.swift create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/ApprovalTests.swift create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/OfferTests.swift create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/Resources/approval-vectors.json diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedAccess.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedAccess.swift new file mode 100644 index 0000000..d496cd0 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedAccess.swift @@ -0,0 +1,290 @@ +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 + + func receive(_ env: Envelope, reply: @escaping (Envelope) -> Void) { + 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))) ?? []) + } + + // 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 home = "" + /// How many times the demo would have asked for Face ID. + public private(set) var unlocks = 0 + + public init() {} + + public func unlock(home: String, reason: String) async throws -> ApprovalUnlock { + self.home = home + unlocks += 1 + return self + } + + public func existing() throws -> ApprovalSigner? { kept[home] } + public func make() throws -> ApprovalSigner { SoftwareSigner() } + public func keep(_ signer: ApprovalSigner) { + if let s = signer as? SoftwareSigner { kept[home] = s } + } +} + +/// 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, control: Bool) 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..bf4f9eb --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Access/AccessModel.swift @@ -0,0 +1,396 @@ +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 +} + +@MainActor +public protocol ApprovalUnlock: AnyObject { + /// This home's key, or nil when the phone has none. Throws when the key + /// is there but can no longer sign: a new Face ID enrolment voids it. + func existing() throws -> ApprovalSigner? + /// A new key, not yet this home's. + func make() throws -> ApprovalSigner + /// Make `signer` this home's key, once the box has it. + func keep(_ signer: ApprovalSigner) +} + +/// 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() {} +} + +/// 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 + + public init(scopes: [String], ops: [String], caps: Caps, lengthMs: Int64) { + self.scopes = Approval.sorted(scopes) + self.ops = Approval.sorted(ops) + self.caps = caps + self.lengthMs = lengthMs + } + + /// 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] = [] + /// 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") ?? [] + site.keepFile("access") { [weak self] in self?.list?.agents } + site.keepFile("tickets") { [weak self] in self?.tickets } + } + + /// 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()) } + + // MARK: Reading + + public func refresh() async { + guard available else { return } + do { + list = AgentList(try await site.ask(AccessWire.msgList, .emptyMap)) + 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 + } + let principal = Principal(client: String(offer.client.prefix(256)), clientId: String(offer.clientId.prefix(256)), ticket: offer.ticket) + let reason = AccessWords.format("Let %@ reach your home", AccessWords.clientName(offer)) + 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) + return ok["agent"].flatMap(AgentRow.init) + } + } + + /// 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 = AccessWords.format("Change what %@ may do", AccessWords.name(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: 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 + } + } + let reason = 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. + @discardableResult + public func replaceKey() async -> Bool { + guard let keyring, let home = site.siteId else { return false } + working = "key" + defer { working = nil } + do { + let unlock = try await keyring.unlock(home: home, reason: AccessWords.text("Replace the approval key on this phone")) + guard let old = try unlock.existing() else { throw ApprovalKeyLost() } + let fresh = try unlock.make() + 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) + } + unlock.keep(fresh) + 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. + private func signed(_ reason: String, target: String, _ work: @escaping @MainActor (ApprovalSigner) async throws -> T?) async -> T? { + guard let keyring, let home = site.siteId else { + help = AccessWords.text("This phone can't approve on its own. Use your box's page.") + return nil + } + working = target + defer { working = nil } + do { + if list == nil { await refresh() } + let unlock = try await keyring.unlock(home: home, reason: reason) + let signer = try await registered(unlock) + let out = try await retryingSkew { try await work(signer) } + 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. + private func registered(_ unlock: ApprovalUnlock) async throws -> ApprovalSigner { + let have = try unlock.existing() + if boxHasKey { + guard let have else { throw ApprovalKeyLost() } + return have + } + let signer = try have ?? unlock.make() + 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) + } + unlock.keep(signer) + if var l = list { + l.keys.append(ApprovalKeyInfo(deviceId: signedFor.deviceID, setAtMs: nowMs)) + list = l + } + return signer + } + + /// 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 %@", id) + } + + 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..0a85a6f --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift @@ -0,0 +1,550 @@ +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 { + 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. + public static func clientName(_ offer: GrantOffer) -> String { + if offer.isSupport { return text("Sourceful support") } + return offer.client.isEmpty ? offer.clientHost : offer.client + } + + public static func name(_ row: AgentRow) -> String { + if row.isSupport { return text("Sourceful support") } + if !row.principal.client.isEmpty { return row.principal.client } + return format("Assistant %@", row.id) + } + + /// Where the client lives: the host of its metadata document, or of the + /// page it sends you back to. + public static func host(_ row: AgentRow) -> String { + guard let url = URL(string: row.principal.clientId), let host = url.host else { return row.principal.clientId } + 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(text("Lasting changes, such as the mode or a goal, wait for your Face ID.")) + } + 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", + ] + + /// 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((e.key.string ?? "?", raw(e.value))) + } + return out + } + + static func value(_ v: CBOR, _ def: Contract.AgentArg, modes: [ModeInfo]) -> String { + 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 AppWords.clock(minute: Int(d)) + case "days": return EVText.days(Int(d)) + default: return AppWords.decimal(d.rounded() == d ? String(Int(d)) : 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 s + case .bool(let b): return b ? "true" : "false" + case .unsigned, .negative, .double: return v.double.map { $0.rounded() == $0 ? String(Int64($0)) : 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 { + let m = Int((seconds / 60).rounded()) + 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)) + } + + /// Who approved it: this phone, another owner phone or the box's page. + public static func approvedBy(_ row: AgentRow, me: String) -> String { + if row.approvedBy == "lan" { return text("Approved on the box's page") } + let id = row.approvedBy.hasPrefix("app:") ? String(row.approvedBy.dropFirst(4)) : row.approvedBy + return id == me ? text("Approved on this phone") : format("Approved 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 %@", 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, id) = split(row.by) + 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 ApprovalKeyLost: + return text("The approval key on this phone stopped working when Face ID changed. Replace it on your box's page with the house password.") + 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 .supportFromStranger: return text("This offer claims to be Sourceful support but comes from someone else. Nothing was granted.") + 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 %@", + "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 Face ID.": "Varaktiga ändringar, som läget eller ett mål, väntar på ditt Face ID.", + "Off: it reads and changes nothing.": "Av: den läser och ändrar ingenting.", + "For": "I", + "1 day": "1 dag", + "%@ days": "%@ dagar", + "Until %@": "Till %@", + "Ends %@": "Slutar %@", + "Ended": "Slutat", + "Never used": "Aldrig använd", + "Last used %@": "Senast använd %@", + "Approved on the box's page": "Godkänd på boxens sida", + "Approved on this phone": "Godkänd på den här telefonen", + "Approved on phone %@": "Godkänd 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. Typing it here proves the request is the one in front of you.": + "Sidan som bad om åtkomst visar en kod. När du skriver den här vet du att det är den begäran du har framför dig.", + "Continue": "Fortsätt", + "Cancel": "Avbryt", + "Approve with Face ID": "Godkänn med Face ID", + "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 Face ID": "Spara med Face ID", + "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 Face ID.": "Godkännanden på den här telefonen använder Face ID.", + "This phone makes its approval key the first time you approve, under the same Face ID.": + "Telefonen gör sin godkännandenyckel första gången du godkänner, med samma Face ID.", + "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 Face ID changed. Replace it on your box's page with the house password.": + "Godkännandenyckeln på telefonen slutade fungera när Face ID ä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", + "Sourceful may": "Sourceful får", + "Also let Sourceful control": "Låt Sourceful också styra", + "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.", + "Sourceful can now look at your home for ticket %@.": "Sourceful kan nu titta på ditt hem för ärende %@.", + "Let Sourceful look at your home": "Låt Sourceful titta på ditt hem", + // Refusals + "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 be Sourceful support but comes from someone else. Nothing was granted.": + "Erbjudandet säger sig vara Sourceful support men kommer från någon annan. Inget har getts.", + "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..6b96afc --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Access/Approval.swift @@ -0,0 +1,173 @@ +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 } + + 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..a6b2b79 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Access/Offer.swift @@ -0,0 +1,357 @@ +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 offer from a client that is not Sourceful's. + case supportFromStranger + /// 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. + 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.supportFromStranger } + return offer + } +} + +/// 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: +/// `https://app.ftw.energy/a#.`. `/a/` with or +/// without the offer in the fragment is read too; without one, the offer +/// is fetched by the request. +public struct OfferLink: Equatable, Sendable { + public var request: String + /// Base64url of the signed offer, when the link carries it. + public var offer: String? + + 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 } + let fragment = url.percentEncodedFragment ?? "" + if url.path == Self.path || url.path == Self.path + "/" { + let parts = fragment.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]) + } else if url.path.hasPrefix(Self.path + "/") { + let rid = String(url.path.dropFirst(Self.path.count + 1)) + guard Self.isToken(rid) else { return nil } + self.init(request: rid, offer: fragment.isEmpty ? nil : fragment) + if let offer, !Self.isToken(offer) { return nil } + } else { + return nil + } + } + + /// 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 the signed offer comes from when the link does not carry it: FTW +/// Cloud's code lookup (`GET /oauth/requests?code=`, once per code) and its +/// request (`GET /oauth/requests/`). 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!) + } + + public func byRequest(_ request: String) async throws -> SignedOffer { + try await get(base.appendingPathComponent("oauth/requests").appendingPathComponent(request)) + } + + 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 + } + 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..d316a81 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Access/OfferFlow.swift @@ -0,0 +1,200 @@ +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. +@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 + 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? + + 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: fetch its offer if it does not carry one, and check it + /// before asking for the code. + public func start(home: String?) async { + guard let link else { return } + guard !keys.isEmpty else { return refuse(.noPinnedKey) } + step = .checking + do { + let signed: SignedOffer + if let carried = link.offer { + signed = try SignedOffer(base64url: carried) + } else { + signed = try await lookup.byRequest(link.request) + } + opened = try check(signed, home: home) + step = .code + } 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 offer = try check(try await lookup.byCode(code), home: home) + guard OfferCode.matches(code, offer) else { return refuse(.wrongCode) } + 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(_ signed: SignedOffer, home: String?) { + do { + review(try check(signed, home: home)) + } catch let e as OfferError { + refuse(e) + } catch { + refuse(.malformed) + } + } + + /// Grant what the owner chose, behind Face ID, at this home. + public func approve(_ access: AccessModel) async { + guard case .review(let offer) = step else { return } + step = .approving(offer) + if let row = await access.approve(offer, grant: grant(offer)) { + step = .done(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 + 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 } + func open(problem: String, control: Bool) 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, control: Bool) async throws -> SignedOffer { throw OfferError.unreachable } +} + +/// What the owner wrote for each ticket, kept on this phone only: the box +/// keeps the ticket id, never the words. +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 + } +} 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..954f6d9 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)? @ObservationIgnored public let vault: Vault @ObservationIgnored public let sites: SiteList @@ -145,7 +158,11 @@ 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) + } + home = models sites.setCurrent(siteId) site.start() recovering = false @@ -288,6 +305,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 +326,21 @@ 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 } + + /// 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 +356,9 @@ 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) } + home = models isDemo = true recovering = false offeredLink = nil 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/Tests/FTWKitTests/AccessFlowTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/AccessFlowTests.swift new file mode 100644 index 0000000..65fc4b3 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/AccessFlowTests.swift @@ -0,0 +1,245 @@ +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 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: >)) + } + + /// 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, .supportFromStranger, .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" + } +} From bbc76c852579c68575fca835b81673336bebabb6 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 16 Oct 2026 08:59:45 +0200 Subject: [PATCH 2/8] iPhone: Access in Settings, the offer sheet, requests, activity and Get help Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW.xcodeproj/project.pbxproj | 2 + appleApp/FTW/Access/AccessGate.swift | 64 +++++ appleApp/FTW/Access/AccessView.swift | 151 ++++++++++++ appleApp/FTW/Access/ApprovalKeychain.swift | 140 +++++++++++ appleApp/FTW/Access/AuditView.swift | 61 +++++ appleApp/FTW/Access/GetHelpView.swift | 122 ++++++++++ appleApp/FTW/Access/GrantView.swift | 146 +++++++++++ appleApp/FTW/Access/OfferSheet.swift | 228 ++++++++++++++++++ appleApp/FTW/Access/RequestView.swift | 81 +++++++ appleApp/FTW/FTWApp.swift | 12 +- appleApp/FTW/UI/ModeSheet.swift | 3 + appleApp/FTW/UI/RootView.swift | 1 + .../Sources/FTWDemo/SimulatedAccess.swift | 10 + .../Sources/FTWKit/Access/AccessModel.swift | 14 +- .../Sources/FTWKit/Access/AccessWords.swift | 52 ++-- .../Sources/FTWKit/Access/Approval.swift | 3 + .../FTWKit/Sources/FTWKit/Access/Offer.swift | 4 +- .../Sources/FTWKit/Identity/CloudGrant.swift | 3 + .../Sources/FTWKit/State/AppModel.swift | 14 +- .../Tests/FTWKitTests/AccessFlowTests.swift | 26 ++ .../Tests/FTWKitTests/LiveOfferTests.swift | 34 +++ appleApp/FTWUITests/AccessE2E.swift | 92 +++++++ appleApp/FTWUITests/AccessTour.swift | 126 ++++++++++ 23 files changed, 1365 insertions(+), 24 deletions(-) create mode 100644 appleApp/FTW/Access/AccessGate.swift create mode 100644 appleApp/FTW/Access/AccessView.swift create mode 100644 appleApp/FTW/Access/ApprovalKeychain.swift create mode 100644 appleApp/FTW/Access/AuditView.swift create mode 100644 appleApp/FTW/Access/GetHelpView.swift create mode 100644 appleApp/FTW/Access/GrantView.swift create mode 100644 appleApp/FTW/Access/OfferSheet.swift create mode 100644 appleApp/FTW/Access/RequestView.swift create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/LiveOfferTests.swift create mode 100644 appleApp/FTWUITests/AccessE2E.swift create mode 100644 appleApp/FTWUITests/AccessTour.swift 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/AccessGate.swift b/appleApp/FTW/Access/AccessGate.swift new file mode 100644 index 0000000..1689db7 --- /dev/null +++ b/appleApp/FTW/Access/AccessGate.swift @@ -0,0 +1,64 @@ +import FTWKit +import SwiftUI + +extension View { + /// A link from an assistant's consent page opens its sheet over the + /// home, once a home is open to grant from. RootView's one line. + func accessLinks(_ app: AppModel) -> some View { + modifier(AccessGate(app: app)) + } +} + +private struct AccessGate: ViewModifier { + let app: AppModel + /// FTW Cloud's one screen at first start goes first: one sheet at a + /// time, and a link waits for it. + @AppStorage(CloudConsent.answeredKey) private var consented: String? + + private var cloudScreenUp: Bool { + guard let site = app.site, !site.isDemo, site.siteId != nil else { return false } + return consented != CloudConsent.policy + } + + func body(content: Content) -> some View { + content.sheet(item: Binding( + get: { app.needsPairing || app.home?.access == nil || cloudScreenUp ? nil : app.accessLink }, + set: { if $0 == nil { app.accessLink = nil } } + )) { link in + OfferSheet(app: app, flow: OfferFlow(link: link, lookup: app.offerLookup, keys: app.offerKeys)) + .tint(Theme.accent) + } + } +} + +/// 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..723ce71 --- /dev/null +++ b/appleApp/FTW/Access/AccessView.swift @@ -0,0 +1,151 @@ +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) } + } + Text(row.isSupport ? AccessWords.format("Ticket %@", row.principal.ticket) : AccessWords.host(row)) + .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) { + 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..2c9cca5 --- /dev/null +++ b/appleApp/FTW/Access/ApprovalKeychain.swift @@ -0,0 +1,140 @@ +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 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? + guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else { + throw FaceIDMissing() + } + do { + try await context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) + } catch let e as LAError where [.userCancel, .appCancel, .systemCancel, .userFallback].contains(e.code) { + throw ApprovalCancelled() + } catch let e as LAError where [.biometryNotEnrolled, .biometryNotAvailable, .passcodeNotSet].contains(e.code) { + throw FaceIDMissing() + } + return Unlock(store: store, account: Self.account(home), context: context) + } + + func forget(home: String) { + store.remove(Self.account(home)) + } + + static func account(_ home: String) -> String { "approval-\(home)" } +} + +/// 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 account: String + let context: LAContext + + init(store: SecureStore, account: String, context: LAContext) { + self.store = store + self.account = account + self.context = context + } + + func existing() 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 { + // A new Face ID enrolment voids a biometryCurrentSet key. + throw ApprovalKeyLost() + } + #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 error!.takeRetainedValue() as Error + } + return EnclaveSigner(try SecureEnclave.P256.Signing.PrivateKey(accessControl: access, authenticationContext: context)) + #endif + } + + func keep(_ signer: ApprovalSigner) { + #if targetEnvironment(simulator) + if let s = signer as? SoftwareSigner { store.put(account, s.rawKey) } + #else + if let s = signer as? EnclaveSigner { store.put(account, 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 ApprovalKeyLost() + } + } +} diff --git a/appleApp/FTW/Access/AuditView.swift b/appleApp/FTW/Access/AuditView.swift new file mode 100644 index 0000000..a0fcac4 --- /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.badge.shield.checkmark") + .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..ca6348c --- /dev/null +++ b/appleApp/FTW/Access/GetHelpView.swift @@ -0,0 +1,122 @@ +import FTWKit +import SwiftUI + +/// Get help: the owner says what is wrong and lets Sourceful look at the +/// home for one ticket. Reading and checks that change nothing; control +/// only when ticked. One Face ID grants it, tied to the ticket, and 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 control = false + @State private var asking = false + @State private var ticket: String? + @State private var help: String? + @FocusState private var writing: Bool + + private static let weekMs: Int64 = 7 * 86_400_000 + private static let reads = ["ftw.live.read", "ftw.history.read", "ftw.plan.read", "ftw.health.read", "ftw.events.read", "ftw.logs.read", "ftw.config.read", "ftw.diag.run"] + + var body: some View { + Group { + if let ticket { + Outcome(icon: "checkmark.seal.fill", tint: Theme.ink, title: AccessWords.format("Sourceful can now look at your home for ticket %@.", ticket), + 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.")) { + self.ticket = nil + problem = "" + } + } else { + form + } + } + .navigationTitle(AccessWords.text("Get help")) + .navigationBarTitleDisplayMode(.inline) + .background(Theme.surface) + .sensoryFeedback(.success, trigger: ticket) + } + + private var form: some View { + let desk = app.desk + return 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 { + ForEach(Self.reads, id: \.self) { Label(AccessWords.scope($0), systemImage: "eye") } + Toggle(AccessWords.text("Also let Sourceful control"), isOn: $control) + .tint(Theme.live) + .accessibilityIdentifier("help.control") + LabeledContent(AccessWords.text("For"), value: AccessWords.length(Self.weekMs)) + } header: { + Text(AccessWords.text("Sourceful may")) + } footer: { + VStack(alignment: .leading, spacing: 6) { + if control { + Text(AccessWords.control(Caps(holdS: AccessWire.holdSRange.upperBound, maxW: 11_000), scopes: Array(AccessWire.controlScopes))) + } + 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 = help ?? access.help { Text(help).foregroundStyle(Theme.alarm) } + } + } + } + .scrollContentBackground(.hidden) + .scrollDismissesKeyboard(.immediately) + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button(AccessWords.text("Done")) { writing = false } + .accessibilityIdentifier("help.written") + } + } + .safeAreaInset(edge: .bottom) { + Button { Task { await look(desk) } } label: { + HStack(spacing: 8) { + if asking { ProgressView().tint(Theme.onAccent) } else { Image(systemName: Biometry.symbol) } + 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") + } + } + + /// The desk opens the ticket and hands back Sourceful's signed offer, + /// which is checked like any other; the owner's choice of control and + /// the week go into the grant, behind one Face ID. + private func look(_ 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, control: control) + let flow = OfferFlow(link: nil, lookup: app.offerLookup, keys: app.offerKeys) + flow.present(signed, home: app.cloudHome) + guard case .review(let offer) = flow.step, offer.isSupport else { + if case .refused(let e) = flow.step { help = AccessWords.offer(e) } + return + } + flow.control = control + flow.lengthMs = Self.weekMs + await flow.approve(access) + if case .done(let row) = flow.step { + access.note(row.principal.ticket, problem: text) + ticket = row.principal.ticket + } + } 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..b588b04 --- /dev/null +++ b/appleApp/FTW/Access/GrantView.swift @@ -0,0 +1,146 @@ +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)) + Text(row.isSupport ? AccessWords.format("Ticket %@", row.principal.ticket) : AccessWords.host(row)) + .foregroundStyle(Theme.ink2) + .textSelection(.enabled) + 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 { + Section { + Toggle(AccessWords.text("Control"), isOn: $control).tint(Theme.live) + } footer: { + let g = grant(row) + Text(control ? AccessWords.control(g.caps, scopes: g.scopes) : AccessWords.text("Off: it reads and changes nothing.")) + } + } + + 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 turned on asks for the ops the box + /// opens to agents, within FTW Cloud's proposed caps; a narrower one + /// keeps the row's own. + private func grant(_ row: AgentRow) -> Grant { + var all = scopes + var ops = row.ops + var caps = row.caps + if control { + all.formUnion(AccessWire.controlScopes) + if !row.hasControl { + ops = Contract.agentOps.keys.filter { AccessWire.controlScopes.contains(Contract.agentOps[$0]!) } + caps = Caps(holdS: AccessWire.holdSRange.upperBound, maxW: 11_000, perMin: AccessWire.perMinDefault) + } + } else { + ops = [] + caps = Caps(perMin: row.caps.perMin) + } + // Unrenewed, the end stays where it is. + let remaining = row.expiresAtMs - Int64(Date().timeIntervalSince1970 * 1000) + Approval.skewMs + return Grant(scopes: Array(all), ops: ops, caps: caps, lengthMs: lengthMs == 0 ? remaining : lengthMs) + } + + private func changed(_ row: AgentRow) -> Bool { + scopes != Set(row.scopes).subtracting(AccessWire.controlScopes) || control != row.hasControl || lengthMs != 0 + } +} diff --git a/appleApp/FTW/Access/OfferSheet.swift b/appleApp/FTW/Access/OfferSheet.swift new file mode 100644 index 0000000..58fe0ec --- /dev/null +++ b/appleApp/FTW/Access/OfferSheet.swift @@ -0,0 +1,228 @@ +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. +struct OfferSheet: View { + let app: AppModel + let flow: OfferFlow + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + OfferFlowView(app: app, flow: flow) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(role: .close) { dismiss() } + .accessibilityIdentifier("offer.close") + } + } + } + } +} + +/// The steps of one offer, in a sheet for a link or pushed from Access +/// for a typed code. +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. Typing it here proves the request is the one in front of you.")) + .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.isEmpty ? offer.clientId : 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) + Text(AccessWords.format("Calls itself %@", "“\(AccessWords.clientName(offer))”")) + .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.")) + } + } + + 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.")) + } + } + + 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) + } footer: { + if let help = access.help { Text(help).foregroundStyle(Theme.alarm) } + } + } + .safeAreaInset(edge: .bottom) { + Button { Task { await flow.approve(access) } } 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 || { 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, which +/// opens it. +struct HomePick: View { + let app: AppModel + + var body: some View { + if app.homes.count > 1, let current = app.site?.siteId { + Picker(AccessWords.text("Home"), selection: Binding(get: { current }, set: { app.open($0) })) { + 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..032d2f4 --- /dev/null +++ b/appleApp/FTW/Access/RequestView.swift @@ -0,0 +1,81 @@ +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) + } + } + + if !request.why.isEmpty { + Section { + Text("“\(request.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) { + Text(AccessWords.text("Approving runs it on your box now, as if you had made the change yourself.")) + 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) { + 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/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..7b9b805 100644 --- a/appleApp/FTW/UI/RootView.swift +++ b/appleApp/FTW/UI/RootView.swift @@ -20,6 +20,7 @@ struct RootView: View { .tint(Theme.accent) .onAppear { LaunchClock.mark("first frame") } .cloudConsent(app) + .accessLinks(app) #if DEBUG .overlay(alignment: .topLeading) { if FrameGapProbe.enabled { FrameGapProbe() } } #endif diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedAccess.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedAccess.swift index d496cd0..78c1887 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedAccess.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedAccess.swift @@ -220,6 +220,15 @@ public final class SimulatedAccess { 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 offer = await MainActor.run { offer(nowMs: Date().timeIntervalSince1970 * 1000).base64url } + return (try JSONSerialization.data(withJSONObject: ["offer": offer, "status": "pending"]), 200) + } + // MARK: Encoding, as the box encodes static func cbor(_ r: AgentRow) -> CBOR { @@ -266,6 +275,7 @@ public final class SimulatedKeyring: ApprovalKeyring, ApprovalUnlock { return self } + public func forget(home: String) { kept[home] = nil } public func existing() throws -> ApprovalSigner? { kept[home] } public func make() throws -> ApprovalSigner { SoftwareSigner() } public func keep(_ signer: ApprovalSigner) { diff --git a/appleApp/FTWKit/Sources/FTWKit/Access/AccessModel.swift b/appleApp/FTWKit/Sources/FTWKit/Access/AccessModel.swift index bf4f9eb..753a100 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Access/AccessModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Access/AccessModel.swift @@ -13,6 +13,8 @@ public protocol ApprovalKeyring: AnyObject { /// 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 @@ -160,6 +162,10 @@ public final class AccessModel { 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 { @@ -289,7 +295,7 @@ public final class AccessModel { /// box takes it. One Face ID. @discardableResult public func replaceKey() async -> Bool { - guard let keyring, let home = site.siteId else { return false } + guard let keyring, let home = keyHome else { return false } working = "key" defer { working = nil } do { @@ -320,10 +326,14 @@ public final class AccessModel { /// phone, then `work` with the key; again once with the box's time if /// the box says this phone's clock is off. private func signed(_ reason: String, target: String, _ work: @escaping @MainActor (ApprovalSigner) async throws -> T?) async -> T? { - guard let keyring, let home = site.siteId else { + 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 } do { diff --git a/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift b/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift index 0a85a6f..99be7e3 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift @@ -4,6 +4,10 @@ import Foundation /// 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 } @@ -78,7 +82,7 @@ public enum AccessWords { 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(text("Lasting changes, such as the mode or a goal, wait for your Face ID.")) + parts.append(format("Lasting changes, such as the mode or a goal, wait for your %@.", biometry)) } return parts.joined(separator: " ") } @@ -202,11 +206,23 @@ public enum AccessWords { ms <= 0 ? text("Never used") : format("Last used %@", AppWords.day(Double(ms), nowMs: nowMs, zone: zone)) } - /// Who approved it: this phone, another owner phone or the box's page. - public static func approvedBy(_ row: AgentRow, me: String) -> String { - if row.approvedBy == "lan" { return text("Approved on the box's page") } + /// 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("Approved on this phone") : format("Approved on phone %@", id) + return id == me ? text("On this phone") : format("On phone %@", id) } // MARK: The audit log @@ -288,7 +304,7 @@ public enum AccessWords { case is ApprovalCancelled: return nil case is ApprovalKeyLost: - return text("The approval key on this phone stopped working when Face ID changed. Replace it on your box's page with the house password.") + 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: @@ -385,7 +401,7 @@ public enum AccessWords { "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 Face ID.": "Varaktiga ändringar, som läget eller ett mål, väntar på ditt Face ID.", + "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": "I", "1 day": "1 dag", @@ -395,9 +411,10 @@ public enum AccessWords { "Ended": "Slutat", "Never used": "Aldrig använd", "Last used %@": "Senast använd %@", - "Approved on the box's page": "Godkänd på boxens sida", - "Approved on this phone": "Godkänd på den här telefonen", - "Approved on phone %@": "Godkänd på telefon %@", + "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 @@ -406,7 +423,7 @@ public enum AccessWords { "Sidan som bad om åtkomst visar en kod. När du skriver den här vet du att det är den begäran du har framför dig.", "Continue": "Fortsätt", "Cancel": "Avbryt", - "Approve with Face ID": "Godkänn med Face ID", + "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.": @@ -414,7 +431,7 @@ public enum AccessWords { "Done": "Klar", "Checking the offer": "Kontrollerar erbjudandet", // One grant - "Save with Face ID": "Spara med Face ID", + "Save with %@": "Spara med %@", "Remove access": "Ta bort åtkomst", "Change what %@ may do": "Ändra vad %@ får göra", "Approved": "Godkänd", @@ -497,13 +514,13 @@ public enum AccessWords { "Read the settings": "Läste inställningarna", "Ran a check": "Körde en kontroll", // The key - "Approvals on this phone use Face ID.": "Godkännanden på den här telefonen använder Face ID.", - "This phone makes its approval key the first time you approve, under the same Face ID.": - "Telefonen gör sin godkännandenyckel första gången du godkänner, med samma Face ID.", + "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 Face ID changed. Replace it on your box's page with the house password.": - "Godkännandenyckeln på telefonen slutade fungera när Face ID ändrades. Byt den på boxens sida med husets lösenord.", + "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", @@ -517,6 +534,7 @@ public enum AccessWords { "Sourceful can now look at your home for ticket %@.": "Sourceful kan nu titta på ditt hem för ärende %@.", "Let Sourceful look at your home": "Låt Sourceful titta på ditt hem", // 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.", diff --git a/appleApp/FTWKit/Sources/FTWKit/Access/Approval.swift b/appleApp/FTWKit/Sources/FTWKit/Access/Approval.swift index 6b96afc..9b92288 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Access/Approval.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Access/Approval.swift @@ -137,6 +137,9 @@ public final class SoftwareSigner: ApprovalSigner { 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 } diff --git a/appleApp/FTWKit/Sources/FTWKit/Access/Offer.swift b/appleApp/FTWKit/Sources/FTWKit/Access/Offer.swift index a6b2b79..c409637 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Access/Offer.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Access/Offer.swift @@ -233,11 +233,13 @@ public struct OfferKeys: Equatable, Sendable { /// `https://app.ftw.energy/a#.`. `/a/` with or /// without the offer in the fragment is read too; without one, the offer /// is fetched by the request. -public struct OfferLink: Equatable, Sendable { +public struct OfferLink: Equatable, Sendable, Identifiable { public var request: String /// Base64url of the signed offer, when the link carries it. public var offer: String? + public var id: String { request } + public init(request: String, offer: String?) { self.request = request self.offer = offer diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/CloudGrant.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/CloudGrant.swift index 8db09e6..a2f9948 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Identity/CloudGrant.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/CloudGrant.swift @@ -166,6 +166,9 @@ public final class CloudConsent { return false } + /// Where the answer is kept, for a screen that must wait for it. + public static let answeredKey = Keys.answered + enum Keys { static let answered = "FTWCloudConsent" static let stats = "FTWCloudStats" diff --git a/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift index 954f6d9..bb839d6 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift @@ -84,7 +84,7 @@ public final class AppModel { /// 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)? + @ObservationIgnored public var demoAccess: (keys: ApprovalKeyring, offers: OfferKeys, desk: SupportDesk, lookup: OfferLookup)? @ObservationIgnored public let vault: Vault @ObservationIgnored public let sites: SiteList @@ -160,7 +160,8 @@ public final class AppModel { } 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) + 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) @@ -335,6 +336,10 @@ public final class AppModel { 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? { @@ -357,7 +362,9 @@ public final class AppModel { if shares { site.sharedWritten = sharedWritten } site.ceilingW = 11_000 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) } + 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 @@ -387,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/Tests/FTWKitTests/AccessFlowTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/AccessFlowTests.swift index 65fc4b3..d013d2a 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/AccessFlowTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/AccessFlowTests.swift @@ -224,6 +224,32 @@ import FTWDemo #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) + } + /// Every word an owner reads has its Swedish. @Test func everyWordHasItsSwedish() { for scope in Contract.agentRoleScopes[Contract.roleAgent]! { diff --git a/appleApp/FTWKit/Tests/FTWKitTests/LiveOfferTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/LiveOfferTests.swift new file mode 100644 index 0000000..669c1d8 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/LiveOfferTests.swift @@ -0,0 +1,34 @@ +import Foundation +import Testing +@testable import FTWKit + +/// A real offer from a local ftwcloudd, read the way the app reads it. Off +/// unless FTW_LIVE_OFFER_LINK names the consent page's link: +/// +/// FTW_LIVE_OFFER_LINK='https://app.ftw.energy/a#…' FTW_LIVE_OFFER_CODE=ABCD-EFGH \ +/// FTW_LIVE_OFFER_KEYS=':' swift test --filter LiveOfferTests +/// +/// The keys are what ftwcloudd serves at /.well-known/ftw-offer-key. +@MainActor +@Suite struct LiveOfferTests { + @Test func aRealOfferOpens() async throws { + let env = ProcessInfo.processInfo.environment + guard let text = env["FTW_LIVE_OFFER_LINK"], let pins = env["FTW_LIVE_OFFER_KEYS"] else { return } + let link = try #require(OfferLink(text)) + let keys = OfferKeys(pins: [pins]) + #expect(!keys.isEmpty) + let flow = OfferFlow(link: link, lookup: OfferLookup(base: URL(string: "https://127.0.0.1:18943")!) { _ in (Data(), 500) }, keys: keys) + await flow.start(home: nil) + #expect(flow.step == .code) + flow.typed = env["FTW_LIVE_OFFER_CODE"] ?? "" + await flow.submit(home: nil) + guard case .review(let offer) = flow.step else { + Issue.record("no review: \(flow.step)") + return + } + #expect(offer.role == Contract.roleAgent) + #expect(offer.key.count == 32) + #expect(offer.home.isEmpty) + print("live offer:", offer.client, offer.clientHost, offer.verified, offer.scopes, offer.ops, offer.caps, offer.ttlS) + } +} diff --git a/appleApp/FTWUITests/AccessE2E.swift b/appleApp/FTWUITests/AccessE2E.swift new file mode 100644 index 0000000..5a09d16 --- /dev/null +++ b/appleApp/FTWUITests/AccessE2E.swift @@ -0,0 +1,92 @@ +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. The simulator has no passkey, so 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() + // FTW Cloud's one screen at first start. + let carryOn = app.buttons["Continue"] + if carryOn.waitForExistence(timeout: 20) { carryOn.tap() } + + // The consent link's sheet opens over the home once the box has + // answered: the code from the page, then the offer. + let field = app.textFields["offer.code"] + XCTAssertTrue(field.waitForExistence(timeout: 60)) + 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() + } + 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"] + for _ in 0..<6 where !access.isHittable { app.swipeUp() } + 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..2e73b32 --- /dev/null +++ b/appleApp/FTWUITests/AccessTour.swift @@ -0,0 +1,126 @@ +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() + + let access = app.buttons["settings.access"] + XCTAssertTrue(app.buttons["mode.close"].waitForExistence(timeout: 15)) + for _ in 0..<6 where !access.isHittable { app.swipeUp() } + 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() + XCTAssertTrue(app.buttons["offer.done"].waitForExistence(timeout: 10)) + Thread.sleep(forTimeInterval: 1) + shoot(app, "help-done") + 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 { + for _ in 0..<8 where !(element.exists && element.isHittable) { + if down { app.swipeDown() } else { app.swipeUp() } + } + 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) + } +} From 549804777f2528b0db481402397c67a83670cd94 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 16 Oct 2026 09:03:27 +0200 Subject: [PATCH 3/8] iPhone: name a grant's client by the host its offer gave A registered client's id is no address, so the row cannot say where the client lives. The phone keeps the offer's host and whether FTW Cloud knew it, beside the row's id. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/Access/AccessView.swift | 9 ++++--- appleApp/FTW/Access/GrantView.swift | 15 ++++++++--- .../Sources/FTWKit/Access/AccessModel.swift | 25 ++++++++++++++++++- .../Sources/FTWKit/Access/AccessWords.swift | 7 +++--- .../Sources/FTWKit/Access/OfferFlow.swift | 14 +++++++++++ .../Tests/FTWKitTests/AccessFlowTests.swift | 7 ++++++ 6 files changed, 67 insertions(+), 10 deletions(-) diff --git a/appleApp/FTW/Access/AccessView.swift b/appleApp/FTW/Access/AccessView.swift index 723ce71..04c95b1 100644 --- a/appleApp/FTW/Access/AccessView.swift +++ b/appleApp/FTW/Access/AccessView.swift @@ -97,9 +97,12 @@ struct GrantLine: View { Spacer(minLength: 8) if access.working == row.id { ProgressView().controlSize(.small) } } - Text(row.isSupport ? AccessWords.format("Ticket %@", row.principal.ticket) : AccessWords.host(row)) - .font(.subheadline) - .foregroundStyle(Theme.ink2) + let place = row.isSupport ? AccessWords.format("Ticket %@", row.principal.ticket) : access.host(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) diff --git a/appleApp/FTW/Access/GrantView.swift b/appleApp/FTW/Access/GrantView.swift index b588b04..3d85db8 100644 --- a/appleApp/FTW/Access/GrantView.swift +++ b/appleApp/FTW/Access/GrantView.swift @@ -40,9 +40,18 @@ struct GrantView: View { Section { VStack(alignment: .leading, spacing: 6) { Text(AccessWords.name(row)).font(.title2.weight(.bold)) - Text(row.isSupport ? AccessWords.format("Ticket %@", row.principal.ticket) : AccessWords.host(row)) - .foregroundStyle(Theme.ink2) - .textSelection(.enabled) + let place = row.isSupport ? AccessWords.format("Ticket %@", row.principal.ticket) : access.host(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) } diff --git a/appleApp/FTWKit/Sources/FTWKit/Access/AccessModel.swift b/appleApp/FTWKit/Sources/FTWKit/Access/AccessModel.swift index 753a100..a4ae589 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Access/AccessModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Access/AccessModel.swift @@ -105,6 +105,10 @@ public final class AccessModel { 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 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. @@ -131,8 +135,23 @@ public final class AccessModel { // 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) + } + + /// 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. @@ -210,7 +229,11 @@ public final class AccessModel { 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) - return ok["agent"].flatMap(AgentRow.init) + 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)) + self.site.persistNow() + return row } } diff --git a/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift b/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift index 99be7e3..88b614f 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift @@ -35,10 +35,11 @@ public enum AccessWords { return format("Assistant %@", row.id) } - /// Where the client lives: the host of its metadata document, or of the - /// page it sends you back to. + /// 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), let host = url.host else { return row.principal.clientId } + guard let url = URL(string: row.principal.clientId), url.scheme == "https", let host = url.host else { return "" } return host } diff --git a/appleApp/FTWKit/Sources/FTWKit/Access/OfferFlow.swift b/appleApp/FTWKit/Sources/FTWKit/Access/OfferFlow.swift index d316a81..2d6aac0 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Access/OfferFlow.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Access/OfferFlow.swift @@ -198,3 +198,17 @@ public struct TicketNote: Codable, Equatable, Sendable { self.atMs = atMs } } + +/// Where a client lives, as the offer the owner approved on this phone +/// said, by its row's id. +public struct ClientNote: Codable, Equatable, Sendable { + public var id: String + public var host: String + public var verified: Bool + + public init(id: String, host: String, verified: Bool) { + self.id = id + self.host = host + self.verified = verified + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/AccessFlowTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/AccessFlowTests.swift index d013d2a..8881ad9 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/AccessFlowTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/AccessFlowTests.swift @@ -82,6 +82,13 @@ import FTWDemo #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) // The next grant needs no new key: one more Face ID, nothing else. let second = try w.offer() From a0dd718c0c2a0c4f5f0caf2be3291e4c00d227d2 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 16 Oct 2026 09:13:51 +0200 Subject: [PATCH 4/8] iPhone: a consent link opens its offer as a page over the home A sheet waited on FTW Cloud's first screen and could miss its turn; a page waits under any sheet. The e2e test against a local FTW Cloud and a Core box adds the passkey the simulator offers. Co-Authored-By: Claude Opus 5.5 --- ...{AccessGate.swift => AccessSettings.swift} | 30 ------------------ .../{OfferSheet.swift => OfferFlowView.swift} | 23 ++------------ appleApp/FTW/UI/RootView.swift | 14 ++++++++- .../FTWKit/Sources/FTWKit/Access/Offer.swift | 2 +- .../Sources/FTWKit/Identity/CloudGrant.swift | 3 -- appleApp/FTWUITests/AccessE2E.swift | 31 +++++++++++++------ 6 files changed, 39 insertions(+), 64 deletions(-) rename appleApp/FTW/Access/{AccessGate.swift => AccessSettings.swift} (54%) rename appleApp/FTW/Access/{OfferSheet.swift => OfferFlowView.swift} (93%) diff --git a/appleApp/FTW/Access/AccessGate.swift b/appleApp/FTW/Access/AccessSettings.swift similarity index 54% rename from appleApp/FTW/Access/AccessGate.swift rename to appleApp/FTW/Access/AccessSettings.swift index 1689db7..0d9db5f 100644 --- a/appleApp/FTW/Access/AccessGate.swift +++ b/appleApp/FTW/Access/AccessSettings.swift @@ -1,36 +1,6 @@ import FTWKit import SwiftUI -extension View { - /// A link from an assistant's consent page opens its sheet over the - /// home, once a home is open to grant from. RootView's one line. - func accessLinks(_ app: AppModel) -> some View { - modifier(AccessGate(app: app)) - } -} - -private struct AccessGate: ViewModifier { - let app: AppModel - /// FTW Cloud's one screen at first start goes first: one sheet at a - /// time, and a link waits for it. - @AppStorage(CloudConsent.answeredKey) private var consented: String? - - private var cloudScreenUp: Bool { - guard let site = app.site, !site.isDemo, site.siteId != nil else { return false } - return consented != CloudConsent.policy - } - - func body(content: Content) -> some View { - content.sheet(item: Binding( - get: { app.needsPairing || app.home?.access == nil || cloudScreenUp ? nil : app.accessLink }, - set: { if $0 == nil { app.accessLink = nil } } - )) { link in - OfferSheet(app: app, flow: OfferFlow(link: link, lookup: app.offerLookup, keys: app.offerKeys)) - .tint(Theme.accent) - } - } -} - /// 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. diff --git a/appleApp/FTW/Access/OfferSheet.swift b/appleApp/FTW/Access/OfferFlowView.swift similarity index 93% rename from appleApp/FTW/Access/OfferSheet.swift rename to appleApp/FTW/Access/OfferFlowView.swift index 58fe0ec..1a19264 100644 --- a/appleApp/FTW/Access/OfferSheet.swift +++ b/appleApp/FTW/Access/OfferFlowView.swift @@ -5,26 +5,9 @@ import SwiftUI /// 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. -struct OfferSheet: View { - let app: AppModel - let flow: OfferFlow - @Environment(\.dismiss) private var dismiss - - var body: some View { - NavigationStack { - OfferFlowView(app: app, flow: flow) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(role: .close) { dismiss() } - .accessibilityIdentifier("offer.close") - } - } - } - } -} - -/// The steps of one offer, in a sheet for a link or pushed from Access -/// for a typed code. +/// 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 diff --git a/appleApp/FTW/UI/RootView.swift b/appleApp/FTW/UI/RootView.swift index 7b9b805..31fc316 100644 --- a/appleApp/FTW/UI/RootView.swift +++ b/appleApp/FTW/UI/RootView.swift @@ -20,7 +20,6 @@ struct RootView: View { .tint(Theme.accent) .onAppear { LaunchClock.mark("first frame") } .cloudConsent(app) - .accessLinks(app) #if DEBUG .overlay(alignment: .topLeading) { if FrameGapProbe.enabled { FrameGapProbe() } } #endif @@ -39,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 { @@ -48,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") } } @@ -117,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) @@ -130,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/FTWKit/Access/Offer.swift b/appleApp/FTWKit/Sources/FTWKit/Access/Offer.swift index c409637..275c4ef 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Access/Offer.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Access/Offer.swift @@ -233,7 +233,7 @@ public struct OfferKeys: Equatable, Sendable { /// `https://app.ftw.energy/a#.`. `/a/` with or /// without the offer in the fragment is read too; without one, the offer /// is fetched by the request. -public struct OfferLink: Equatable, Sendable, Identifiable { +public struct OfferLink: Hashable, Sendable, Identifiable { public var request: String /// Base64url of the signed offer, when the link carries it. public var offer: String? diff --git a/appleApp/FTWKit/Sources/FTWKit/Identity/CloudGrant.swift b/appleApp/FTWKit/Sources/FTWKit/Identity/CloudGrant.swift index a2f9948..8db09e6 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Identity/CloudGrant.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Identity/CloudGrant.swift @@ -166,9 +166,6 @@ public final class CloudConsent { return false } - /// Where the answer is kept, for a screen that must wait for it. - public static let answeredKey = Keys.answered - enum Keys { static let answered = "FTWCloudConsent" static let stats = "FTWCloudStats" diff --git a/appleApp/FTWUITests/AccessE2E.swift b/appleApp/FTWUITests/AccessE2E.swift index 5a09d16..99006cb 100644 --- a/appleApp/FTWUITests/AccessE2E.swift +++ b/appleApp/FTWUITests/AccessE2E.swift @@ -33,19 +33,32 @@ final class AccessE2E: XCTestCase { ] app.launch() - // Pair with the box. The simulator has no passkey, so the phone - // keeps its key on the device, as a phone without one does. + // 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() - // FTW Cloud's one screen at first start. - let carryOn = app.buttons["Continue"] - if carryOn.waitForExistence(timeout: 20) { carryOn.tap() } - - // The consent link's sheet opens over the home once the box has - // answered: the code from the page, then the offer. + 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"] - XCTAssertTrue(field.waitForExistence(timeout: 60)) + 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() From 0c5decee0dce15c29d90cbf16fbd193cc3f044bd Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 16 Oct 2026 09:22:26 +0200 Subject: [PATCH 5/8] =?UTF-8?q?Access:=20the=20demo=20lookup=20builds=20on?= =?UTF-8?q?=20the=20runner's=20compiler;=20'Hur=20l=C3=A4nge'=20for=20the?= =?UTF-8?q?=20length?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- appleApp/FTWKit/Sources/FTWDemo/SimulatedAccess.swift | 6 ++++-- .../FTWKit/Sources/FTWKit/Access/AccessWords.swift | 2 +- appleApp/FTWUITests/AccessE2E.swift | 5 ++++- appleApp/FTWUITests/AccessTour.swift | 10 ++++++---- 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedAccess.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedAccess.swift index 78c1887..952ee6f 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedAccess.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedAccess.swift @@ -225,8 +225,10 @@ public final class SimulatedAccess { 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 offer = await MainActor.run { offer(nowMs: Date().timeIntervalSince1970 * 1000).base64url } - return (try JSONSerialization.data(withJSONObject: ["offer": offer, "status": "pending"]), 200) + 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 diff --git a/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift b/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift index 88b614f..5d48658 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift @@ -404,7 +404,7 @@ public enum AccessWords { "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": "I", + "For": "Hur länge", "1 day": "1 dag", "%@ days": "%@ dagar", "Until %@": "Till %@", diff --git a/appleApp/FTWUITests/AccessE2E.swift b/appleApp/FTWUITests/AccessE2E.swift index 99006cb..bb31477 100644 --- a/appleApp/FTWUITests/AccessE2E.swift +++ b/appleApp/FTWUITests/AccessE2E.swift @@ -78,7 +78,10 @@ final class AccessE2E: XCTestCase { app.buttons["now.title"].tap() app.buttons["menu.settings"].tap() let access = app.buttons["settings.access"] - for _ in 0..<6 where !access.isHittable { app.swipeUp() } + 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)) diff --git a/appleApp/FTWUITests/AccessTour.swift b/appleApp/FTWUITests/AccessTour.swift index 2e73b32..a73f383 100644 --- a/appleApp/FTWUITests/AccessTour.swift +++ b/appleApp/FTWUITests/AccessTour.swift @@ -22,9 +22,8 @@ final class AccessTour: XCTestCase { ] app.launch() - let access = app.buttons["settings.access"] XCTAssertTrue(app.buttons["mode.close"].waitForExistence(timeout: 15)) - for _ in 0..<6 where !access.isHittable { app.swipeUp() } + let access = reveal(app, app.buttons["settings.access"]) XCTAssertTrue(access.waitForExistence(timeout: 5)) Thread.sleep(forTimeInterval: 1) shoot(app, "settings") @@ -105,8 +104,11 @@ final class AccessTour: XCTestCase { /// 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 { - for _ in 0..<8 where !(element.exists && element.isHittable) { - if down { app.swipeDown() } else { app.swipeUp() } + 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 } From 046e04dbf64be939079094cb22ac5a197580bbc3 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 16 Oct 2026 10:25:48 +0200 Subject: [PATCH 6/8] Access review: keys from the box's list, names that pass for no one, plain hosts, a second check at approval - keysKnown: only the box's answer to agent.list decides that this phone has no key there; a cached list never registers one, and with no answer nothing is signed. - Names: a client's own name is quoted, cleaned of control and bidi characters, and set aside when it names Sourceful or FTW; the host goes beside it and into the Face ID line. - SignedOffer.open refuses a client host that is not plain ASCII, and any release offer while no release client is pinned. - OfferFlow keeps the signed offer and checks it again at approval, against the home open then and the clock then; an unknown client waits for the owner's word. - Only the fragment link (/a#rid.offer) is read; the lookup refuses an offer FTW Cloud no longer holds open. - A request the app cannot show in full may only be declined; numbers from outside never crash a formatter. - A first key is kept before the box hears of it; a replacement waits beside the current key until the box is known to hold it. - Principal fields are cut by UTF-8 bytes; a widen that does not renew keeps the end exactly; every Face ID's context ends with its approval. Co-Authored-By: Claude Opus 5.5 --- .../Sources/FTWDemo/SimulatedAccess.swift | 39 +++- .../Sources/FTWKit/Access/AccessModel.swift | 187 +++++++++++++++--- .../Sources/FTWKit/Access/AccessWords.swift | 128 ++++++++++-- .../FTWKit/Sources/FTWKit/Access/Offer.swift | 70 ++++--- .../Sources/FTWKit/Access/OfferFlow.swift | 86 +++++--- .../Tests/FTWKitTests/AccessFlowTests.swift | 137 ++++++++++++- .../FTWKit/Tests/FTWKitTests/OfferTests.swift | 92 ++++++++- 7 files changed, 624 insertions(+), 115 deletions(-) diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedAccess.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedAccess.swift index 952ee6f..f3901fe 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedAccess.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedAccess.swift @@ -60,7 +60,16 @@ public final class SimulatedAccess { // MARK: Messages - func receive(_ env: Envelope, reply: @escaping (Envelope) -> Void) { + /// 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 @@ -265,9 +274,14 @@ public final class SimulatedAccess { @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() {} @@ -277,12 +291,27 @@ public final class SimulatedKeyring: ApprovalKeyring, ApprovalUnlock { return self } - public func forget(home: String) { kept[home] = nil } + 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) { - if let s = signer as? SoftwareSigner { kept[home] = s } + 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 @@ -295,7 +324,7 @@ public final class SimulatedDesk: SupportDesk { public var isOpen: Bool { true } - public func open(problem: String, control: Bool) async throws -> SignedOffer { + 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/FTWKit/Access/AccessModel.swift b/appleApp/FTWKit/Sources/FTWKit/Access/AccessModel.swift index a4ae589..7b776eb 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Access/AccessModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Access/AccessModel.swift @@ -19,13 +19,26 @@ public protocol ApprovalKeyring: AnyObject { @MainActor public protocol ApprovalUnlock: AnyObject { - /// This home's key, or nil when the phone has none. Throws when the key - /// is there but can no longer sign: a new Face ID enrolment voids it. + /// 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 new key, not yet this home's. + /// 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 - /// Make `signer` this home's key, once the box has it. - func keep(_ signer: 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. @@ -38,6 +51,21 @@ 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 { @@ -46,11 +74,15 @@ public struct Grant: Equatable, Sendable { public var caps: Caps public var lengthMs: Int64 - public init(scopes: [String], ops: [String], caps: Caps, 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. @@ -109,6 +141,10 @@ public final class AccessModel { /// 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. @@ -148,6 +184,17 @@ public final class AccessModel { clients.first { $0.id == row.id }?.host ?? AccessWords.host(row) } + /// 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? { @@ -191,6 +238,7 @@ public final class AccessModel { guard available else { return } do { list = AgentList(try await site.ask(AccessWire.msgList, .emptyMap)) + keysKnown = true help = nil } catch { help = words(error) @@ -222,8 +270,12 @@ public final class AccessModel { help = AccessWords.text("Choose at least one thing it may do.") return nil } - let principal = Principal(client: String(offer.client.prefix(256)), clientId: String(offer.clientId.prefix(256)), ticket: offer.ticket) - let reason = AccessWords.format("Let %@ reach your home", AccessWords.clientName(offer)) + 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) @@ -231,7 +283,8 @@ public final class AccessModel { 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)) + self.clients.append(ClientNote(id: row.id, host: offer.clientHost, verified: offer.verified, + askedOps: offer.ops, askedCaps: offer.caps)) self.site.persistNow() return row } @@ -244,10 +297,10 @@ public final class AccessModel { help = AccessWords.text("Choose at least one thing it may do.") return false } - let reason = AccessWords.format("Change what %@ may do", AccessWords.name(row)) + 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: self.expiry(grant.lengthMs), atMs: self.nowMs) + 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 @@ -300,7 +353,12 @@ public final class AccessModel { return false } } - let reason = AccessWords.format("Let %@ make this change", agentName(request.agent)) + 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)) @@ -315,25 +373,41 @@ public final class AccessModel { // 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. + /// 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() - 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) + do { + try unlock.keepPending(fresh) + } catch { + throw ApprovalKeyNotKept() } - unlock.keep(fresh) + 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 @@ -347,7 +421,8 @@ public final class AccessModel { /// 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 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.") @@ -359,11 +434,25 @@ public final class AccessModel { } working = target defer { working = nil } + if !keysKnown { await refresh() } + guard keysKnown else { + help = help ?? AccessWords.help(SessionError.noCarrier) + return nil + } do { - if list == nil { await refresh() } let unlock = try await keyring.unlock(home: home, reason: reason) - let signer = try await registered(unlock) - let out = try await retryingSkew { try await work(signer) } + 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 @@ -376,13 +465,28 @@ public final class AccessModel { /// 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. - private func registered(_ unlock: ApprovalUnlock) async throws -> ApprovalSigner { + /// 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 { - guard let have else { throw ApprovalKeyLost() } - return have + 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() + } } - let signer = try have ?? unlock.make() try await retryingSkew { let atMs = self.nowMs let msg = Approval.keySetBytes(self.signedFor, atMs: atMs, pub: signer.publicKey) @@ -390,12 +494,11 @@ public final class AccessModel { ("pub", .bytes(signer.publicKey)), ("atMs", .int(atMs)), ("sig", .bytes(try signer.sign(msg))), ]).deterministic) } - unlock.keep(signer) if var l = list { l.keys.append(ApprovalKeyInfo(deviceId: signedFor.deviceID, setAtMs: nowMs)) list = l } - return signer + return (signer, nil, false) } /// Once more with the box's clock, when the box says this phone's is @@ -420,7 +523,27 @@ public final class AccessModel { /// 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 %@", id) + 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? { diff --git a/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift b/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift index 5d48658..f29a0ac 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift @@ -24,15 +24,50 @@ public enum AccessWords { // 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 offer.client.isEmpty ? offer.clientHost : offer.client + return ownName(offer.client) ?? format("Assistant at %@", offer.clientHost) } public static func name(_ row: AgentRow) -> String { if row.isSupport { return text("Sourceful support") } - if !row.principal.client.isEmpty { return row.principal.client } - return format("Assistant %@", row.id) + 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 @@ -103,6 +138,36 @@ public enum AccessWords { "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)] { @@ -114,12 +179,14 @@ public enum AccessWords { 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((e.key.string ?? "?", raw(e.value))) + 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) } @@ -146,9 +213,9 @@ public enum AccessWords { default: guard let d = v.double else { return raw(v) } switch def.name { - case "ready_by_min": return AppWords.clock(minute: Int(d)) - case "days": return EVText.days(Int(d)) - default: return AppWords.decimal(d.rounded() == d ? String(Int(d)) : String(d)) + 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)) } } } @@ -164,9 +231,9 @@ public enum AccessWords { static func raw(_ v: CBOR) -> String { switch v { - case .text(let s): return s + 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 { $0.rounded() == $0 ? String(Int64($0)) : String($0) } ?? "?" + 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 "…" } @@ -174,7 +241,7 @@ public enum AccessWords { /// "30 min", "1 h", "1 h 30 min". public static func minutes(_ seconds: Double) -> String { - let m = Int((seconds / 60).rounded()) + 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" @@ -232,7 +299,7 @@ public enum AccessWords { 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 %@", id) + 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") @@ -244,7 +311,8 @@ public enum AccessWords { /// 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, id) = split(row.by) + 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) } @@ -304,6 +372,12 @@ public enum AccessWords { 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: @@ -325,7 +399,9 @@ public enum AccessWords { 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 .supportFromStranger: return text("This offer claims to be Sourceful support but comes from someone else. 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.") @@ -367,6 +443,20 @@ public enum AccessWords { // 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", @@ -420,8 +510,8 @@ public enum AccessWords { "Ticket %@": "Ärende %@", // Offer "Type the code on the page": "Skriv koden från sidan", - "The page that asked for access shows a code. Typing it here proves the request is the one in front of you.": - "Sidan som bad om åtkomst visar en kod. När du skriver den här vet du att det är den begäran du har framför dig.", + "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 %@", @@ -540,8 +630,12 @@ public enum AccessWords { "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 be Sourceful support but comes from someone else. Nothing was granted.": - "Erbjudandet säger sig vara Sourceful support men kommer från någon annan. 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.", diff --git a/appleApp/FTWKit/Sources/FTWKit/Access/Offer.swift b/appleApp/FTWKit/Sources/FTWKit/Access/Offer.swift index 275c4ef..8acafd7 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Access/Offer.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Access/Offer.swift @@ -111,8 +111,13 @@ public enum OfferError: Error, Equatable, Sendable { case unsigned case expired case malformed - /// A support offer from a client that is not Sourceful's. - case supportFromStranger + /// 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. @@ -160,7 +165,9 @@ public struct SignedOffer: Equatable, Sendable { } /// The offer, if a pinned key signed it and it is still good. A support - /// offer counts only from Sourceful's own client. + /// 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 { @@ -173,9 +180,19 @@ public struct SignedOffer: Equatable, Sendable { 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.supportFromStranger } + 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 @@ -229,18 +246,18 @@ public struct OfferKeys: Equatable, Sendable { } } -/// A link from the consent page, as FTW Cloud makes it: -/// `https://app.ftw.energy/a#.`. `/a/` with or -/// without the offer in the fragment is read too; without one, the offer -/// is fetched by the request. +/// 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, when the link carries it. - public var offer: String? + /// Base64url of the signed offer. + public var offer: String public var id: String { request } - public init(request: String, offer: String?) { + public init(request: String, offer: String) { self.request = request self.offer = offer } @@ -250,19 +267,10 @@ public struct OfferLink: Hashable, Sendable, Identifiable { public init?(_ text: String) { guard let url = URLComponents(string: text), url.scheme == "https", url.host == Self.host else { return nil } - let fragment = url.percentEncodedFragment ?? "" - if url.path == Self.path || url.path == Self.path + "/" { - let parts = fragment.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]) - } else if url.path.hasPrefix(Self.path + "/") { - let rid = String(url.path.dropFirst(Self.path.count + 1)) - guard Self.isToken(rid) else { return nil } - self.init(request: rid, offer: fragment.isEmpty ? nil : fragment) - if let offer, !Self.isToken(offer) { return nil } - } 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. @@ -299,10 +307,10 @@ public enum OfferCode { } } -/// Where the signed offer comes from when the link does not carry it: FTW -/// Cloud's code lookup (`GET /oauth/requests?code=`, once per code) and its -/// request (`GET /oauth/requests/`). A debug build may point at a -/// local `ftwcloudd` with `-FTWOfferURL https://127.0.0.1:18443`. +/// 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) @@ -335,10 +343,6 @@ public struct OfferLookup: Sendable { return try await get(c.url!) } - public func byRequest(_ request: String) async throws -> SignedOffer { - try await get(base.appendingPathComponent("oauth/requests").appendingPathComponent(request)) - } - private func get(_ url: URL) async throws -> SignedOffer { var req = URLRequest(url: url, timeoutInterval: 15) req.setValue("application/json", forHTTPHeaderField: "Accept") @@ -354,6 +358,8 @@ public struct OfferLookup: Sendable { 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 index 2d6aac0..776fd8e 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Access/OfferFlow.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Access/OfferFlow.swift @@ -10,6 +10,8 @@ import Observation /// 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 { @@ -30,6 +32,9 @@ public final class OfferFlow { 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? @@ -41,6 +46,10 @@ public final class OfferFlow { /// 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 @@ -59,21 +68,14 @@ public final class OfferFlow { } } - /// For a link: fetch its offer if it does not carry one, and check it - /// before asking for the code. + /// For a link: check its offer before asking for the code. public func start(home: String?) async { - guard let link else { return } + guard let link, opened == nil, step == .code else { return } guard !keys.isEmpty else { return refuse(.noPinnedKey) } - step = .checking do { - let signed: SignedOffer - if let carried = link.offer { - signed = try SignedOffer(base64url: carried) - } else { - signed = try await lookup.byRequest(link.request) - } - opened = try check(signed, home: home) - step = .code + let carried = try SignedOffer(base64url: link.offer) + opened = try check(carried, home: home) + signed = carried } catch let e as OfferError { refuse(e) } catch { @@ -99,8 +101,10 @@ public final class OfferFlow { guard !keys.isEmpty else { return refuse(.noPinnedKey) } step = .checking do { - let offer = try check(try await lookup.byCode(code), home: home) + 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 @@ -113,9 +117,11 @@ public final class OfferFlow { } /// An offer that came another way, such as Get help: checked like any. - public func present(_ signed: SignedOffer, home: String?) { + public func present(_ given: SignedOffer, home: String?) { do { - review(try check(signed, home: home)) + let offer = try check(given, home: home) + signed = given + review(offer) } catch let e as OfferError { refuse(e) } catch { @@ -123,12 +129,25 @@ public final class OfferFlow { } } - /// Grant what the owner chose, behind Face ID, at this home. - public func approve(_ access: AccessModel) async { - guard case .review(let offer) = step else { return } + /// 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) } @@ -157,6 +176,7 @@ public final class OfferFlow { 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) } @@ -174,7 +194,9 @@ public final class OfferFlow { public protocol SupportDesk: AnyObject { /// Whether a ticket can be opened from the app. var isOpen: Bool { get } - func open(problem: String, control: Bool) async throws -> SignedOffer + /// 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. @@ -182,11 +204,11 @@ public protocol SupportDesk: AnyObject { public final class NoSupportDesk: SupportDesk { public init() {} public var isOpen: Bool { false } - public func open(problem: String, control: Bool) async throws -> SignedOffer { throw OfferError.unreachable } + public func open(problem: String) async throws -> SignedOffer { throw OfferError.unreachable } } -/// What the owner wrote for each ticket, kept on this phone only: the box -/// keeps the ticket id, never the words. +/// 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 @@ -199,16 +221,30 @@ public struct TicketNote: Codable, Equatable, Sendable { } } -/// Where a client lives, as the offer the owner approved on this phone -/// said, by its row's id. +/// 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) { + 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/Tests/FTWKitTests/AccessFlowTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/AccessFlowTests.swift index 8881ad9..81838f5 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/AccessFlowTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/AccessFlowTests.swift @@ -257,6 +257,141 @@ import FTWDemo #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]! { @@ -270,7 +405,7 @@ import FTWDemo } 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, .supportFromStranger, .unknownHome, .wrongCode, .notFound, .unreachable] + 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/OfferTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/OfferTests.swift index 044ad04..0908001 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/OfferTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/OfferTests.swift @@ -64,11 +64,51 @@ import Testing o.role = Contract.roleSupport o.ticket = "FTW-1" o.clientId = "https://evil.example/clients/sourceful-support" - #expect(throws: OfferError.supportFromStranger) { try sign(o, key).open(keys: keys, nowMs: 1_000) } + #expect(throws: OfferError.notFromSourceful) { try sign(o, key).open(keys: keys, nowMs: 1_000) } o.clientId = AccessWire.supportClientID #expect(try sign(o, key).open(keys: keys, nowMs: 1_000).ticket == "FTW-1") } + /// A host in look-alike letters is no host; punycode stays as it is. + @Test func aLookAlikeHostIsRefused() throws { + let key = SoftwareSigner() + let keys = OfferKeys(pins: ["\(OfferKeys.kid(key.publicKey)):\(Base64url.encode(key.publicKey))"]) + var o = offer() + o.clientHost = "claude.\u{0430}i" + #expect(throws: OfferError.strangeHost) { try sign(o, key).open(keys: keys, nowMs: 1_000) } + o.clientHost = "claude.ai\u{202E}" + #expect(throws: OfferError.strangeHost) { try sign(o, key).open(keys: keys, nowMs: 1_000) } + o.clientHost = "" + #expect(throws: OfferError.strangeHost) { try sign(o, key).open(keys: keys, nowMs: 1_000) } + o.clientHost = "xn--clude-0ve.ai" + #expect(try sign(o, key).open(keys: keys, nowMs: 1_000).clientHost == "xn--clude-0ve.ai") + o.clientHost = "127.0.0.1:18943" + #expect(throws: Never.self) { try sign(o, key).open(keys: keys, nowMs: 1_000) } + } + + /// No release client is pinned, so no release offer opens. + @Test func aReleaseOfferIsRefused() throws { + let key = SoftwareSigner() + let keys = OfferKeys(pins: ["\(OfferKeys.kid(key.publicKey)):\(Base64url.encode(key.publicKey))"]) + var o = offer() + o.role = Contract.roleRelease + o.scopes = ["ftw.health.read"] + #expect(throws: OfferError.notFromSourceful) { try sign(o, key).open(keys: keys, nowMs: 1_000) } + } + + /// An offer FTW Cloud no longer holds open is answered already. + @Test func anAnsweredOfferIsRefused() async throws { + let key = SoftwareSigner() + let keys = OfferKeys(pins: ["\(OfferKeys.kid(key.publicKey)):\(Base64url.encode(key.publicKey))"]) + let wire = try sign(offer(), key).base64url + let flow = OfferFlow(link: nil, lookup: lookup { _ in + (try JSONSerialization.data(withJSONObject: ["offer": wire, "status": "approved"]), 200) + }, keys: keys, wallMs: { 1_000 }) + flow.typed = "K7QF2MXD" + await flow.submit(home: nil) + #expect(flow.step == .refused(.used)) + } + /// Strict, as the box reads it. @Test func aFieldTheBoxDoesNotKnowIsNoOffer() throws { let key = SoftwareSigner() @@ -84,8 +124,10 @@ import Testing @Test func theLinksFTWCloudMakesAreRead() { let a = OfferLink("https://app.ftw.energy/a#rid_1.b2ZmZXI") #expect(a == OfferLink(request: "rid_1", offer: "b2ZmZXI")) - #expect(OfferLink("https://app.ftw.energy/a/rid_1#b2ZmZXI") == OfferLink(request: "rid_1", offer: "b2ZmZXI")) - #expect(OfferLink("https://app.ftw.energy/a/rid_1") == OfferLink(request: "rid_1", offer: nil)) + // Only the fragment carries a request: a path would reach a log. + #expect(OfferLink("https://app.ftw.energy/a/rid_1#b2ZmZXI") == nil) + #expect(OfferLink("https://app.ftw.energy/a/rid_1") == nil) + #expect(OfferLink("https://app.ftw.energy/a?x=1#rid_1.b2ZmZXI") == nil) #expect(OfferLink("https://app.ftw.energy/p#AAAA") == nil) #expect(OfferLink("https://evil.example/a#rid.offer") == nil) #expect(OfferLink("http://app.ftw.energy/a#rid.offer") == nil) @@ -157,6 +199,50 @@ import Testing #expect(gone.codeHelp == AccessWords.offer(.notFound)) } + /// Approving checks the signed offer again, against the home open then + /// and the clock then: a home picked after the review, or an offer + /// that ran out while the owner read, is refused. + @Test func approvingChecksAgain() async throws { + let key = SoftwareSigner() + let keys = OfferKeys(pins: ["\(OfferKeys.kid(key.publicKey)):\(Base64url.encode(key.publicKey))"]) + var o = offer() + o.home = "homeA" + var now = 1_000.0 + let flow = OfferFlow(link: OfferLink(request: "r1", offer: try sign(o, key).base64url), lookup: lookup { _ in (Data(), 500) }, keys: keys, wallMs: { now }) + await flow.start(home: "homeA") + flow.typed = "K7QF2MXD" + await flow.submit(home: "homeA") + guard case .review = flow.step else { Issue.record("no review: \(flow.step)"); return } + let site = SiteModel(siteId: "homeB", build: "t", ua: "t", scheduler: ManualScheduler(), files: nil) + let access = AccessModel(site: site, signedFor: SignedFor(boxKey: [], deviceID: "x"), keyring: nil) + await flow.approve(access, home: "homeB") + #expect(flow.step == .refused(.unknownHome)) + + let late = OfferFlow(link: OfferLink(request: "r1", offer: try sign(o, key).base64url), lookup: lookup { _ in (Data(), 500) }, keys: keys, wallMs: { now }) + await late.start(home: "homeA") + late.typed = "K7QF2MXD" + await late.submit(home: "homeA") + now += 10 * 60_000 + await late.approve(access, home: "homeA") + #expect(late.step == .refused(.expired)) + } + + /// An unknown client waits for the owner to say they started it. + @Test func anUnknownClientWaitsForTheOwnersWord() async throws { + let key = SoftwareSigner() + let keys = OfferKeys(pins: ["\(OfferKeys.kid(key.publicKey)):\(Base64url.encode(key.publicKey))"]) + var o = offer() + o.verified = false + let flow = OfferFlow(link: OfferLink(request: "r1", offer: try sign(o, key).base64url), lookup: lookup { _ in (Data(), 500) }, keys: keys, wallMs: { 1_000 }) + await flow.start(home: nil) + flow.typed = "K7QF2MXD" + await flow.submit(home: nil) + let site = SiteModel(siteId: "h", build: "t", ua: "t", scheduler: ManualScheduler(), files: nil) + await flow.approve(AccessModel(site: site, signedFor: SignedFor(boxKey: [], deviceID: "x"), keyring: nil), home: nil) + guard case .review = flow.step else { Issue.record("moved without the owner's word: \(flow.step)"); return } + #expect(!flow.trustsUnknown) + } + /// FTW Cloud leaves the home empty. One that names a home must name /// this one. @Test func anOfferForAnotherHomeIsRefused() async throws { From 2eba416d30379f54c2326186966d0a5bbef395fc Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 16 Oct 2026 10:32:27 +0200 Subject: [PATCH 7/8] Access review in the app: keys that cannot be stranded, honest names, Get help shows what it signs - ApprovalKeychain: a replacement waits in a second slot until the box is known to hold it; a cancel, a lockout and a lost key are told apart; the LAContext ends with its approval; KeychainStore.write reports a failed add. - The offer page: the host first, the client's own name quoted or set aside when it names Sourceful or FTW, an unknown client waits for the owner's word, control lists its ops, and the home picker starts the link again at the home picked, or is not shown for an offer that names one. - Get help sends the words to the desk and then shows Sourceful's offer as it will be signed. - A request the app cannot show in full offers only Decline; its reason is cleaned of control and bidi characters. - Control turned on again asks for what the client asked, never fixed maxima; an unrenewed change keeps the end exactly. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/Access/AccessView.swift | 7 +- appleApp/FTW/Access/ApprovalKeychain.swift | 102 ++++++++++++++---- appleApp/FTW/Access/AuditView.swift | 2 +- appleApp/FTW/Access/GetHelpView.swift | 89 ++++++--------- appleApp/FTW/Access/GrantView.swift | 46 +++++--- appleApp/FTW/Access/OfferFlowView.swift | 53 +++++++-- appleApp/FTW/Access/RequestView.swift | 26 +++-- appleApp/FTW/Platform/KeychainStore.swift | 10 +- .../Sources/FTWKit/Access/AccessModel.swift | 11 ++ .../Sources/FTWKit/Access/AccessWords.swift | 8 +- .../Sources/FTWKit/Store/SecureStore.swift | 6 ++ .../Tests/FTWKitTests/AccessFlowTests.swift | 1 + appleApp/FTWUITests/AccessE2E.swift | 7 ++ appleApp/FTWUITests/AccessTour.swift | 7 ++ 14 files changed, 257 insertions(+), 118 deletions(-) diff --git a/appleApp/FTW/Access/AccessView.swift b/appleApp/FTW/Access/AccessView.swift index 04c95b1..3f8e820 100644 --- a/appleApp/FTW/Access/AccessView.swift +++ b/appleApp/FTW/Access/AccessView.swift @@ -97,7 +97,7 @@ struct GrantLine: View { Spacer(minLength: 8) if access.working == row.id { ProgressView().controlSize(.small) } } - let place = row.isSupport ? AccessWords.format("Ticket %@", row.principal.ticket) : access.host(row) + let place = access.place(row) if !place.isEmpty { Text(place) .font(.subheadline) @@ -145,7 +145,10 @@ struct KeyFooter: View { var body: some View { VStack(alignment: .leading, spacing: 6) { - 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)) + // 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 index 2c9cca5..58f7176 100644 --- a/appleApp/FTW/Access/ApprovalKeychain.swift +++ b/appleApp/FTW/Access/ApprovalKeychain.swift @@ -11,7 +11,11 @@ import Security /// /// 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. +/// 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 @@ -28,24 +32,49 @@ final class ApprovalKeychain: ApprovalKeyring { let context = LAContext() context.localizedCancelTitle = AccessWords.text("Cancel") var error: NSError? - guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else { - throw FaceIDMissing() + 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 let e as LAError where [.userCancel, .appCancel, .systemCancel, .userFallback].contains(e.code) { - throw ApprovalCancelled() - } catch let e as LAError where [.biometryNotEnrolled, .biometryNotAvailable, .passcodeNotSet].contains(e.code) { - throw FaceIDMissing() + } catch { + throw Self.meaning(error) } - return Unlock(store: store, account: Self.account(home), context: context) + 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. @@ -73,16 +102,24 @@ enum Biometry { @MainActor private final class Unlock: ApprovalUnlock { let store: SecureStore - let account: String + let home: String let context: LAContext - init(store: SecureStore, account: String, context: LAContext) { + init(store: SecureStore, home: String, context: LAContext) { self.store = store - self.account = account + 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) @@ -90,8 +127,7 @@ private final class Unlock: ApprovalUnlock { do { return EnclaveSigner(try SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: Data(blob), authenticationContext: context)) } catch { - // A new Face ID enrolment voids a biometryCurrentSet key. - throw ApprovalKeyLost() + throw ApprovalKeychain.meaning(error) } #endif } @@ -104,17 +140,45 @@ private final class Unlock: ApprovalUnlock { guard let access = SecAccessControlCreateWithFlags( nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, [.privateKeyUsage, .biometryCurrentSet], &error ) else { - throw error!.takeRetainedValue() as Error + throw ApprovalSigningFailed() + } + do { + return EnclaveSigner(try SecureEnclave.P256.Signing.PrivateKey(accessControl: access, authenticationContext: context)) + } catch { + throw ApprovalKeychain.meaning(error) } - return EnclaveSigner(try SecureEnclave.P256.Signing.PrivateKey(accessControl: access, authenticationContext: context)) #endif } - func keep(_ signer: ApprovalSigner) { + 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) - if let s = signer as? SoftwareSigner { store.put(account, s.rawKey) } + guard let s = signer as? SoftwareSigner else { throw ApprovalKeyNotKept() } + return s.rawKey #else - if let s = signer as? EnclaveSigner { store.put(account, s.key.dataRepresentation.byteArray) } + guard let s = signer as? EnclaveSigner else { throw ApprovalKeyNotKept() } + return s.key.dataRepresentation.byteArray #endif } } @@ -134,7 +198,7 @@ private final class EnclaveSigner: ApprovalSigner { do { return try key.signature(for: Data(message)).rawRepresentation.byteArray } catch { - throw ApprovalKeyLost() + throw ApprovalKeychain.meaning(error) } } } diff --git a/appleApp/FTW/Access/AuditView.swift b/appleApp/FTW/Access/AuditView.swift index a0fcac4..80e5953 100644 --- a/appleApp/FTW/Access/AuditView.swift +++ b/appleApp/FTW/Access/AuditView.swift @@ -47,7 +47,7 @@ struct AuditLine: View { } Text(AccessWords.what(row)) if let by = AccessWords.by(row) { - Label(by, systemImage: "person.badge.shield.checkmark") + Label(by, systemImage: "person") .font(.footnote) .foregroundStyle(Theme.ink2) } diff --git a/appleApp/FTW/Access/GetHelpView.swift b/appleApp/FTW/Access/GetHelpView.swift index ca6348c..7c562e0 100644 --- a/appleApp/FTW/Access/GetHelpView.swift +++ b/appleApp/FTW/Access/GetHelpView.swift @@ -1,11 +1,12 @@ import FTWKit import SwiftUI -/// Get help: the owner says what is wrong and lets Sourceful look at the -/// home for one ticket. Reading and checks that change nothing; control -/// only when ticked. One Face ID grants it, tied to the ticket, and it ends -/// when the ticket closes, when its time runs out or when the owner -/// removes it. +/// 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. @@ -13,36 +14,15 @@ struct GetHelpView: View { let app: AppModel let access: AccessModel @State private var problem = "" - @State private var control = false + @State private var flow: OfferFlow? + @State private var reviewing = false @State private var asking = false - @State private var ticket: String? @State private var help: String? @FocusState private var writing: Bool - private static let weekMs: Int64 = 7 * 86_400_000 - private static let reads = ["ftw.live.read", "ftw.history.read", "ftw.plan.read", "ftw.health.read", "ftw.events.read", "ftw.logs.read", "ftw.config.read", "ftw.diag.run"] - var body: some View { - Group { - if let ticket { - Outcome(icon: "checkmark.seal.fill", tint: Theme.ink, title: AccessWords.format("Sourceful can now look at your home for ticket %@.", ticket), - 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.")) { - self.ticket = nil - problem = "" - } - } else { - form - } - } - .navigationTitle(AccessWords.text("Get help")) - .navigationBarTitleDisplayMode(.inline) - .background(Theme.surface) - .sensoryFeedback(.success, trigger: ticket) - } - - private var form: some View { let desk = app.desk - return List { + List { Section(AccessWords.text("Describe the problem")) { TextField(AccessWords.text("What happened, and when?"), text: $problem, axis: .vertical) .lineLimit(3...8) @@ -50,26 +30,21 @@ struct GetHelpView: View { .accessibilityIdentifier("help.problem") } Section { - ForEach(Self.reads, id: \.self) { Label(AccessWords.scope($0), systemImage: "eye") } - Toggle(AccessWords.text("Also let Sourceful control"), isOn: $control) - .tint(Theme.live) - .accessibilityIdentifier("help.control") - LabeledContent(AccessWords.text("For"), value: AccessWords.length(Self.weekMs)) - } header: { - Text(AccessWords.text("Sourceful may")) } footer: { VStack(alignment: .leading, spacing: 6) { - if control { - Text(AccessWords.control(Caps(holdS: AccessWire.holdSRange.upperBound, maxW: 11_000), scopes: Array(AccessWire.controlScopes))) - } + 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 = help ?? access.help { Text(help).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() @@ -78,9 +53,9 @@ struct GetHelpView: View { } } .safeAreaInset(edge: .bottom) { - Button { Task { await look(desk) } } label: { + Button { Task { await ask(desk) } } label: { HStack(spacing: 8) { - if asking { ProgressView().tint(Theme.onAccent) } else { Image(systemName: Biometry.symbol) } + if asking { ProgressView().tint(Theme.onAccent) } Text(AccessWords.text("Let Sourceful look")) } } @@ -90,31 +65,33 @@ struct GetHelpView: View { .background(Theme.surface) .accessibilityIdentifier("help.look") } + .navigationDestination(isPresented: $reviewing) { + if let flow { OfferFlowView(app: app, flow: flow) } + } } - /// The desk opens the ticket and hands back Sourceful's signed offer, - /// which is checked like any other; the owner's choice of control and - /// the week go into the grant, behind one Face ID. - private func look(_ desk: SupportDesk) async { + /// 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, control: control) - let flow = OfferFlow(link: nil, lookup: app.offerLookup, keys: app.offerKeys) - flow.present(signed, home: app.cloudHome) - guard case .review(let offer) = flow.step, offer.isSupport else { - if case .refused(let e) = flow.step { help = AccessWords.offer(e) } + 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 } - flow.control = control - flow.lengthMs = Self.weekMs - await flow.approve(access) - if case .done(let row) = flow.step { + next.onDone = { row in access.note(row.principal.ticket, problem: text) - ticket = row.principal.ticket + problem = "" } + flow = next + reviewing = true } catch { help = AccessWords.help(error) } diff --git a/appleApp/FTW/Access/GrantView.swift b/appleApp/FTW/Access/GrantView.swift index 3d85db8..f2c0e05 100644 --- a/appleApp/FTW/Access/GrantView.swift +++ b/appleApp/FTW/Access/GrantView.swift @@ -40,7 +40,7 @@ struct GrantView: View { Section { VStack(alignment: .leading, spacing: 6) { Text(AccessWords.name(row)).font(.title2.weight(.bold)) - let place = row.isSupport ? AccessWords.format("Ticket %@", row.principal.ticket) : access.host(row) + let place = access.place(row) if !place.isEmpty { Text(place) .foregroundStyle(Theme.ink2) @@ -74,11 +74,25 @@ struct GrantView: View { } 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) + Toggle(AccessWords.text("Control"), isOn: $control) + .tint(Theme.live) + .disabled(!canTurnOn) } footer: { let g = grant(row) - Text(control ? AccessWords.control(g.caps, scopes: g.scopes) : AccessWords.text("Off: it reads and changes nothing.")) + 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") + } + } } } @@ -127,26 +141,30 @@ struct GrantView: View { .animation(Theme.spring, value: changed(row)) } - /// The whole new grant. Control turned on asks for the ops the box - /// opens to agents, within FTW Cloud's proposed caps; a narrower one - /// keeps the row's own. + /// 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 { - all.formUnion(AccessWire.controlScopes) - if !row.hasControl { - ops = Contract.agentOps.keys.filter { AccessWire.controlScopes.contains(Contract.agentOps[$0]!) } - caps = Caps(holdS: AccessWire.holdSRange.upperBound, maxW: 11_000, perMin: AccessWire.perMinDefault) + 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 stays where it is. - let remaining = row.expiresAtMs - Int64(Date().timeIntervalSince1970 * 1000) + Approval.skewMs - return Grant(scopes: Array(all), ops: ops, caps: caps, lengthMs: lengthMs == 0 ? remaining : lengthMs) + // 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 { diff --git a/appleApp/FTW/Access/OfferFlowView.swift b/appleApp/FTW/Access/OfferFlowView.swift index 1a19264..8b36cc9 100644 --- a/appleApp/FTW/Access/OfferFlowView.swift +++ b/appleApp/FTW/Access/OfferFlowView.swift @@ -59,7 +59,7 @@ struct OfferFlowView: View { 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. Typing it here proves the request is the one in front of you.")) + 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) @@ -101,7 +101,7 @@ struct OfferReview: View { List { Section { VStack(alignment: .leading, spacing: 8) { - Text(offer.clientHost.isEmpty ? offer.clientId : offer.clientHost) + Text(offer.clientHost) .font(.title2.weight(.bold)) .foregroundStyle(Theme.ink) .textSelection(.enabled) @@ -110,8 +110,15 @@ struct OfferReview: View { systemImage: offer.verified ? "checkmark.seal.fill" : "exclamationmark.triangle.fill") .font(.subheadline.weight(.semibold)) .foregroundStyle(offer.verified ? Theme.ink : Theme.alarm) - Text(AccessWords.format("Calls itself %@", "“\(AccessWords.clientName(offer))”")) - .foregroundStyle(Theme.ink2) + 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) } @@ -138,6 +145,21 @@ struct OfferReview: View { 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") + } + } + } + } + + 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 { @@ -145,20 +167,21 @@ struct OfferReview: View { ForEach(flow.lengths, id: \.self) { Text(AccessWords.length($0)).tag($0) } } .accessibilityIdentifier("offer.length") - HomePick(app: app) + 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) } } label: { + 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 || { if case .approving = flow.step { return true }; return false }()) + .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") @@ -166,14 +189,22 @@ struct OfferReview: View { } } -/// The home the grant is for. With several, the owner picks one, which -/// opens it. +/// 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, let current = app.site?.siteId { - Picker(AccessWords.text("Home"), selection: Binding(get: { current }, set: { app.open($0) })) { + 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 { diff --git a/appleApp/FTW/Access/RequestView.swift b/appleApp/FTW/Access/RequestView.swift index 032d2f4..bd3b87e 100644 --- a/appleApp/FTW/Access/RequestView.swift +++ b/appleApp/FTW/Access/RequestView.swift @@ -26,9 +26,10 @@ struct RequestView: View { } } - if !request.why.isEmpty { + let why = AccessWords.clean(request.why).trimmingCharacters(in: .whitespaces) + if !why.isEmpty { Section { - Text("“\(request.why)”").italic() + Text("“\(why)”").italic() } header: { Text(AccessWords.text("In its own words")) } footer: { @@ -39,7 +40,12 @@ struct RequestView: View { Section { } footer: { VStack(alignment: .leading, spacing: 6) { - Text(AccessWords.text("Approving runs it on your box now, as if you had made the change yourself.")) + 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) } } @@ -51,14 +57,16 @@ struct RequestView: View { .navigationBarTitleDisplayMode(.inline) .safeAreaInset(edge: .bottom) { VStack(spacing: 8) { - 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)) + 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") } - .buttonStyle(FTWButtonStyle(kind: .primary)) - .accessibilityIdentifier("request.approve") Button(AccessWords.text("Decline")) { answer(false) } .buttonStyle(FTWButtonStyle(kind: .quiet)) .accessibilityIdentifier("request.decline") 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/FTWKit/Sources/FTWKit/Access/AccessModel.swift b/appleApp/FTWKit/Sources/FTWKit/Access/AccessModel.swift index 7b776eb..363a7c7 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Access/AccessModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Access/AccessModel.swift @@ -184,6 +184,17 @@ public final class AccessModel { 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 } diff --git a/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift b/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift index f29a0ac..7b5d779 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift @@ -617,13 +617,13 @@ public enum AccessWords { "Describe the problem": "Beskriv problemet", "What happened, and when?": "Vad hände, och när?", "Let Sourceful look": "Låt Sourceful titta", - "Sourceful may": "Sourceful får", - "Also let Sourceful control": "Låt Sourceful också styra", + "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.", - "Sourceful can now look at your home for ticket %@.": "Sourceful kan nu titta på ditt hem för ärende %@.", - "Let Sourceful look at your home": "Låt Sourceful titta på ditt hem", // 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.", 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 index 81838f5..b4ca595 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/AccessFlowTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/AccessFlowTests.swift @@ -89,6 +89,7 @@ import FTWDemo 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() diff --git a/appleApp/FTWUITests/AccessE2E.swift b/appleApp/FTWUITests/AccessE2E.swift index bb31477..76d92b1 100644 --- a/appleApp/FTWUITests/AccessE2E.swift +++ b/appleApp/FTWUITests/AccessE2E.swift @@ -68,6 +68,13 @@ final class AccessE2E: XCTestCase { 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"] + if trust.waitForExistence(timeout: 3) { + for _ in 0..<4 where !trust.isHittable { app.collectionViews.firstMatch.swipeUp(velocity: .slow) } + 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)) diff --git a/appleApp/FTWUITests/AccessTour.swift b/appleApp/FTWUITests/AccessTour.swift index a73f383..6106d36 100644 --- a/appleApp/FTWUITests/AccessTour.swift +++ b/appleApp/FTWUITests/AccessTour.swift @@ -69,9 +69,16 @@ final class AccessTour: XCTestCase { 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) From dc02dc75305b5318a1e03d0d419c98b219e8dc7b Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 16 Oct 2026 10:39:50 +0200 Subject: [PATCH 8/8] Access: the unknown-client switch sits under its warning Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/Access/OfferFlowView.swift | 15 ++++++++------- appleApp/FTWUITests/AccessE2E.swift | 7 +++---- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/appleApp/FTW/Access/OfferFlowView.swift b/appleApp/FTW/Access/OfferFlowView.swift index 8b36cc9..16915c9 100644 --- a/appleApp/FTW/Access/OfferFlowView.swift +++ b/appleApp/FTW/Access/OfferFlowView.swift @@ -130,6 +130,14 @@ struct OfferReview: View { } } + 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") @@ -154,13 +162,6 @@ struct OfferReview: View { } } - 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 { Picker(AccessWords.text("For"), selection: $flow.lengthMs) { diff --git a/appleApp/FTWUITests/AccessE2E.swift b/appleApp/FTWUITests/AccessE2E.swift index 76d92b1..8dcb215 100644 --- a/appleApp/FTWUITests/AccessE2E.swift +++ b/appleApp/FTWUITests/AccessE2E.swift @@ -71,10 +71,9 @@ final class AccessE2E: XCTestCase { // The local consent page is no client FTW Cloud knows: the owner // says they started it. let trust = app.switches["offer.trust"] - if trust.waitForExistence(timeout: 3) { - for _ in 0..<4 where !trust.isHittable { app.collectionViews.firstMatch.swipeUp(velocity: .slow) } - trust.switches.firstMatch.exists ? trust.switches.firstMatch.tap() : trust.tap() - } + 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))