Skip to content

feat(cloud): release builds dial FTW Cloud with its Noise key - #21

Closed
frahlg wants to merge 12 commits into
mainfrom
claude/cloud-noise-release-key
Closed

frahlg wants to merge 12 commits into
mainfrom
claude/cloud-noise-release-key

Conversation

@frahlg

@frahlg frahlg commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

Release builds of the app now dial FTW Cloud. CloudEndpoint.releaseKeys carries the public half of the Noise key of cloud.ftw.energy:

6-1WbbDzKvbwODSwhUN24MPemfLNRhcbMP2tpU_QAEk

The private half is the SecureString /ftw-cloud/noise-key in Parameter Store (srcfl/ftw-cloud#9, deploy/noise.md). Nobody printed or read it. The app still reads from FTW Cloud only once the box's hello says FTW Cloud is on. A debug build does the same unless -FTWCloudURL and -FTWCloudKey point it at a local service, as before.

Stack

This PR is stacked on #19, the only place CloudEndpoint exists so far. Until #19 lands, the diff against main includes #19; after that, only this commit (appleApp/FTWKit/Sources/FTWKit/Identity/CloudKeys.swift and Tests/FTWKitTests/CloudCarrierTests.swift). #20 needs it too; I will merge main into #20 when this lands.

main is merged in (7907cc9). Its one conflict is a comment in FTWShared/Readings.swift: #15 landed as a squash with "Nothing produces it yet", and #19 says "signed by the box". I kept #19's line. #19 itself has the same conflict against main.

Rotation

The app carries up to two keys and tries the next when the service refuses message 1. Ship the new public half beside the old one, wait for phones to update, switch the service (put-parameter --overwrite, then restart the units), and drop the old half in a later release. The comment on releaseKeys says the same.

Tests

cd appleApp/FTWKit && swift test passes: 195 tests in 26 suites, and 35 in 4. New:

  • aReleaseBuildCarriesTheServicesKey: exactly one 32-byte key, the text above. A release build and a debug build without an override both resolve to wss://cloud.ftw.energy/v1/app with that key.
  • aServiceWithAnotherKeyIsNotTheCloud: a carrier with the release key against a service with another key gets 4403 after message 1, never opens, and holds no snapshot.

/v1/app at cloud.ftw.energy answers 501 until srcfl/ftw-cloud#9 is deployed and noise.sh has run, so a live run against the service waits for that.

🤖 Generated with Claude Code

frahlg and others added 8 commits October 12, 2026 21:00
protocol/registry.yaml is the byte-for-byte copy of
srcfl/ftw-webapp contract/registry.yaml, which now holds scope
ftw.events.read, capability cloud.link, carrier state cloud and the
cloud_scopes and cloud_purposes lists. Contract and CarrierKind follow
so ContractTests still reads both directions. Nothing produces the
cloud carrier yet; that is C3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CloudWire is the phone's half of the FTW Cloud wire: the unpadded lane 1
frame, the {t, b, sig} envelope, the signed input and its ES256 check
with CryptoKit, the home id, the app session's prologue and snapshot,
and the bodies of the classes a phone reads. Bodies decode as Go's
decoder does: unknown keys ignored, a known key of the wrong type
refused.

CloudView ports Go's cloudwire.View: the highest seq per class and boot,
boot replacement only by a later anchored message, rules N and U with
the drift margin and 24-hour lifetime, and the state rules, which can
only make the box's own grades worse. It is a plain Sendable value with
no clock, so an extension can run the same rules. It also keeps the
newest prices and hb for the screens, outside the grades.

The tests replay srcfl/ftw's cloud-vectors.json and
cloud-app-vectors.json, copied unchanged from cloud-c3-core at cc6b36cb,
and all 16 freshness cases in docs/protocol-cloud.md, with the box
signing by the vectors' key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CloudCarrier opens one WebSocket to wss://cloud.ftw.energy/v1/app and
runs Noise IK as initiator to the service's key with the home id in the
prologue. Message 2 brings the copy the service holds in memory; every
change the box sends follows on the same socket. Each envelope is
checked with the box's P-256 key, which the box gives every role in
hello_ok over its own Noise session, and graded by CloudView: never
live without an anchor, a replayed copy ages, a forged one is dropped.

SiteModel reads the cloud first while the home has a grant and the
box session over the relay stays open beside it for commands. The
relay's readings take over when the cloud fails, is out of reach or has
nothing better, under the carrier the readings came by. Rule U is
learned from the box session and kept with the box's FTW Cloud identity
for the next cold start. Prices and cars from the cloud go to the
widgets as carrier cloud.

The first start shows one screen: the FTW app uses FTW Cloud, with a
separate statistics box that is off. An owner's phone turns the grant on
at the box over Noise, once per home; a viewer is told to ask the owner.
About gains an FTW Cloud row to turn it off and show the receipt, and
its privacy line says what FTW Cloud holds when it is on. The address is
fixed and only a debug build may point elsewhere. Release builds carry
no service key yet, so they read through the relay alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A refusal from FTW Cloud is asked again slowly, from 7.5 s up to five
minutes, and at once on wake: a service that just restarted refuses a
phone that reaches it before the box does. A session another session of
the same phone replaced stays closed.

SiteModel logs, once per home, how long after start the relay's first
reading, the cloud's snapshot and its first live arrived (category
reading), beside LaunchClock's first frame.

