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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions appleApp/FTW/Access/OfferFlowView.swift
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
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.
/// An assistant's offer, from its consent page to Face ID. A link or the
/// page's QR code opens it at once; with neither, the owner types the code
/// first. Then who is asking, the host before the name it gives itself,
/// what it may read, control as its own switch, off, and for how long.
/// The steps of one offer, pushed over the home for a link or from Access
/// for a typed code. A page, never a sheet: FTW Cloud's first screen or
/// Settings may be up when a link arrives, and a page waits under them.
Expand Down Expand Up @@ -124,6 +124,12 @@ struct OfferReview: View {
}
}
.padding(.vertical, 4)
if let code = flow.pageCode {
LabeledContent(AccessWords.text("Code on the page")) {
Text(code).font(Theme.mono(.body, weight: .semibold)).foregroundStyle(Theme.ink)
}
.accessibilityIdentifier("offer.pageCode")
}
} footer: {
if !offer.verified {
Text(AccessWords.text("FTW Cloud doesn't know this client. Check that the address is the one you expect."))
Expand Down Expand Up @@ -191,9 +197,9 @@ struct OfferReview: View {
}

/// 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.
/// home opens and the link starts again there, 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
Expand Down
12 changes: 11 additions & 1 deletion appleApp/FTW/FTWApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,17 @@ struct FTWApp: App {
// 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)
var demoOffers = SimulatedAccess.offerKeys
#if DEBUG
// For screenshots: `-FTWOfferKeys <kid>:<pub>` pins those keys in the
// demo too, beside its own, so a consent link signed elsewhere opens
// in the simulator as a tapped one would.
if let extra = UserDefaults.standard.string(forKey: "FTWOfferKeys") {
let own = "\(OfferKeys.kid(SimulatedAccess.offerKey.publicKey)):\(Base64url.encode(SimulatedAccess.offerKey.publicKey))"
demoOffers = OfferKeys(pins: [own] + extra.split(separator: ",").map(String.init))
}
#endif
model.demoAccess = (SimulatedKeyring(), demoOffers, SimulatedDesk(), SimulatedAccess.lookup)
// A paired phone paints its cached home before anything else runs.
model.launch()
#if DEBUG
Expand Down
5 changes: 3 additions & 2 deletions appleApp/FTW/Platform/QRScanner.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import AVFoundation
import SwiftUI

/// Reads a pairing QR through the camera. The first code that looks like an
/// FTW link is handed over once; the camera stops at that moment.
/// Reads a pairing QR, or an assistant's consent QR, through the camera.
/// The first code that looks like an FTW link is handed over once; the
/// camera stops at that moment.
struct QRScanner: View {
var onCode: (String) -> Void
@State private var allowed: Bool?
Expand Down
7 changes: 6 additions & 1 deletion appleApp/FTW/UI/RootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ 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.
/// An assistant's consent link: its offer, to Face ID.
case offer(OfferLink)

/// The name in the bar, the same as the screen's own title.
Expand Down Expand Up @@ -120,7 +120,10 @@ struct HomeView: View {
case .savings:
SavingsView(home: home)
case .offer(let link):
// Its own identity, so a newer link in the same
// place gets a new flow, not the old one's state.
OfferFlowView(app: app, flow: OfferFlow(link: link, lookup: app.offerLookup, keys: app.offerKeys))
.id(link)
}
}
.homeBar(app: app, home: home, title: route.title)
Expand All @@ -136,10 +139,12 @@ struct HomeView: View {
}
// 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.
// The newest link wins: an offer page already open goes.
.onChange(of: app.accessLink, initial: true) { _, link in
guard let link, home.access != nil else { return }
app.accessLink = nil
sheet = nil
path.removeAll { if case .offer = $0 { return true } else { return false } }
path.append(.offer(link))
}
// Now and the car read the chargers from one model, so a screen
Expand Down
1 change: 1 addition & 0 deletions appleApp/FTWKit/Sources/FTWKit/Access/AccessWords.swift
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,7 @@ public enum AccessWords {
"Boxen kontrollerar varje anrop, och du kan ta bort åtkomsten när du vill.",
"Done": "Klar",
"Checking the offer": "Kontrollerar erbjudandet",
"Code on the page": "Koden på sidan",
// One grant
"Save with %@": "Spara med %@",
"Remove access": "Ta bort åtkomst",
Expand Down
61 changes: 30 additions & 31 deletions appleApp/FTWKit/Sources/FTWKit/Access/OfferFlow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,21 @@ import Observation
/// One offer from a consent page, from the link or the typed code to Face
/// ID (docs/agents.md in srcfl/ftw, The grant offer).
///
/// A link someone else sent never reaches Face ID on its own: the owner
/// types the code the page in front of them shows, and it must be the
/// offer's. Typed without a link, the code fetches the offer from FTW Cloud,
/// once. Either way the offer is checked against the pinned offer keys
/// before anything is shown, and refused when it is unsigned, expired, for
/// a home this phone does not have, or support from anyone but Sourceful.
/// Approving checks it again, against the home open at that moment and the
/// clock then, from the signed bytes rather than what was shown.
/// A link, tapped or opened by the iPhone Camera from the page's QR code,
/// goes straight to review: the owner reads who asks, its host, whether FTW
/// Cloud knows it and what it asks for, and may compare the page's code.
/// Only with neither link nor QR code does the owner type the code, which
/// fetches the offer from FTW Cloud, once. Either way the offer is checked
/// against the pinned offer keys before anything is shown, and refused when
/// it is unsigned, expired, for a home this phone does not have, or support
/// from anyone but Sourceful. Approving checks it again, against the home
/// open at that moment and the clock then, from the signed bytes rather
/// than what was shown.
@Observable
@MainActor
public final class OfferFlow {
public enum Step: Equatable {
/// Type the code from the page.
/// No link: type the code from the page.
case code
case checking
/// The offer, verified, for the owner to choose from.
Expand All @@ -27,8 +29,8 @@ public final class OfferFlow {
case refused(OfferError)
}

public private(set) var step: Step = .code
/// The code as typed.
public private(set) var step: Step
/// The code as typed, when there is no link.
public var typed = ""
/// Control is a separate switch, off until ticked.
public var control = false
Expand All @@ -43,9 +45,6 @@ public final class OfferFlow {
@ObservationIgnored let lookup: OfferLookup
@ObservationIgnored let keys: OfferKeys
@ObservationIgnored let wallMs: @MainActor () -> Double
/// Opened before the code: a link's offer is checked at once, so a bad
/// one is refused without asking for anything.
@ObservationIgnored private var opened: GrantOffer?
/// The offer as signed, checked again when the owner approves.
@ObservationIgnored private var signed: SignedOffer?
/// Told of the row once the box granted it.
Expand All @@ -56,6 +55,7 @@ public final class OfferFlow {
self.lookup = lookup
self.keys = keys
self.wallMs = wallMs
step = link == nil ? .code : .checking
}

var nowMs: Int64 { Int64(wallMs()) }
Expand All @@ -68,36 +68,32 @@ public final class OfferFlow {
}
}

/// For a link: check its offer before asking for the code.
/// For a link: check its offer and show it, with no code to type.
public func start(home: String?) async {
guard let link, opened == nil, step == .code else { return }
guard !keys.isEmpty else { return refuse(.noPinnedKey) }
guard let link, step == .checking else { return }
do {
let carried = try SignedOffer(base64url: link.offer)
opened = try check(carried, home: home)
signed = carried
} catch let e as OfferError {
refuse(e)
present(try SignedOffer(base64url: link.offer), home: home)
} catch {
refuse(.malformed)
}
}

/// The owner typed the code: compare it with the link's offer, or fetch
/// the offer it names.
/// The code on the page, to compare, for an offer that came by link.
/// A typed code needs no second look.
public var pageCode: String? {
guard link != nil, let offer, !offer.code.isEmpty else { return nil }
return OfferCode.show(offer.code)
}

/// The owner typed the code: fetch the offer it names, once. A second
/// press while the first is out asks nothing: the code works once.
public func submit(home: String?) async {
guard link == nil, step == .code else { return }
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 {
Expand All @@ -117,7 +113,10 @@ public final class OfferFlow {
}

/// An offer that came another way, such as Get help: checked like any.
/// Only before one is shown, so nothing swaps the signed offer under a
/// review or an approval.
public func present(_ given: SignedOffer, home: String?) {
guard step == .code || step == .checking else { return }
do {
let offer = try check(given, home: home)
signed = given
Expand Down
2 changes: 2 additions & 0 deletions appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,8 @@ public enum NodeWords {
"That link is not an FTW pairing code.": "Länken är ingen FTW-kod för parkoppling.",
"That didn't work. Try scanning again.": "Det gick inte. Försök skanna igen.",
"There is no FTW pairing code in that picture. Try a closer screenshot of the QR.": "Bilden har ingen FTW-kod för parkoppling. Prova en närmare skärmbild av QR-koden.",
"That is an assistant's QR code. Connect your box first, then scan it with the iPhone Camera.": "Det är en assistents QR-kod. Anslut boxen först och skanna den sedan med iPhones kamera.",
"The assistant's request opens once your home does.": "Assistentens begäran öppnas när ditt hem öppnas.",

// The status line under Now's title: short words in mono caps.
// LIVE and AS OF are the node view's, above.
Expand Down
19 changes: 19 additions & 0 deletions appleApp/FTWKit/Sources/FTWKit/State/PairModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ public final class PairModel {
public var canOpen: Bool { known != nil && app.connectHelp == nil }

/// Scanned with the camera or pasted: a deliberate act, so it pairs.
/// An assistant's consent page goes where its link goes.
public func pair(_ raw: String) async {
if OfferLink(raw) != nil { return scannedOffer(raw) }
stage = .pairing
message = nil
do {
Expand All @@ -57,6 +59,23 @@ public final class PairModel {
}
}

/// An assistant's consent QR in the pairing scanner, taken as its link:
/// the home this phone opens shows the offer, or keeps it until it can
/// open. With no home there is nothing to grant from.
private func scannedOffer(_ raw: String) {
stage = .intro
guard app.vault.isEnrolled, let home = app.sites.current() else {
message = NodeWords.text("That is an assistant's QR code. Connect your box first, then scan it with the iPhone Camera.")
return
}
app.offer(raw)
if app.connectHelp == nil {
app.paired(home.siteId)
} else {
message = NodeWords.text("The assistant's request opens once your home does.")
}
}

/// Pair with the link that arrived, now that someone said so.
public func acceptOffer() async {
guard let link = app.offeredLink else { return }
Expand Down
19 changes: 19 additions & 0 deletions appleApp/FTWKit/Tests/FTWKitTests/AccessFlowTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,25 @@ import FTWDemo
#expect(keyring.unlocks == 1)
}

/// A consent page's link, tapped or opened from its QR code, reaches the
/// box with one Face ID and no code typed. Approving checks the offer
/// again before it signs.
@Test func aLinkIsApprovedWithNoCode() async throws {
let w = await world(seed: false)
let link = OfferLink(request: "r1", offer: SimulatedAccess.offer(nowMs: w.scheduler.nowMs).base64url)
let flow = OfferFlow(link: link, lookup: OfferLookup(base: URL(string: "https://cloud.test")!) { _ in
Issue.record("a link asked FTW Cloud")
return (Data(), 500)
}, keys: SimulatedAccess.offerKeys, wallMs: { [s = w.scheduler] in s.nowMs })
await flow.start(home: nil)
guard case .review = flow.step else { Issue.record("no review: \(flow.step)"); return }
await w.during { await flow.approve(w.access, home: nil) }
guard case .done(let row) = flow.step else { Issue.record("not granted: \(flow.step) \(w.access.help ?? "")"); return }
#expect(row.principal.client == "Claude")
#expect(w.keyring.unlocks == 1)
#expect(w.box.access.rows.contains { $0.id == row.id })
}

/// A cached list knows no keys: the box's own list decides, and with
/// no answer nothing is signed.
@Test func aCachedListNeverRegistersAKey() async throws {
Expand Down
35 changes: 35 additions & 0 deletions appleApp/FTWKit/Tests/FTWKitTests/AppModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,41 @@ import FTWDemo
#expect(PairModel(app: app).offeredFingerprint != nil)
}

/// The pairing scanner reads an assistant's consent QR as its link: the
/// home this phone opens shows the offer, and nothing pairs. With no
/// home it says to connect the box first.
@Test func aConsentQRInThePairingScannerOpensTheOffer() async throws {
let world = World()
let link = "https://app.ftw.energy/a#r1.b2ZmZXI"
let app = world.app()
app.launch()
let first = PairModel(app: app)
await first.pair(link)
#expect(first.stage == .intro)
#expect(first.message == NodeWords.text("That is an assistant's QR code. Connect your box first, then scan it with the iPhone Camera."))
#expect(app.accessLink == nil)
#expect(world.passkeys.registrations == 0)

let pairing = PairModel(app: app)
let t = Task { await pairing.pair(world.pairingURL) }
await world.run(500)
await t.value
let siteId = try #require(app.site?.siteId)
// Another home listed first: the one this phone opens still wins.
let paired = try #require(app.sites.get(siteId))
app.sites.put(StoredSite(siteId: "other", label: "Cabin", boxStaticKey: Data(repeating: 1, count: 32), rendezvousSecret: nil, pairingCode: nil, lanHint: nil, addedAtMs: 0, lastSeenAtMs: 0))
app.sites.put(paired)
#expect(app.sites.all().first?.siteId == "other")
// Back on the scanner from the home, as "Scan a new pairing QR" is.
app.recovering = true
let again = PairModel(app: app)
await again.pair(link)
#expect(app.accessLink == OfferLink(link))
#expect(!app.needsPairing)
#expect(app.site?.siteId == siteId)
#expect(world.passkeys.registrations == 1)
}

@Test func aConfigureWriteGoesThrough() async throws {
let world = World()
let app = world.app()
Expand Down
10 changes: 6 additions & 4 deletions appleApp/FTWKit/Tests/FTWKitTests/LiveOfferTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ 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:
/// unless FTW_LIVE_OFFER_LINK names the consent page's link. The link goes
/// straight to review; FTW_LIVE_OFFER_CODE, if set, must be the code the
/// review shows:
///
/// FTW_LIVE_OFFER_LINK='https://app.ftw.energy/a#…' FTW_LIVE_OFFER_CODE=ABCD-EFGH \
/// FTW_LIVE_OFFER_KEYS='<kid>:<pub>' swift test --filter LiveOfferTests
Expand All @@ -19,13 +21,13 @@ import Testing
#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
}
if let code = env["FTW_LIVE_OFFER_CODE"] {
#expect(flow.pageCode == OfferCode.normalize(code).map(OfferCode.show))
}
#expect(offer.role == Contract.roleAgent)
#expect(offer.key.count == 32)
#expect(offer.home.isEmpty)
Expand Down
Loading
Loading