From 1fd7c568d9425428c98bfb4164245f86d42fe306 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 18 Oct 2026 19:16:25 +0200 Subject: [PATCH 1/2] Create and manage shared MCP invitations with six-character codes --- appleApp/FTW/Access/AccessView.swift | 4 + appleApp/FTW/Access/InvitationsView.swift | 237 ++++++++++++++++++ .../Sources/FTWDemo/SimulatedAccess.swift | 37 +++ .../FTWKit/Sources/FTWDemo/SimulatedBox.swift | 4 +- .../Sources/FTWKit/Access/AccessModel.swift | 87 ++++++- .../Sources/FTWKit/Access/AccessWords.swift | 40 +++ .../Sources/FTWKit/Access/Invitation.swift | 124 +++++++++ .../FTWKit/Sources/FTWKit/Access/Offer.swift | 19 ++ .../Sources/FTWKit/Protocol/Contract.swift | 3 +- .../Sources/FTWKit/State/AppModel.swift | 2 +- .../Tests/FTWKitTests/AccessFlowTests.swift | 2 +- .../Tests/FTWKitTests/InvitationTests.swift | 172 +++++++++++++ .../Resources/invitation-vectors.json | 29 +++ protocol/registry.yaml | 2 + 14 files changed, 753 insertions(+), 9 deletions(-) create mode 100644 appleApp/FTW/Access/InvitationsView.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Access/Invitation.swift create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/InvitationTests.swift create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/Resources/invitation-vectors.json diff --git a/appleApp/FTW/Access/AccessView.swift b/appleApp/FTW/Access/AccessView.swift index cddd18ee..6480b750 100644 --- a/appleApp/FTW/Access/AccessView.swift +++ b/appleApp/FTW/Access/AccessView.swift @@ -42,6 +42,10 @@ struct AccessView: View { 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.")) } + if access.invitationsAvailable { + Section { NavigationLink { InvitationsView(app:app,access:access) } label: { Label(AccessWords.text("Shared access"),systemImage:"person.2.badge.key") } } + } + 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) } diff --git a/appleApp/FTW/Access/InvitationsView.swift b/appleApp/FTW/Access/InvitationsView.swift new file mode 100644 index 00000000..61931451 --- /dev/null +++ b/appleApp/FTW/Access/InvitationsView.swift @@ -0,0 +1,237 @@ +import FTWKit +import SwiftUI + +struct InvitationsView: View { + let app: AppModel + let access: AccessModel + + var body: some View { + List { + Section { + NavigationLink { + NewInvitationView(app: app, access: access) + } label: { + Label(AccessWords.text("Create an invitation"), systemImage: "plus") + } + } footer: { + Text( + AccessWords.text( + "Share a code with someone who will use their own assistant. The first person who uses it gets a separate connection you can remove." + )) + } + ForEach(access.invitations) { row in + Section { + Text(AccessWords.clean(row.label.isEmpty ? AccessWords.text("Guest") : row.label)).font( + .headline) + Text(AccessWords.summary(row.scopes)).font(.footnote).foregroundStyle(Theme.ink2) + Text( + AccessWords.text( + row.state == "pending" + ? (row.clockUnavailable ? "Waiting for the box's clock" : "Waiting to be used") + : row.state == "claimed" ? "Access is given" : "Invitation ended")) + LabeledContent(AccessWords.text("Use before"), value: when(row.claimUntilMs)) + LabeledContent(AccessWords.text("Access ends"), value: when(row.expiresAtMs)) + if row.state == "pending" { + Text( + AccessWords.text( + "The code cannot be shown again. Cancel this invitation and create a new one if needed." + ) + ).font(.footnote).foregroundStyle(Theme.ink2) + Button(AccessWords.text("Cancel invitation"), role: .destructive) { + Task { _ = await access.cancelInvitation(row) } + } + } else if row.state == "claimed", access.row(row.agentID) != nil { + NavigationLink { + GrantView(app: app, access: access, id: row.agentID) + } label: { + Label(AccessWords.text("View or remove access"), systemImage: "person.badge.key") + } + } + } + } + if let help = access.help { Section { Text(help).foregroundStyle(Theme.ink2) } } + } + .navigationTitle(AccessWords.text("Shared access")) + .task { + await access.refreshInvitations() + await access.refresh() + } + .refreshable { + await access.refreshInvitations() + await access.refresh() + } + } +} + +private func when(_ ms: Int64) -> String { + Date(timeIntervalSince1970: Double(ms) / 1000).formatted(date: .abbreviated, time: .shortened) +} + +struct NewInvitationView: View { + let app: AppModel + let access: AccessModel + @State private var draft = InvitationDraft() + @State private var label = "" + @State private var scopes = Set( + (Contract.agentRoleScopes[AccessWire.roleAgent] ?? []).filter { + !AccessWire.controlScopes.contains($0) && $0 != AccessWire.scopeSetup + }) + @State private var setup = false + @State private var control = false + @State private var days: Int64 = 7 + @State private var power: Double = 11_000 + @State private var hold: Double = 900 + @Environment(\.scenePhase) private var scenePhase + + var body: some View { + Form { + if let receipt = draft.receipt { + Section( + AccessWords.text(receipt.state == "pending" ? "Invitation code" : "Invitation ended") + ) { + if let code = access.invitationCode(draft) { + Text(code).font(.system(.largeTitle, design: .monospaced)).textSelection(.enabled) + ShareLink(item: code) { + Label(AccessWords.text("Share code"), systemImage: "square.and.arrow.up") + } + Text( + AccessWords.text( + "They connect FTW in their assistant, choose ‘I have an invitation code’ on the FTW connection page and paste it. The box must be online when they use it. Anyone with the unused code can claim these rights." + )) + } else if receipt.state == "pending", draft.alias == nil { + Text( + AccessWords.text("Waiting for the box to publish the code. Keep this page open and retry the same invitation.")) + Button(AccessWords.text("Get the six-character code")) { Task { await access.publishInvitation(draft) } } + .disabled(access.working != nil) + } else { + Text( + AccessWords.text( + receipt.state == "claimed" + ? "Access is already given. Open Shared access to remove its connection." + : receipt.state == "pending" + ? "This code has ended. Check status before creating a new invitation." + : "This invitation has ended.")) + } + if receipt.state == "pending" { + Button(AccessWords.text("Check status and code")) { Task { await checkStatus() } } + .disabled(access.working != nil) + if let help = access.help { Text(help).font(.footnote).foregroundStyle(Theme.ink2) } + } + LabeledContent(AccessWords.text("Use before"), value: when(draft.alias?.expiresAtMs ?? receipt.claimUntilMs)) + LabeledContent(AccessWords.text("Access ends"), value: when(receipt.expiresAtMs)) + } + } else { + Section { + TextField(AccessWords.text("Who is this for?"), text: $label) + ForEach( + Approval.sorted(Contract.agentRoleScopes[AccessWire.roleAgent] ?? []).filter { + !AccessWire.controlScopes.contains($0) && $0 != AccessWire.scopeSetup + }, id: \.self + ) { scope in + Toggle( + AccessWords.scope(scope), + isOn: Binding( + get: { scopes.contains(scope) }, + set: { if $0 { scopes.insert(scope) } else { scopes.remove(scope) } })) + } + Toggle(AccessWords.text("Allow setup"), isOn: $setup) + if setup { + Text( + AccessWords.text( + "Setup can save equipment and electrical settings. Review and confirm the exact draft before saving and confirm any restart separately." + ) + ).font(.footnote) + } + Toggle(AccessWords.text("Also let it control"), isOn: $control) + if control { + ForEach(Approval.sorted(Contract.agentOps.keys), id: \.self) { op in + Text(AccessWords.op(op)).font(.footnote) + } + LabeledContent(AccessWords.text("Power limit (W)")) { + TextField("11000", value: $power, format: .number).keyboardType(.numberPad) + .multilineTextAlignment(.trailing) + } + LabeledContent(AccessWords.text("Hold limit (seconds)")) { + TextField("900", value: $hold, format: .number).keyboardType(.numberPad) + .multilineTextAlignment(.trailing) + } + } + Picker(AccessWords.text("Access lasts"), selection: $days) { + ForEach([Int64(1), 7, 30, 365], id: \.self) { d in + Text(AccessWords.format("%@ days", String(d))).tag(d) + } + } + Text( + AccessWords.text( + "The six-character code works once and ends within ten minutes. Access lasts as long as you choose above.")) + } footer: { + Text( + AccessWords.text( + "The label does not check the recipient's identity. Technical backups can contain saved credentials. The code appears only while this page stays open." + )) + } + .disabled(draft.request != nil) + if draft.request != nil { + Section { + Text( + AccessWords.text( + "Creation is not confirmed. Retry sends the exact same invitation. The Shared access list can show whether it already exists." + )) + } + } + Section { + Button { + Task { await create() } + } label: { + if access.working == "invitation" { + ProgressView() + } else { + Text( + draft.request == nil + ? AccessWords.format("Create code with %@", AccessWords.biometry) + : AccessWords.text("Retry the same invitation")) + } + }.disabled( + (app.cloudHome == nil && !app.isDemo) || access.working != nil + || scopes.isEmpty && !setup && !control + || control + && (!power.isFinite || !hold.isFinite || power < 1 || power > 100_000 || hold < 60 + || hold > 1800) + ) + if app.cloudHome == nil && !app.isDemo { + Text( + AccessWords.text("Wait for the box to connect to FTW Cloud before sharing access.")) + } + if let help = access.help { Text(help).foregroundStyle(Theme.ink2) } + } + } + } + .navigationTitle(AccessWords.text("New invitation")) + .task { await checkStatus() } + .onChange(of: scenePhase) { _, phase in if phase == .active { Task { await checkStatus() } } } + } + + private func checkStatus() async { + guard draft.receipt != nil else { return } + await access.refreshInvitations() + if let row = access.invitations.first(where: { $0.id == draft.id }) { draft.receipt = row } + if draft.receipt?.state == "pending" { await access.publishInvitation(draft) } + } + + private func create() async { + guard let home = app.cloudHome ?? (app.isDemo ? "abcdefghijklmnopqrstuvwx23" : nil) else { + return + } + var allowed = scopes + if setup { allowed.insert(AccessWire.scopeSetup) } + if control { allowed.formUnion(AccessWire.controlScopes) } + let ops = Approval.sorted( + Contract.agentOps.keys.filter { allowed.contains(Contract.agentOps[$0] ?? "") }) + let caps = Caps(holdS: control ? UInt64(hold) : 0, maxW: control ? UInt64(power) : 0, perMin: 6) + let grant = Grant(scopes: Array(allowed), ops: ops, caps: caps, lengthMs: days * 86_400_000) + var name = label.trimmingCharacters(in: .whitespacesAndNewlines) + while name.utf8.count > 128 { name.removeLast() } + _ = await access.createInvitation( + draft, home: home, label: name, grant: grant, claimSeconds: 600) + } +} diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedAccess.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedAccess.swift index 407651f6..1ab892aa 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedAccess.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedAccess.swift @@ -22,6 +22,9 @@ public final class SimulatedAccess { /// 30 days for an assistant. public var longestMs: [String: Int64]? = Dictionary(uniqueKeysWithValues: [Contract.roleAgent, Contract.roleSupport, Contract.roleRelease].map { ($0, AccessWire.longestMs($0)) }) + private var invitations: [String:CBOR] = [:] + private var invitationRequests: [String:CBOR] = [:] + public var loseNextInvitationAnswer = false private var seen = Set() private var seq: UInt64 = 0 let nowMs: @MainActor () -> Double @@ -99,6 +102,10 @@ public final class SimulatedAccess { loseNextAnswer = false reply = { _ in } } + if env.t == InvitationWire.create && loseNextInvitationAnswer { + loseNextInvitationAnswer = 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 @@ -106,6 +113,29 @@ public final class SimulatedAccess { } let now = Int64(nowMs()) switch env.t { + case InvitationWire.list: + ok(.map([("invitations", .array(Array(invitations.values))), ("nowMs", .int(now)), ("clockReady", .bool(true))])) + case InvitationWire.cancel: + guard let id = body["id"]?.string, let row = invitations[id], let m = InvitationCreate(invitationRequests[id] ?? .emptyMap) + else { return refuse("E_NOT_FOUND", []) } + let updated = row["state"]?.string == "pending" ? Self.invitation(m, signedFor.boxKey, state: "cancelled") : row + invitations[id] = updated; ok(.map([("invitation", updated)])) + case InvitationWire.create: + guard let m = InvitationCreate(body), let pub = keys[signedFor.deviceID], let msg = try? m.signedBytes(signedFor) else { + return refuse("E_BAD_SIGNATURE", []) + } + if let old = invitationRequests[m.id], let row = invitations[m.id] { + guard old == body else { return refuse("E_CONFLICT", []) }; ok(.map([("invitation", row)])); return + } + guard m.claimUntilMs > now, m.claimUntilMs <= now + 86_400_000, m.claimUntilMs <= m.expiresAtMs else { + return refuse("E_BAD_SIGNATURE", [("field", .text("claimUntilMs"))]) + } + guard m.expiresAtMs > now, m.expiresAtMs <= now + AccessWire.longestMs(AccessWire.roleAgent) else { + return refuse("E_CAP_EXCEEDED", [("cap", .text("expiresAtMs"))]) + } + if let code = check(pub, msg, m.sig, atMs: m.atMs, now: now) { return refuse(code.0, code.1) } + let row = Self.invitation(m, signedFor.boxKey, state: "pending"); invitations[m.id] = row; invitationRequests[m.id] = body + ok(.map([("invitation", row)])) case AccessWire.msgList: lists += 1 var list: [(String, CBOR)] = [ @@ -210,6 +240,10 @@ public final class SimulatedAccess { /// The box's checks on a signature: fresh, never seen, and over these /// bytes. + private static func invitation(_ m:InvitationCreate,_ box:Bytes,state:String)->CBOR { + .map([("id",.text(m.id)),("home",.text(m.home)),("box",.bytes(box)),("label",.text(m.label)),("state",.text(state)),("scopes",.array(m.grant.scopes.map { .text($0) })),("ops",.array(m.grant.ops.map { .text($0) })),("claimUntilMs",.int(m.claimUntilMs)),("expiresAtMs",.int(m.expiresAtMs))]) + } + 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", []) } @@ -270,6 +304,9 @@ public final class SimulatedAccess { /// 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 + if req.url?.path == "/oauth/invitations", req.httpMethod == "POST" { + return (try JSONSerialization.data(withJSONObject:["code":"K7M-4PX","expiresAtMs":Int64(Date().timeIntervalSince1970*1000)+600_000]),200) + } let code = URLComponents(url: req.url!, resolvingAgainstBaseURL: false)?.queryItems?.first { $0.name == "code" }?.value guard code == "K7QF2MXD" else { return (Data(), 404) } let signed: String = await MainActor.run { () -> String in diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift index bac2f3f9..b5ce6ad4 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift @@ -92,7 +92,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", "access.agents", + "cmd.readback", "der.battery", "der.ev", "plan.dispatch", "price.spot", "api.passthrough", "access.agents", "access.invites", ] /// Capabilities this box offers beyond `caps`, for a test that needs a @@ -196,7 +196,7 @@ 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, + case InvitationWire.create,InvitationWire.list,InvitationWire.cancel,AccessWire.msgList, AccessWire.msgAudit, AccessWire.msgKeySet, AccessWire.msgAdd, AccessWire.msgWiden, AccessWire.msgRevoke, AccessWire.msgConfirm: access.receive(env) { [weak self] reply in guard let self else { return } diff --git a/appleApp/FTWKit/Sources/FTWKit/Access/AccessModel.swift b/appleApp/FTWKit/Sources/FTWKit/Access/AccessModel.swift index bcd29854..9e522f09 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Access/AccessModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Access/AccessModel.swift @@ -144,6 +144,7 @@ public struct Grant: Equatable, Sendable { public final class AccessModel { /// Nil until the box has answered once. public private(set) var list: AgentList? + public private(set) var invitations: [AgentInvitation] = [] public private(set) var audit: [AuditRow] = [] public private(set) var auditMore = false /// What the owner wrote for each support ticket, on this phone only. @@ -175,6 +176,7 @@ public final class AccessModel { public let signedFor: SignedFor @ObservationIgnored let keyring: ApprovalKeyring? @ObservationIgnored let wallMs: @MainActor () -> Double + @ObservationIgnored let invitationLookup: OfferLookup /// Box wall clock minus this phone's, once the box has said /// `E_CLOCK_SKEW`. @ObservationIgnored private var boxOffsetMs: Double = 0 @@ -183,11 +185,12 @@ public final class AccessModel { /// 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 }) { + public init(site: SiteModel, signedFor: SignedFor, keyring: ApprovalKeyring?, wallMs: @escaping @MainActor () -> Double = { Date().timeIntervalSince1970 * 1000 }, invitationLookup: OfferLookup = .current()) { self.site = site self.signedFor = signedFor self.keyring = keyring self.wallMs = wallMs + self.invitationLookup = invitationLookup // 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) } @@ -297,6 +300,78 @@ public final class AccessModel { } } + public var invitationsAvailable: Bool { available && site.session.caps.contains(Contract.capAccessInvites) } + + public func refreshInvitations() async { + guard invitationsAvailable else { return } + do { + let rows = (try await site.ask(InvitationWire.list, .emptyMap))["invitations"]?.array?.compactMap(AgentInvitation.init) ?? [] + let newlyClaimed = rows.contains { row in + row.state == "claimed" && !invitations.contains(where: { $0.id == row.id && $0.state == "claimed" }) + } + invitations = rows + if newlyClaimed { await refresh() } + } catch { help = words(error) } + } + + public func cancelInvitation(_ invitation: AgentInvitation) async -> AgentInvitation? { + do { + let answer = try await site.ask(InvitationWire.cancel, .map([("id", .text(invitation.id))])) + let row = answer["invitation"].flatMap(AgentInvitation.init) + await refreshInvitations(); await refresh(); return row + } catch { help = words(error); return nil } + } + + @discardableResult + public func createInvitation(_ draft: InvitationDraft, home: String, label: String, grant: Grant, claimSeconds: Int64) async + -> AgentInvitation? + { + let result: AgentInvitation? = await signed( + AccessWords.text("Create an invitation to this home"), target: "invitation", retrySkew: false, + onSpare: { signer in + guard var request = draft.request else { return } + request.sig = try signer.sign(try request.signedBytes(self.signedFor)) + draft.request = request + } + ) { signer in + if draft.request == nil { + let clock = try await self.site.ask(InvitationWire.list, .emptyMap) + guard clock["clockReady"]?.bool == true, let boxMs = clock["nowMs"]?.int64, boxMs > 0 else { + throw BoxRefusal(detail: ErrorMsg(code: "E_UNAVAILABLE", retryable: true, args: ["subsystem": .text("clock")])) + } + self.boxOffsetMs = Double(boxMs) - self.wallMs() + let at = self.nowMs, end = self.expiry(grant.lengthMs) + var m = InvitationCreate( + id: draft.id, home: home, label: label, hash: InvitationWire.hash(home: home, secret: draft.secret), grant: grant, + claimUntilMs: min(at + claimSeconds * 1000, end), expiresAtMs: end, atMs: at) + m.sig = try signer.sign(try m.signedBytes(self.signedFor)); draft.request = m + } + guard let m = draft.request else { return nil } + let answer = try await self.site.ask(InvitationWire.create, m.cbor) + return answer["invitation"].flatMap(AgentInvitation.init) + } + if let result { draft.receipt = result } + await refreshInvitations() + if result == nil, let row = invitations.first(where: { $0.id == draft.id }) { draft.receipt = row } + if draft.receipt?.state == "pending" { await publishInvitation(draft) } + return draft.receipt + } + + public func invitationCode(_ draft: InvitationDraft) -> String? { + guard let alias = draft.alias, nowMs < alias.expiresAtMs else { return nil } + return draft.code + } + + public func publishInvitation(_ draft: InvitationDraft) async { + guard let receipt = draft.receipt, receipt.state == "pending" else { return } + working = "invitationCode" + defer { working = nil } + let proof = InvitationWire.code(home: receipt.home, box: receipt.box, secret: draft.secret) + do { draft.alias = try await invitationLookup.publishInvitation(proof); help = nil } catch { + help = AccessWords.text("Waiting for the box to publish the code. Keep this page open and retry the same invitation.") + } + } + // MARK: Granting /// Grant what the owner chose from an offer, behind one Face ID. A phone @@ -491,7 +566,10 @@ 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. 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? { + private func signed( + _ reason: String, target: String, retrySkew: Bool = true, onSpare: (@MainActor (ApprovalSigner) throws -> Void)? = nil, + _ work: @escaping @MainActor (ApprovalSigner) async throws -> T? + ) async -> T? { guard let keyring, let home = keyHome else { help = AccessWords.text("This phone can't approve on its own. Use your box's page.") return nil @@ -513,12 +591,13 @@ public final class AccessModel { let keys = try await registered(unlock) let out: T? do { - out = try await retryingSkew { try await work(keys.signer) } + if retrySkew { out = try await retryingSkew { try await work(keys.signer) } } else { out = 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 onSpare?(keys.spare!) + if retrySkew { out = try await retryingSkew { try await work(keys.spare!) } } else { out = try await work(keys.spare!) } try? unlock.promote() } help = nil diff --git a/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift b/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift index 701f0962..3906dda7 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift @@ -460,6 +460,46 @@ public enum AccessWords { // MARK: Swedish static let sv: [String: String] = [ + "Shared access": "Delad åtkomst", + "New invitation": "Ny inbjudan", + "Create an invitation": "Skapa en inbjudan", + "Create an invitation to this home": "Skapa en inbjudan till den här anläggningen", + "Guest": "Gäst", + "Invitation code": "Inbjudningskod", + "Invitation ended": "Inbjudan har upphört", + "Waiting for the box's clock": "Väntar på boxens klocka", + "Waiting for the box to publish the code. Keep this page open and retry the same invitation.": "Väntar på att boxen ska publicera koden. Behåll sidan öppen och försök igen med samma inbjudan.", + "Get the six-character code": "Hämta koden med sex tecken", + "Check status and code": "Kontrollera status och kod", + "This code has ended. Check status before creating a new invitation.": "Koden har gått ut. Kontrollera status innan du skapar en ny inbjudan.", + "The six-character code works once and ends within ten minutes. Access lasts as long as you choose above.": "Koden med sex tecken fungerar en gång och går ut inom tio minuter. Åtkomsten gäller så länge som du väljer ovan.", + "Waiting to be used": "Väntar på att användas", + "Access is given": "Åtkomst är given", + "Use before": "Använd före", + "Access ends": "Åtkomsten upphör", + "Access lasts": "Åtkomst i", + "Use the code within": "Använd koden inom", + "1 hour": "1 timme", + "24 hours": "24 timmar", + "10 minutes": "10 minuter", + "Cancel invitation": "Avbryt inbjudan", + "Share code": "Dela kod", + "View or remove access": "Visa eller ta bort åtkomst", + "Who is this for?": "Vem är det till?", + "Power limit (W)": "Effektgräns (W)", + "Hold limit (seconds)": "Tidsgräns för tillfällig styrning (sekunder)", + "Check status": "Kontrollera status", + "Create code with %@": "Skapa kod med %@", + "Retry the same invitation": "Försök igen med samma inbjudan", + "Share a code with someone who will use their own assistant. The first person who uses it gets a separate connection you can remove.": "Dela en kod med någon som använder sin egen assistent. Den första som använder koden får en egen anslutning som du kan ta bort.", + "The code cannot be shown again. Cancel this invitation and create a new one if needed.": "Koden går inte att visa igen. Avbryt inbjudan och skapa en ny om det behövs.", + "They connect FTW in their assistant, choose ‘I have an invitation code’ on the FTW connection page and paste it. The box must be online when they use it. Anyone with the unused code can claim these rights.": "Mottagaren ansluter FTW i sin assistent, väljer ”I have an invitation code” på FTW:s anslutningssida och klistrar in koden. Boxen måste vara online då. Vem som helst med den oanvända koden kan få de här rättigheterna.", + "Access is already given. Open Shared access to remove its connection.": "Åtkomst är redan given. Öppna Delad åtkomst för att ta bort anslutningen.", + "This invitation has ended.": "Den här inbjudan har upphört.", + "Setup can save equipment and electrical settings. Review and confirm the exact draft before saving and confirm any restart separately.": "Setup kan spara utrustning och elinställningar. Granska och bekräfta det exakta utkastet före sparning och bekräfta en omstart för sig.", + "The label does not check the recipient's identity. Technical backups can contain saved credentials. The code appears only while this page stays open.": "Namnet kontrollerar inte mottagarens identitet. Tekniska säkerhetskopior kan innehålla sparade inloggningsuppgifter. Koden visas bara medan den här sidan är öppen.", + "Creation is not confirmed. Retry sends the exact same invitation. The Shared access list can show whether it already exists.": "Skapandet är inte bekräftat. Ett nytt försök skickar exakt samma inbjudan. Listan Delad åtkomst kan visa om den redan finns.", + "Wait for the box to connect to FTW Cloud before sharing access.": "Vänta tills boxen har anslutit till FTW Cloud innan du delar åtkomst.", // Who "Sourceful support": "Sourceful support", "Assistant %@": "Assistent %@", diff --git a/appleApp/FTWKit/Sources/FTWKit/Access/Invitation.swift b/appleApp/FTWKit/Sources/FTWKit/Access/Invitation.swift new file mode 100644 index 00000000..5fb3a2bc --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Access/Invitation.swift @@ -0,0 +1,124 @@ +import CryptoKit +import Foundation +import Observation + +public enum InvitationWire { + public static let create = "agent.invite.create" + public static let list = "agent.invite.list" + public static let cancel = "agent.invite.cancel" + + public static func hash(home: String, secret: Bytes) -> Bytes { + Primitives.sha256(Bytes("ftw.agent.invite.v1\0".utf8) + Bytes(home.utf8) + [0] + secret) + } + + public static func code(home: String, box: Bytes, secret: Bytes) -> String { + Base64url.encode( + encodeDeterministic( + .map([ + ("v", .unsigned(1)), ("home", .text(home)), ("box", .bytes(box)), + ("secret", .bytes(secret)), + ]))) + } +} + +public struct InvitationCreate: Equatable, Sendable { + public var id, home, label: String + public var hash: Bytes + public var grant: Grant + public var claimUntilMs, expiresAtMs, atMs: Int64 + public var sig: Bytes = [] + + public init( + id: String, home: String, label: String, hash: Bytes, grant: Grant, claimUntilMs: Int64, + expiresAtMs: Int64, atMs: Int64 + ) { + self.id = id + self.home = home + self.label = label + self.hash = hash + self.grant = grant + self.claimUntilMs = claimUntilMs + self.expiresAtMs = expiresAtMs + self.atMs = atMs + } + + public init?(_ c: CBOR) { + guard let id = c["id"]?.string, let home = c["home"]?.string, let hash = c["hash"]?.byteString, + let claim = c["claimUntilMs"]?.int64, let end = c["expiresAtMs"]?.int64, + let at = c["atMs"]?.int64 + else { return nil } + self.init( + id: id, home: home, label: c["label"]?.string ?? "", hash: hash, + grant: Grant( + scopes: c["scopes"]?.stringArray ?? [], ops: c["ops"]?.stringArray ?? [], + caps: Caps(c["caps"]), lengthMs: end - at), claimUntilMs: claim, expiresAtMs: end, atMs: at) + sig = c["sig"]?.byteString ?? [] + } + + public func signedBytes(_ f: SignedFor) throws -> Bytes { + try Approval.bytes( + InvitationWire.create, f, atMs: atMs, + [ + .text(home), .text(id), .bytes(hash), .text(label), + Approval.sortedList(grant.scopes), Approval.sortedList(grant.ops), grant.caps.array, + .int(claimUntilMs), .int(expiresAtMs), + ]) + } + + public var cbor: CBOR { + .map([ + ("id", .text(id)), ("home", .text(home)), ("hash", .bytes(hash)), ("label", .text(label)), + ("scopes", .array(grant.scopes.map { .text($0) })), + ("ops", .array(grant.ops.map { .text($0) })), ("caps", grant.caps.cbor), + ("claimUntilMs", .int(claimUntilMs)), ("expiresAtMs", .int(expiresAtMs)), + ("atMs", .int(atMs)), ("sig", .bytes(sig)), + ]).deterministic + } +} + +public struct AgentInvitation: Identifiable, Equatable, Sendable { + public let id, home, label, state, agentID, client: String + public let box: Bytes + public let scopes, ops: [String] + public let claimUntilMs, expiresAtMs: Int64 + public let clockUnavailable: Bool + + public init?(_ c: CBOR) { + guard let id = c["id"]?.string, let home = c["home"]?.string, let box = c["box"]?.byteString, + box.count == 32, + let state = c["state"]?.string, let claim = c["claimUntilMs"]?.int64, + let end = c["expiresAtMs"]?.int64 + else { return nil } + self.id = id + self.home = home + self.box = box + self.state = state + self.claimUntilMs = claim + self.expiresAtMs = end + label = c["label"]?.string ?? "" + agentID = c["agentId"]?.string ?? "" + client = c["client"]?.string ?? "" + scopes = c["scopes"]?.stringArray ?? [] + ops = c["ops"]?.stringArray ?? [] + clockUnavailable = c["clockUnavailable"]?.bool ?? false + } +} + +// Only memory holds the code and the exact signed request. A lost reply +// never makes a new timestamp, id, signature or secret behind the owner's back. +@Observable @MainActor +public final class InvitationDraft { + public let secret: Bytes + public let id: String + public var request: InvitationCreate? + public var receipt: AgentInvitation? + public var alias: InvitationAlias? + public init(secret: Bytes? = nil, id: String? = nil) { + self.secret = secret ?? SymmetricKey(size: .bits256).withUnsafeBytes { Bytes($0) } + self.id = id ?? Base64url.encode(SymmetricKey(size: .bits128).withUnsafeBytes { Bytes($0) }) + } + public var code: String? { + guard let receipt, receipt.state == "pending" else { return nil } + return alias?.code + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Access/Offer.swift b/appleApp/FTWKit/Sources/FTWKit/Access/Offer.swift index 17565f32..2ce90885 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Access/Offer.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Access/Offer.swift @@ -345,6 +345,20 @@ public struct OfferLookup: Sendable { return try await get(c.url!) } + public func publishInvitation(_ invitation: String) async throws -> InvitationAlias { + var request = URLRequest(url: base.appendingPathComponent("oauth/invitations"), timeoutInterval: 15) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: ["invitation": invitation]) + let (data, status) = try await fetch(request) + guard status == 200 else { throw OfferError.unreachable } + let alias = try JSONDecoder().decode(InvitationAlias.self, from: data) + guard alias.code.range(of: "^[A-HJ-NP-Z2-9]{3}-[A-HJ-NP-Z2-9]{3}$", options: .regularExpression) != nil else { + throw OfferError.malformed + } + return alias + } + private func get(_ url: URL) async throws -> SignedOffer { var req = URLRequest(url: url, timeoutInterval: 15) req.setValue("application/json", forHTTPHeaderField: "Accept") @@ -365,3 +379,8 @@ public struct OfferLookup: Sendable { return try SignedOffer(base64url: offer) } } + +public struct InvitationAlias: Decodable, Sendable { + public let code: String + public let expiresAtMs: Int64 +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift index 2a3b5f9e..c08c010c 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift @@ -103,7 +103,7 @@ public enum Contract { "cmd.lease", "cmd.precondition", "cmd.readback", "der.battery", "der.ev", "der.v2x", "plan.dispatch", "net.webrtc", "price.spot", "api.passthrough", - "cloud.link", "cloud.tun", "cmd.exchange", "access.agents", "der.car", + "cloud.link", "cloud.tun", "cmd.exchange", "access.agents", "access.invites", "der.car", ] public static let capHistory5m = "history.5m" @@ -122,6 +122,7 @@ public enum Contract { /// The box takes agents and answers the agent messages. Absent, the app /// offers no Access list. public static let capAccessAgents = "access.agents" + public static let capAccessInvites = "access.invites" /// The scopes an FTW Cloud grant may name, and what its data may be /// used for: the registry's `cloud_scopes` and `cloud_purposes`. diff --git a/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift index 742b79da..ead6cc03 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift @@ -369,7 +369,7 @@ public final class AppModel { 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, wallMs: { [scheduler] in scheduler.nowMs }) + models.access = AccessModel(site: site, signedFor: AccessModel.demoFor, keyring: demoAccess.keys, wallMs: { [scheduler] in scheduler.nowMs }, invitationLookup: demoAccess.lookup) } home = models isDemo = true diff --git a/appleApp/FTWKit/Tests/FTWKitTests/AccessFlowTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/AccessFlowTests.swift index 9d879dc0..19b08aa6 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/AccessFlowTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/AccessFlowTests.swift @@ -21,7 +21,7 @@ import FTWDemo 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 }) + access = AccessModel(site: site, signedFor: AccessModel.demoFor, keyring: keyring, wallMs: { s.nowMs + phoneClockOffMs }, invitationLookup: SimulatedAccess.lookup) site.start() site.connect(LoopbackCarrier(box: box, scheduler: scheduler, latencyMs: 20)) } diff --git a/appleApp/FTWKit/Tests/FTWKitTests/InvitationTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/InvitationTests.swift new file mode 100644 index 00000000..60aba3d2 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/InvitationTests.swift @@ -0,0 +1,172 @@ +import FTWDemo +import Foundation +import Testing + +@testable import FTWKit + +@MainActor @Suite struct InvitationTests { + struct Vector: Decodable { + let home, boxHex, secretHex, deviceId, hashHex, signedHex, code: String + } + @Test func codeHashAndApprovalMatchCore() throws { + let url = try #require( + Bundle.module.url( + forResource: "invitation-vectors", withExtension: "json", subdirectory: "Resources")) + let v = try JSONDecoder().decode(Vector.self, from: Data(contentsOf: url)) + let secret = Bytes(hex: v.secretHex) + let box = Bytes(hex: v.boxHex) + #expect(InvitationWire.hash(home: v.home, secret: secret) == Bytes(hex: v.hashHex)) + #expect(InvitationWire.code(home: v.home, box: box, secret: secret) == v.code) + let m = InvitationCreate( + id: Base64url.encode(Bytes(repeating: 0x55, count: 16)), home: v.home, label: "Hannes", + hash: Bytes(hex: v.hashHex), + grant: Grant( + scopes: ["ftw.diag.run", "ftw.live.read"], ops: [], caps: Caps(), lengthMs: 86_400_000), + claimUntilMs: 1_792_000_060_000, expiresAtMs: 1_792_086_400_000, atMs: 1_792_000_000_000) + #expect( + try m.signedBytes(SignedFor(boxKey: box, deviceID: v.deviceId)) == Bytes(hex: v.signedHex)) + } +} + +extension AccessFlowTests { + @Test func invitationIsSignedAndAnExactRetryKeepsItsReceipt() async throws { + let w = await world(seed: false) + let draft = InvitationDraft() + let grant = Grant( + scopes: ["ftw.diag.run", "ftw.live.read"], ops: [], caps: Caps(), lengthMs: 7 * 86_400_000) + let home = "abcdefghijklmnopqrstuvwx23" + let first = await w.during { + await w.access.createInvitation( + draft, home: home, label: "Hannes", grant: grant, claimSeconds: 3600) + } + #expect(first?.state == "pending") + #expect(draft.code != nil) + let request = try #require(draft.request) + #expect( + Approval.verify( + pub: try #require(w.box.access.keys[AccessModel.demoFor.deviceID]), + message: try request.signedBytes(AccessModel.demoFor), sig: request.sig)) + _ = await w.during { + await w.access.createInvitation( + draft, home: home, label: "Changed after send", grant: grant, claimSeconds: 600) + } + #expect(draft.request == request) + #expect(draft.receipt?.label == "Hannes") + #expect(w.access.invitations.count == 1) + let receipt = try #require(draft.receipt) + let ended = await w.during { await w.access.cancelInvitation(receipt) } + #expect(ended?.state == "cancelled") + } + + @Test func lostInvitationReplyRecoversFromTheBoxWithoutChangingSignedBytes() async throws { + let w = await world(seed: false) + let grant = Grant(scopes: ["ftw.live.read"], ops: [], caps: Caps(), lengthMs: 86_400_000) + let home = "abcdefghijklmnopqrstuvwx23" + _ = await w.during { + await w.access.createInvitation( + InvitationDraft(), home: home, label: "First", grant: grant, claimSeconds: 3600) + } + let draft = InvitationDraft() + w.box.access.loseNextInvitationAnswer = true + let task = Task { + await w.access.createInvitation( + draft, home: home, label: "Lost reply", grant: grant, claimSeconds: 3600) + } + await w.run(12_000) + _ = await task.value + let request = try #require(draft.request) + #expect(draft.receipt?.state == "pending") + #expect(draft.code != nil) + _ = await w.during { + await w.access.createInvitation( + draft, home: home, label: "Ignored", grant: grant, claimSeconds: 600) + } + #expect(draft.request == request) + #expect(w.access.invitations.count == 2) + } + + @Test func invitationRecoversAReplacementKeyWithoutChangingItsFields() 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 replacing = Task { await w.access.replaceKey() } + await w.run(10_000) + #expect(await replacing.value == false) + let taken = try #require(w.box.access.keys[AccessModel.demoFor.deviceID]) + #expect(taken != old) + let draft = InvitationDraft() + let grant = Grant(scopes: ["ftw.live.read"], ops: [], caps: Caps(), lengthMs: 86_400_000) + let row = await w.during { + await w.access.createInvitation( + draft, home: "abcdefghijklmnopqrstuvwx23", label: "After replacement", grant: grant, claimSeconds: 3600) + } + #expect(row?.state == "pending", "\(w.access.help ?? "")") + let request = try #require(draft.request) + #expect(Approval.verify(pub: taken, message: try request.signedBytes(AccessModel.demoFor), sig: request.sig)) + #expect(try w.keyring.existing()?.publicKey == taken) + #expect(try w.keyring.pending() == nil) + #expect(w.keyring.ends == w.keyring.unlocks) + } + + @Test(arguments: [300_000.0, 900_000.0, -900_000.0]) + func invitationUsesTheBoxTimeBeforeItCachesMaximumLengths(phoneClockOffMs: Double) 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 access = AccessModel( + site: w.site, signedFor: AccessModel.demoFor, keyring: w.keyring, wallMs: { w.scheduler.nowMs + phoneClockOffMs }, + invitationLookup: SimulatedAccess.lookup) + let draft = InvitationDraft() + let grant = Grant(scopes: ["ftw.live.read"], ops: [], caps: Caps(), lengthMs: 365 * 86_400_000) + let row = await w.during { + await access.createInvitation(draft, home: "abcdefghijklmnopqrstuvwx23", label: "Clock", grant: grant, claimSeconds: 86400) + } + #expect(row?.state == "pending", "\(access.help ?? "")") + let request = try #require(draft.request) + #expect(abs(request.atMs - Int64(w.scheduler.nowMs)) < 3000) + #expect(request.claimUntilMs <= Int64(w.scheduler.nowMs) + 86_400_000) + #expect(request.expiresAtMs <= Int64(w.scheduler.nowMs) + 365 * 86_400_000) + } + + @Test func lostShortCodeReplyRetriesTheSameProofWithoutAnotherApproval() async throws { + let w = await world(seed: false) + let recorder = InvitationPublicationRecorder() + let lookup = OfferLookup(base: URL(string: "https://closed-test.invalid")!) { request in + try await recorder.reply(request) + } + let access = AccessModel( + site: w.site, signedFor: AccessModel.demoFor, keyring: w.keyring, wallMs: { w.scheduler.nowMs }, invitationLookup: lookup) + let draft = InvitationDraft() + let grant = Grant(scopes: ["ftw.live.read"], ops: [], caps: Caps(), lengthMs: 86_400_000) + _ = await w.during { + await access.createInvitation(draft, home: "abcdefghijklmnopqrstuvwx23", label: "Hannes", grant: grant, claimSeconds: 600) + } + #expect(draft.receipt?.state == "pending") + #expect(draft.code == nil) + let request = try #require(draft.request) + let unlocks = w.keyring.unlocks + await access.publishInvitation(draft) + #expect(draft.code == "K7M-4PX") + #expect(draft.request == request) + #expect(w.keyring.unlocks == unlocks) + #expect(access.invitations.count == 1) + let bodies = await recorder.bodies + #expect(bodies.count == 2) + #expect(bodies[0] == bodies[1]) + } +} + +private actor InvitationPublicationRecorder { + private(set) var bodies: [Data] = [] + func reply(_ request: URLRequest) throws -> (Data, Int) { + bodies.append(request.httpBody ?? Data()) + if bodies.count == 1 { throw URLError(.networkConnectionLost) } + return ( + try JSONSerialization.data(withJSONObject: [ + "code": "K7M-4PX", "expiresAtMs": Int64(Date().timeIntervalSince1970 * 1000) + 600_000, + ]), 200 + ) + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/Resources/invitation-vectors.json b/appleApp/FTWKit/Tests/FTWKitTests/Resources/invitation-vectors.json new file mode 100644 index 00000000..eb030553 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/Resources/invitation-vectors.json @@ -0,0 +1,29 @@ +{ + "boxHex": "5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a", + "code": "pGF2AWNib3hYIFpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaZGhvbWV4GmFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eDIzZnNlY3JldFggkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZE", + "deviceId": "demoPh01", + "hashHex": "084bd13dd37899d0a7ca8f7b74050689d66a7969a97222d86e8c20f96534380e", + "home": "abcdefghijklmnopqrstuvwx23", + "request": { + "home": "abcdefghijklmnopqrstuvwx23", + "id": "VVVVVVVVVVVVVVVVVVVVVQ", + "hash": "CEvRPdN4mdCnyo97dAUGidZqeWmpciLYbowg+WU0OA4=", + "label": "Hannes", + "scopes": [ + "ftw.diag.run", + "ftw.live.read" + ], + "ops": [], + "caps": { + "holdS": 0, + "maxW": 0, + "perMin": 0 + }, + "claimUntilMs": 1792000060000, + "expiresAtMs": 1792086400000, + "atMs": 1792000000000, + "sig": null + }, + "secretHex": "9191919191919191919191919191919191919191919191919191919191919191", + "signedHex": "8e6f6674772e617070726f76616c2e7631736167656e742e696e766974652e63726561746558205a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a6864656d6f506830311b000001a13b860000781a6162636465666768696a6b6c6d6e6f707172737475767778323376565656565656565656565656565656565656565656515820084bd13dd37899d0a7ca8f7b74050689d66a7969a97222d86e8c20f96534380e6648616e6e6573826c6674772e646961672e72756e6d6674772e6c6976652e7265616480830000001b000001a13b86ea601b000001a140ac5c00" +} diff --git a/protocol/registry.yaml b/protocol/registry.yaml index b8c70b46..a5fff570 100644 --- a/protocol/registry.yaml +++ b/protocol/registry.yaml @@ -116,6 +116,8 @@ capabilities: # answers the agent.* messages and approval.key.set (docs/agents.md in # srcfl/ftw). Absent means the app offers no Access list. - access.agents + # Owner-created codes for one agent with its own rights and expiry. + - access.invites # The box keeps cars apart from chargers: each car has its own level, goal # and sources, and a charger names the car on it (docs/cars.md in # srcfl/ftw). The box answers the car ops and session.car.set. Absent From 351a3df73101ff1f6b9452f9b59a40304ba12730 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 18 Oct 2026 20:24:47 +0200 Subject: [PATCH 2/2] Use the box invitation limit and recover rejected requests --- appleApp/FTW/Access/InvitationsView.swift | 24 ++++++--- .../Sources/FTWDemo/SimulatedAccess.swift | 2 +- .../Sources/FTWKit/Access/AccessModel.swift | 36 ++++++++++++- .../Sources/FTWKit/Access/AccessWords.swift | 1 + .../Tests/FTWKitTests/InvitationTests.swift | 54 +++++++++++++++++++ 5 files changed, 108 insertions(+), 9 deletions(-) diff --git a/appleApp/FTW/Access/InvitationsView.swift b/appleApp/FTW/Access/InvitationsView.swift index 61931451..b3542cac 100644 --- a/appleApp/FTW/Access/InvitationsView.swift +++ b/appleApp/FTW/Access/InvitationsView.swift @@ -78,7 +78,7 @@ struct NewInvitationView: View { }) @State private var setup = false @State private var control = false - @State private var days: Int64 = 7 + @State private var lengthMs: Int64 = 7 * 86_400_000 @State private var power: Double = 11_000 @State private var hold: Double = 900 @Environment(\.scenePhase) private var scenePhase @@ -156,9 +156,9 @@ struct NewInvitationView: View { .multilineTextAlignment(.trailing) } } - Picker(AccessWords.text("Access lasts"), selection: $days) { - ForEach([Int64(1), 7, 30, 365], id: \.self) { d in - Text(AccessWords.format("%@ days", String(d))).tag(d) + Picker(AccessWords.text("Access lasts"), selection: $lengthMs) { + ForEach(access.invitationLengths, id: \.self) { length in + Text(AccessWords.length(length)).tag(length) } } Text( @@ -207,10 +207,21 @@ struct NewInvitationView: View { } } .navigationTitle(AccessWords.text("New invitation")) - .task { await checkStatus() } + .task { + await access.refresh() + keepLengthValid() + await checkStatus() + } + .onChange(of: access.invitationLengths) { _, _ in keepLengthValid() } .onChange(of: scenePhase) { _, phase in if phase == .active { Task { await checkStatus() } } } } + private func keepLengthValid() { + guard draft.request == nil, !access.invitationLengths.contains(lengthMs) else { return } + lengthMs = access.invitationLengths.last(where: { $0 <= 7 * 86_400_000 }) + ?? access.invitationLengths.first ?? 7 * 86_400_000 + } + private func checkStatus() async { guard draft.receipt != nil else { return } await access.refreshInvitations() @@ -228,10 +239,11 @@ struct NewInvitationView: View { let ops = Approval.sorted( Contract.agentOps.keys.filter { allowed.contains(Contract.agentOps[$0] ?? "") }) let caps = Caps(holdS: control ? UInt64(hold) : 0, maxW: control ? UInt64(power) : 0, perMin: 6) - let grant = Grant(scopes: Array(allowed), ops: ops, caps: caps, lengthMs: days * 86_400_000) + let grant = Grant(scopes: Array(allowed), ops: ops, caps: caps, lengthMs: lengthMs) var name = label.trimmingCharacters(in: .whitespacesAndNewlines) while name.utf8.count > 128 { name.removeLast() } _ = await access.createInvitation( draft, home: home, label: name, grant: grant, claimSeconds: 600) + keepLengthValid() } } diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedAccess.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedAccess.swift index 1ab892aa..2421b516 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedAccess.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedAccess.swift @@ -130,7 +130,7 @@ public final class SimulatedAccess { guard m.claimUntilMs > now, m.claimUntilMs <= now + 86_400_000, m.claimUntilMs <= m.expiresAtMs else { return refuse("E_BAD_SIGNATURE", [("field", .text("claimUntilMs"))]) } - guard m.expiresAtMs > now, m.expiresAtMs <= now + AccessWire.longestMs(AccessWire.roleAgent) else { + guard m.expiresAtMs > now, m.expiresAtMs <= now + longest(AccessWire.roleAgent) else { return refuse("E_CAP_EXCEEDED", [("cap", .text("expiresAtMs"))]) } if let code = check(pub, msg, m.sig, atMs: m.atMs, now: now) { return refuse(code.0, code.1) } diff --git a/appleApp/FTWKit/Sources/FTWKit/Access/AccessModel.swift b/appleApp/FTWKit/Sources/FTWKit/Access/AccessModel.swift index 9e522f09..ac30a4c9 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Access/AccessModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Access/AccessModel.swift @@ -273,6 +273,15 @@ public final class AccessModel { keysKnown ? list?.longestMs[role] : nil } + public var invitationLengths: [Int64] { + Grant.lengths(role: Contract.roleAgent, boxMs: boxLongestMs(Contract.roleAgent)) + } + + private func invitationLengthAllowed(_ length: Int64) -> Bool { + length > 0 && length <= min(AccessWire.longestMs(Contract.roleAgent), + boxLongestMs(Contract.roleAgent) ?? AccessWire.unstatedLongestMs(Contract.roleAgent)) + } + // MARK: Reading public func refresh() async { @@ -326,6 +335,17 @@ public final class AccessModel { public func createInvitation(_ draft: InvitationDraft, home: String, label: String, grant: Grant, claimSeconds: Int64) async -> AgentInvitation? { + guard working == nil else { return nil } + working = "invitation" + defer { working = nil } + if draft.request == nil { + await refresh() + guard keysKnown else { return nil } + guard invitationLengthAllowed(grant.lengthMs) else { + help = AccessWords.text("Choose a shorter access time that your box allows.") + return nil + } + } let result: AgentInvitation? = await signed( AccessWords.text("Create an invitation to this home"), target: "invitation", retrySkew: false, onSpare: { signer in @@ -335,6 +355,10 @@ public final class AccessModel { } ) { signer in if draft.request == nil { + await self.refresh() + guard self.invitationLengthAllowed(grant.lengthMs) else { + throw BoxRefusal(detail: ErrorMsg(code: "E_CAP_EXCEEDED", retryable: false, args: ["cap": .text("expiresAtMs")])) + } let clock = try await self.site.ask(InvitationWire.list, .emptyMap) guard clock["clockReady"]?.bool == true, let boxMs = clock["nowMs"]?.int64, boxMs > 0 else { throw BoxRefusal(detail: ErrorMsg(code: "E_UNAVAILABLE", retryable: true, args: ["subsystem": .text("clock")])) @@ -347,8 +371,16 @@ public final class AccessModel { m.sig = try signer.sign(try m.signedBytes(self.signedFor)); draft.request = m } guard let m = draft.request else { return nil } - let answer = try await self.site.ask(InvitationWire.create, m.cbor) - return answer["invitation"].flatMap(AgentInvitation.init) + do { + let answer = try await self.site.ask(InvitationWire.create, m.cbor) + return answer["invitation"].flatMap(AgentInvitation.init) + } catch let refusal as BoxRefusal where refusal.detail.code == "E_CAP_EXCEEDED" + || (refusal.detail.code == "E_BAD_SIGNATURE" && refusal.detail.args["field"]?.string == "claimUntilMs") { + // An accepted exact retry returns its receipt before these + // checks. A timeout keeps the signed request for recovery. + if draft.receipt == nil { draft.request = nil } + throw refusal + } } if let result { draft.receipt = result } await refreshInvitations() diff --git a/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift b/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift index 3906dda7..8c075fc7 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift @@ -500,6 +500,7 @@ public enum AccessWords { "The label does not check the recipient's identity. Technical backups can contain saved credentials. The code appears only while this page stays open.": "Namnet kontrollerar inte mottagarens identitet. Tekniska säkerhetskopior kan innehålla sparade inloggningsuppgifter. Koden visas bara medan den här sidan är öppen.", "Creation is not confirmed. Retry sends the exact same invitation. The Shared access list can show whether it already exists.": "Skapandet är inte bekräftat. Ett nytt försök skickar exakt samma inbjudan. Listan Delad åtkomst kan visa om den redan finns.", "Wait for the box to connect to FTW Cloud before sharing access.": "Vänta tills boxen har anslutit till FTW Cloud innan du delar åtkomst.", + "Choose a shorter access time that your box allows.": "Välj en kortare åtkomsttid som boxen tillåter.", // Who "Sourceful support": "Sourceful support", "Assistant %@": "Assistent %@", diff --git a/appleApp/FTWKit/Tests/FTWKitTests/InvitationTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/InvitationTests.swift index 60aba3d2..cdcc5f2f 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/InvitationTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/InvitationTests.swift @@ -29,6 +29,60 @@ import Testing } extension AccessFlowTests { + @Test func invitationLengthsFollowTheBoxBeforeBiometry() async throws { + let day: Int64 = 86_400_000 + for limit in [Int64?.none, 2 * day, 365 * day] { + let w = await world(seed: false) + w.box.access.longestMs = limit.map { [Contract.roleAgent: $0] } + await w.during { await w.access.refresh() } + let expected: [Int64] = limit == nil ? [day, 7 * day, 30 * day] + : limit == 2 * day ? [day] : [day, 7 * day, 30 * day, 90 * day, 365 * day] + #expect(w.access.invitationLengths == expected) + let unlocks = w.keyring.unlocks + let draft = InvitationDraft() + let invalid = Grant(scopes: ["ftw.live.read"], ops: [], caps: Caps(), lengthMs: (limit ?? 30 * day) + day) + let row = await w.during { + await w.access.createInvitation(draft, home: "abcdefghijklmnopqrstuvwx23", label: "Too long", grant: invalid, claimSeconds: 600) + } + #expect(row == nil && draft.request == nil) + #expect(w.keyring.unlocks == unlocks) + let valid = Grant(scopes: invalid.scopes, ops: [], caps: Caps(), lengthMs: try #require(expected.last)) + let accepted = await w.during { + await w.access.createInvitation(draft, home: "abcdefghijklmnopqrstuvwx23", label: "Fits", grant: valid, claimSeconds: 600) + } + #expect(accepted?.state == "pending", "\(w.access.help ?? "")") + } + } + + @Test(arguments: ["cap", "claimUntilMs"]) + func definiteInvitationRefusalUnlocksTheFormForANewApproval(reason: String) async throws { + let w = await world(seed: false) + let home = "abcdefghijklmnopqrstuvwx23" + let day: Int64 = 86_400_000 + let valid = Grant(scopes: ["ftw.live.read"], ops: [], caps: Caps(), lengthMs: day) + _ = await w.during { await w.access.createInvitation(InvitationDraft(), home: home, label: "Register", grant: valid, claimSeconds: 600) } + let draft = InvitationDraft() + let tooLong = Grant(scopes: valid.scopes, ops: [], caps: Caps(), lengthMs: 365 * day) + let at = Int64(w.scheduler.nowMs) + var old = InvitationCreate(id: draft.id, home: home, label: "Old request", hash: InvitationWire.hash(home: home, secret: draft.secret), + grant: tooLong, claimUntilMs: at + 600_000, expiresAtMs: at + tooLong.lengthMs, atMs: at) + if reason == "claimUntilMs" { + old.atMs = at - 601_000 + old.claimUntilMs = at - 1_000 + old.expiresAtMs = at + day + } + old.sig = try #require(try w.keyring.existing()).sign(try old.signedBytes(AccessModel.demoFor)) + draft.request = old + w.box.access.longestMs = nil + let refused = await w.during { await w.access.createInvitation(draft, home: home, label: "Ignored", grant: tooLong, claimSeconds: 600) } + #expect(refused == nil && draft.receipt == nil && draft.request == nil) + let unlocks = w.keyring.unlocks + let accepted = await w.during { await w.access.createInvitation(draft, home: home, label: "Shorter", grant: valid, claimSeconds: 600) } + #expect(accepted?.state == "pending") + #expect(draft.request?.label == "Shorter" && draft.request != old) + #expect(w.keyring.unlocks == unlocks + 1) + } + @Test func invitationIsSignedAndAnExactRetryKeepsItsReceipt() async throws { let w = await world(seed: false) let draft = InvitationDraft()