LiveCloudTests drives a real box and a real ftwcloudd through the
production relay, off unless the environment names them: it times the
three layers against the relay five times each, replays a snapshot an
hour late, revokes a phone with its stream open and closes the cloud
under a running home.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Consent given while FTW Cloud was already on never noted that the home
had been asked, so turning it off in About let the next start turn it
on again. Found in the simulator. ensure now notes an existing grant,
and turning off in About notes it too. The consent sheet takes the
accent tint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review points in SiteModel and the carriers:

- A cold start no longer says "Reconnecting to your box". connect woke
  the carrier it had just made, which reported a reconnect that never
  happened; it now only arms the foreground deadline.
- Carrier.send returns whether the frame went out, and a command on a
  carrier that is not open fails at once with E_NO_ACK instead of after
  the 5 s ack deadline. CloudCarrier's sends say so too.
- The 1 Hz ticker is gone. The clock moves with each reading and, between
  readings, only when something on screen would change: the minute, a
  stream going quiet, a cloud grade lapsing (CloudView.nextGradeChange),
  or a counter the band shows in seconds. minuteMs is there for text that
  needs no finer clock.
- After 20 s without a connection the band says "Can't reach your box ·
  last reading HH:MM" and stops counting.
- Snapshots are sealed and written off the main actor with the cache key
  kept in memory, are skipped when nothing changed, and the background
  writes once.
- A keychain that cannot answer, as before the first unlock, never leads
  to a write: SecureStore.read tells absent from unavailable, and keys
  are made only when absent.
- ProcessClock marks the first live reading beside LaunchClock's first
  frame, and signpost intervals time cold start to live and foreground
  to live.

For flowing numbers: every value carries the time it was read
(fieldAtMs, readingAtMs), a burst within one display frame reaches the
screens as one change, and a source only takes over with a copy at least
as new as the one on screen, so numbers never step back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…start

Found in the simulator: while a burst waited for its display frame, the
band read the newest relay state and said "Live via encrypted relay"
while the card still said "Not live right now". carrier, srcState,
ageMs and isLive now derive from a snapshot taken in the same change as
session, and the cache's claim is retired in that change too. Closing
the cloud publishes at once, and the background settles what the
screens say before it writes.

A cold start passes through inactive on its way to the front; it keeps
its own cold-start-to-live interval instead of starting a foreground
one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CloudEndpoint.releaseKeys carries the public half of cloud.ftw.energy's
Noise key, /ftw-cloud/noise-key in Parameter Store. A release build now
reads from FTW Cloud once the box says FTW Cloud is on; a debug build
does the same unless launch arguments point it at a local service.

Two tests: the release endpoint is cloud.ftw.energy with exactly that
key, and a service that answers with any other key refuses message 1,
so the phone opens no session and holds no snapshot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 13, 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-13T08:55:42.466194Z 57afd80 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.

The one conflict is a comment in Readings.swift: C3's "signed by the
box" is now true, so it stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@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: 57afd80056

