Skip to content

iPhone: approve assistants and Sourceful support, with Face ID - #76

Merged
frahlg merged 8 commits into
mainfrom
claude/ios-access
Oct 16, 2026
Merged

frahlg merged 8 commits into
mainfrom
claude/ios-access

Conversation

@frahlg

@frahlg frahlg commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

The owner can now let an assistant (Claude, ChatGPT, Grok through MCP at FTW Cloud) or Sourceful support reach the home, from the iPhone, and see and remove that access again. Core decides and checks everything (srcfl/ftw#1642 contract, #1646 enforcement); the app signs the owner's word with a Secure Enclave key behind Face ID.

Paired with srcfl/ftw-webapp#93, which opens the app for https://app.ftw.energy/a and serves a small page where the app is missing.

What the owner gets

  • Settings → Access → Assistants and support. Each grant shows its name, where the client lives, what it may do, when it ends and when it last called. A client's own name is always quoted, with its host (or "Assistant ") beside it; only a support grant, which the box takes from Sourceful's client alone, is called Sourceful support. A change (more, less or longer) is signed with Face ID. Removing it is one swipe or one tap, with no Face ID and no question.
  • An assistant's offer. FTW Cloud's consent page links to https://app.ftw.energy/a#<request>.<offer>, or shows a code the owner types under Add an assistant. The owner types the page's code either way, and the page says to do so only for a request they started themselves. The sheet shows the client's host first, verified by FTW Cloud or not, then the name it gives itself in quotes (set aside when it names Sourceful or FTW), what it may read in plain words, control as its own switch (off) with the ops it would allow, and the length (30 days). An unverified client also needs the owner's "I started this myself and trust " before Face ID. Approve sends agent.add.
  • Waiting for you. An agent.request shows exactly what the assistant asks: the op in words and each argument under its registry agent_args label, the agent's reason as its own words (control and bidi characters taken out), and when it lapses. Approve signs agent.confirm over the request, the agent, the op and the hash of the args as shown. A request with a key, a kind or an op the registry does not name for it can only be declined.
  • Activity. The box's audit log, newest first: who (this phone, another phone, the box page, an assistant by name), what, and the result. A support call shows the person at Sourceful "named by FTW Cloud", since the box cannot check it.
  • Get help. The owner describes the problem; the words go to Sourceful's help desk with the ticket (the phone keeps a copy beside the ticket id, and the box never sees them). The desk answers with Sourceful's signed offer, which is checked like any other and shown as it will be signed: what Sourceful may read and run, control off unless ticked, 7 days. One Face ID grants it, tied to the ticket.

How it is built

  • FTWKit/Access/Approval.swift: deterministic CBOR (RFC 8949 §4.2.1, maps by encoded key) and the four signed arrays exactly as go/protocol/agents.go builds them. ApprovalTests rebuilds every body in approval-vectors.json (copied byte for byte from srcfl/ftw master) into the box's signedHex, checks the SHA-256, verifies the box's signatures and fails on a flipped bit.
  • Offer.swift: the signed offer {o, kid, sig} is checked against pinned offer keys before anything is shown, decoded strictly (an unknown or repeated key is no offer), and refused when unsigned, expired or longer-lived than 10 minutes, malformed, for another home, support from any client but https://cloud.ftw.energy/clients/sourceful-support, release (no release client is pinned), or with a client host that is not plain ASCII letters, digits, hyphens, dots and a port. Only the fragment link is read; a code lookup that FTW Cloud no longer holds open (approved, denied, expired) is answered already.
  • OfferFlow keeps the signed offer and checks it again when the owner approves, against the home open then and the clock then. The home picker starts the link again at the home picked, and is not shown for an offer that names its home or came by code.
  • AccessModel: only the box's own answer to agent.list (keysKnown) decides that this phone has no key there; a cached list never does, and with no answer nothing is signed. The first approval registers the key (approval.key.set) under the same Face ID, after keeping it on the phone. Replacing signs the new key with the old (prevSig) and keeps the new key in a second slot until the box is known to hold it; the next approval tries both, so neither is stranded by a lost answer. E_CLOCK_SKEW signs again with the box's time under the same Face ID. Principal fields are cut to 256 bytes of UTF-8; an unrenewed change sends the end exactly as the box has it; control turned on again asks for what the client asked for when this phone approved it, never fixed maxima.
  • One key per home in the Secure Enclave, biometryCurrentSet, kept as the enclave's wrapped handle in the Keychain (FTW/Access/ApprovalKeychain.swift). One LAContext covers every signature of one approval and is invalidated when it ends. A cancel, a lockout and a lost key are told apart. KeychainStore.write reports a failed add. The simulator has no enclave, so there a software key behind the same (simulated) Face ID stands in.
  • Session.ask sends an agent message on the bulk lane and waits for agent.ok or the error with its id.
  • Contract.swift gains the agent roles, op scopes, op and scope descriptions and agent_args; ContractTests holds them to protocol/registry.yaml.
  • The simulated box answers every agent message and checks each signature as the box does, so the demo (and App Review) can try Access without a box.
  • Words in Swedish and English (AccessWords); a test fails on a label without Swedish. Touch ID phones say Touch ID.

What is stubbed or not here

  • No help desk. There is no ticket backend, so a release build's Get help says "Sourceful can't open a ticket from the app yet" and offers nothing to press (NoSupportDesk). A desk must open the ticket and return FTW Cloud's signed support offer (ticket id, support key); the app verifies it like any offer and keeps the ticket id from it. FTW Cloud's offers today are always role agent. The demo has a desk of its own.
  • No offer key pinned. FTW Cloud's keys part is not running, so OfferKeys.releasePins is empty and a release build opens no offer ("This version of the app can't check offers from FTW Cloud yet"). A debug build pins one with -FTWOfferKeys kid:pub. Once ftwkeysd runs, pin the kid and key from https://cloud.ftw.energy/.well-known/ftw-offer-key here (and in Core's cloudlink.OfferKeys).
  • The agent.request push is not routed to Waiting for you; the notices session owns push and its words. Requests show in Settings meanwhile.
  • Chargers and cars in a request show the agent's opaque id: only the box can tell which one it is. Core could add the names for owners in agent.list.
  • The Secure Enclave path is compiled, not run: the simulator has no enclave. It needs a run on a real iPhone before release.

Evidence

  • swift test: all pass (FTWKitTests and FTWSharedTests). Among them ApprovalTests (vectors), OfferTests (refusals including look-alike hosts and release, fragment-only links, codes, an answered offer, the second check at approval, an unknown client waiting for the owner's word) and AccessFlowTests against the simulated box: the first grant registers the key under one Face ID; a cached list never registers one and a silent box gets no Face ID; a key that cannot be kept is never sent; a replacement whose answer was lost is found on the next approval; a change is signed and an unrenewed one keeps its end; removing needs none; a request is approved over what was shown, and a strange one can only be declined (numbers that are infinite, NaN or huge never crash); no assistant passes for Sourceful; the audit names who.
  • LiveOfferTests (opt-in) opened a real offer from a local ftwcloudd.
  • End to end, run again after the review against srcfl/ftw-cloud main (ftwcloudd and ftwkeysd with -tags ftwclouddev -file-key) and a Core box built from srcfl/ftw master b5686357, on an iPhone 17 Pro Max simulator, with FTWUITests/AccessE2E: pair (with the passkey the simulator offered), FTW Cloud's first screen over the consent link's page, the code, "I started this myself" for the unverified local client, control on, Approve (Face ID matched by the simulator), the agent in the roster, an assistant client making MCP status calls through its own Noise session to the box, then Remove: the next call got 401 invalid_token. The box's audit log: approval.key.set, agent.add, live.get by the agent, agent.revoke, all by this phone. Details below.
  • Screenshots on an iPhone SE (3rd gen) and a 17 Pro Max, light and dark, Swedish and English: claude/ios-access-shots, taken after the review (the README is the index).

The end-to-end run
  • ftw-cloud main: ftwkeysd -file-key and ftwcloudd on 127.0.0.1:18943 with a local CA; offer kid read from /.well-known/ftw-offer-key and passed as -FTWOfferKeys.
  • Core from ftw master, built with cloudlink.Endpoint, ServiceKeys and DevRootCA pointing at it, no devices.
  • An assistant client (Python, stdlib) registered as "Claude", started the authorize request with PKCE, read the consent page's link and code, and waited on the page's event stream.
  • The client: consent approved, token with the asked scopes, initialize, 16 tools, 12 status calls answered from the box, then 401 invalid_token on the first call after Remove.
  • The box's log: "app session open through FTW Cloud" for the agent's session right after agent.add.
  • So a signature made by CryptoKit in the app verified in Go at the box, for the key and for the grant.

🤖 Generated with Claude Code

frahlg and others added 4 commits October 16, 2026 08:09
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 <noreply@anthropic.com>
…et help

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-16T07:18:36.584074Z a0dd718 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a0dd718c0c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +127 to +130
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)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Revalidate expiry immediately before approval

If the review screen remains open past offer.expMs—for example while the app is backgrounded—this method still passes the previously validated offer to AccessModel.approve. The box receives only the derived grant and does not receive the signed offer or its expiry, so it can grant access from an expired consent request. Check the offer's expiry again immediately before invoking the access model and refuse it when it has elapsed.

Useful? React with 👍 / 👎.

Comment thread appleApp/FTW/Access/OfferFlowView.swift Outdated
Comment on lines +175 to +177
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) }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Prevent switching away from a signed offer's home

When an offer has a nonempty signed home, OfferFlow.check verifies it only before the review is shown, but this picker still lets a multi-home owner call app.open for another home. The review then uses app.home?.access, so approval can send the grant to the newly selected box without rechecking offer.home; the box cannot detect this because agent.add contains no offer home. Hide or lock this picker for home-bound offers, or revalidate the selected home before approval.

Useful? React with 👍 / 👎.

working = target
defer { working = nil }
do {
if list == nil { await refresh() }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh cached lists before deciding key registration

On a cold launch after the access list has been persisted, the initializer reconstructs list with cached agents only, leaving keys empty. This non-nil check therefore skips agent.list; a consent link can reach approval without any other refresh, causing registered to treat an already registered key as absent and send approval.key.set without prevSig. The box rejects that as a key replacement, so the first Face ID approval fails and tells the owner to replace the key even though a refresh and retry would work.

Useful? React with 👍 / 👎.

…for the length

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg and others added 3 commits October 16, 2026 10:25
…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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg added a commit that referenced this pull request Oct 16, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@frahlg
frahlg merged commit b6cd9ac into main Oct 16, 2026
1 check passed
@frahlg frahlg mentioned this pull request Oct 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant