Repository navigation
iPhone: approve assistants and Sourceful support, with Face ID - #76
Conversation
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>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 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".
| 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)) { |
There was a problem hiding this comment.
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 👍 / 👎.
| 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) } |
There was a problem hiding this comment.
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() } |
There was a problem hiding this comment.
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>
…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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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/aand serves a small page where the app is missing.What the owner gets
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 sendsagent.add.agent.requestshows exactly what the assistant asks: the op in words and each argument under its registryagent_argslabel, the agent's reason as its own words (control and bidi characters taken out), and when it lapses. Approve signsagent.confirmover 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.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 asgo/protocol/agents.gobuilds them.ApprovalTestsrebuilds every body inapproval-vectors.json(copied byte for byte from srcfl/ftw master) into the box'ssignedHex, 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,supportfrom any client buthttps://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.OfferFlowkeeps 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 toagent.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_SKEWsigns 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.biometryCurrentSet, kept as the enclave's wrapped handle in the Keychain (FTW/Access/ApprovalKeychain.swift). OneLAContextcovers every signature of one approval and is invalidated when it ends. A cancel, a lockout and a lost key are told apart.KeychainStore.writereports a failed add. The simulator has no enclave, so there a software key behind the same (simulated) Face ID stands in.Session.asksends an agent message on the bulk lane and waits foragent.okor the error with its id.Contract.swiftgains the agent roles, op scopes, op and scope descriptions andagent_args;ContractTestsholds them toprotocol/registry.yaml.AccessWords); a test fails on a label without Swedish. Touch ID phones say Touch ID.What is stubbed or not here
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 roleagent. The demo has a desk of its own.OfferKeys.releasePinsis 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 fromhttps://cloud.ftw.energy/.well-known/ftw-offer-keyhere (and in Core'scloudlink.OfferKeys).agent.requestpush is not routed to Waiting for you; the notices session owns push and its words. Requests show in Settings meanwhile.agent.list.Evidence
swift test: all pass (FTWKitTests and FTWSharedTests). Among themApprovalTests(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) andAccessFlowTestsagainst 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.-tags ftwclouddev -file-key) and a Core box built from srcfl/ftw master b5686357, on an iPhone 17 Pro Max simulator, withFTWUITests/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 MCPstatuscalls through its own Noise session to the box, then Remove: the next call got401 invalid_token. The box's audit log:approval.key.set,agent.add,live.getby the agent,agent.revoke, all by this phone. Details below.The end-to-end run
ftwkeysd -file-keyandftwclouddon 127.0.0.1:18943 with a local CA; offer kid read from/.well-known/ftw-offer-keyand passed as-FTWOfferKeys.cloudlink.Endpoint,ServiceKeysandDevRootCApointing at it, no devices.consent approved, token with the asked scopes,initialize, 16 tools, 12statuscalls answered from the box, then401 invalid_tokenon the first call after Remove.agent.add.🤖 Generated with Claude Code