ℹ️ 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 +60 to +63
public init(defaults: UserDefaults = .standard) {
self.defaults = defaults
answered = defaults.string(forKey: Keys.answered) == Self.policy
contributeStats = defaults.bool(forKey: Keys.stats)

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 Clear cloud consent when signing out

Persisting both consent and the statistics choice in global UserDefaults lets them survive AppModel.leave(), which clears the keychain, sites, and caches but not these keys. If the phone is handed to another person and paired with a different home, CloudGate skips the consent sheet and ensure can enable FTW Cloud—and statistics when the previous person selected it—without the new person making either choice. Reset these user-scoped defaults during sign-out or bind them to the enrolled identity.

Useful? React with 👍 / 👎.

Comment on lines +31 to +33
private var ensureKey: String {
guard let site = app.site else { return "" }
return "\(site.siteId ?? "")-\(site.heardFromBox)-\(site.cloudOn)-\(consent.answered)"

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 Include cloud capability in the consent task identity

When consent is accepted while the box is .ownerLater, this task runs and ensure returns because cloud.link is absent. If that same SiteModel later reconnects after a box update and the new hello adds cloud.link, every component of ensureKey remains unchanged (heardFromBox was already true and cloudOn remains false), so SwiftUI does not rerun the task and the promised automatic grant never happens until the view is recreated. Include the capability/audience in the task ID.

Useful? React with 👍 / 👎.

Comment on lines +121 to +124
if wait {
write(name, data.byteArray)
} else {
writeLater(name, data.byteArray)

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 Serialize final cache writes with queued writes

A wait: true write bypasses the writing task chain. If a periodic asynchronous snapshot is still sealing when the app backgrounds, suspend() writes the newer snapshot synchronously, but the older queued write can subsequently finish and atomically overwrite it; the next launch then restores stale readings despite the background write being intended as the final state. The synchronous/final path must wait for or invalidate pending writes to the same file.

Useful? React with 👍 / 👎.

Comment on lines +1089 to +1092
socPct: l.socPermille.map { (Double($0) / 10).rounded() },
socSource: l.socSource,
socReadAtMs: l.socAgeS.map { now - Double($0) * 1000 },
powerW: Double(l.powerW),

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 Account for cloud message age in car timestamps

socAgeS is the vehicle reading's age when the box sent the LP message, but this computes its wall time relative to phone receipt. When FTW Cloud delays or replays an otherwise valid LP envelope, the cached widget record therefore makes the vehicle SoC look newer by the entire message delay (for example, a ten-minute-old envelope with socAgeS == 60 is recorded as only one minute old). Derive the timestamp from the authenticated LP header's send time, then subtract socAgeS.

Useful? React with 👍 / 👎.

frahlg and others added 3 commits October 13, 2026 16:10
Two fixes the merge needs: the comment on CarrierKind.cloud keeps C3's
"signed by the box", which is now true, and M2's RecordingCarrier
returns what send returns, as C3's Carrier asks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- AppModel.connect catches PasskeyCancelled. A closed sheet now says the
  unlock was cancelled and offers Try again, instead of saying the key is
  lost. Every help text of ConnectError and VaultError, and the one for a
  failure connect cannot name, goes through AppWords with Swedish. A test
  reads them from the source, so a new one needs Swedish too.
- SiteModel.sharePrices passes the box's time zone to PriceEntry, so the
  price widget reads the clock the way the home does.

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 13, 2026
… join

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
frahlg added a commit that referenced this pull request Oct 13, 2026
…d first (#18, #19, #21, #20)

One squash for four stacked PRs:
- #18: M3/M4, notifications you can act on, delivered through FTW Cloud, with the FTWNotify extension that checks the box's signature.
- #19: C3, the app reads its home from FTW Cloud first and falls back to the relay. It also passes the box's time zone to the shared prices and gives a cancelled unlock its own text, in English and Swedish.
- #21: release builds dial FTW Cloud with its production Noise key.
- #20: C3 + C4, the FTW Cloud session carries the push token.

A live run of a release build against cloud.ftw.energy showed "Live via FTW Cloud" at a median of 262 ms from a cold start, against 611 ms through the relay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@frahlg

frahlg commented Oct 13, 2026

Copy link
Copy Markdown
Member Author

Included in #20, merged as 6a4e2ba (#20 merged this branch in whole).

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