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
5 changes: 5 additions & 0 deletions .changeset/ftw-cloud-release-key.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch

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 Mark the Cloud activation as a minor release

When the Version Packages workflow consumes this changeset, it will publish the first release in which an owner can enable FTW Cloud as a patch. That is a visible new capability, not a fix, so this entry must use minor and receive the required owner approval; otherwise the release version understates the user-visible change.

AGENTS.md reference: AGENTS.md:L164-L168

Useful? React with 👍 / 👎.

---

Release builds know FTW Cloud at `wss://cloud.ftw.energy` by the service's public key, so an owner can turn FTW Cloud on. Nothing changes until they do. The box checks every message from the service against that key and refuses a service that signs with any other.
2 changes: 1 addition & 1 deletion .changeset/ftw-cloud-uplink.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
"ftw": patch
---

Add the FTW Cloud uplink, off unless an owner turns it on. `GET /api/cloud` shows the grant and the bytes sent in the last 24 hours, `GET /api/cloud/preview` shows the exact bytes that leave the house, and `POST` and `DELETE /api/cloud/grant` turn it on and off. Release builds name no FTW Cloud service yet, so the box refuses a grant and never dials. Backups no longer include `cloud.key`.
Add the FTW Cloud uplink, off unless an owner turns it on. `GET /api/cloud` shows the grant and the bytes sent in the last 24 hours, `GET /api/cloud/preview` shows the exact bytes that leave the house, and `POST` and `DELETE /api/cloud/grant` turn it on and off. Backups no longer include `cloud.key`.
24 changes: 24 additions & 0 deletions docs/protocol-cloud.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,30 @@ Envelope = { t: text, b: bstr, sig: bstr }
A home id comes only from the key that signed `hello` on this connection,
never from a body field.

### Rotating the service keys

The service key signs every `hello_ok` and every downlink message. A box
trusts only the public halves its build carries (`cloudlink.ServiceKeys`).
Release builds carry one: the key `cloud.ftw.energy` holds in Secrets
Manager as `ftw-cloud/service-key`. To rotate it:

1. Make the new key where only the service can read it, beside the old one.
Put its public half into Core next to the old one, so a release carries
both.
2. Ship that release and wait for boxes to update. Until the switch, every
box checks the service against the old key.
3. Switch the service to the new key. Boxes on the new release accept it.
A box on an older release refuses the service and keeps running on its
own until it updates.
4. Drop the old public half in a later release.

If the key leaks, ship a release with only the new key at once. To stand in
for the service, an attacker would also need the TLS certificate for
`cloud.ftw.energy`.

The service's Noise key rotates the same way through the app, which carries
up to two and tries the next when the service refuses message 1.

## Common body fields

Every body the box sends carries these four fields. The class tables below do
Expand Down
2 changes: 1 addition & 1 deletion go/cmd/ftw/cloud_link.go
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,7 @@ func (c *cloudSession) load() *cloudlink.Link {
}

// caps adds cloud.link to an app session's capabilities when this build can
// run the uplink. A release build names no service and says nothing.
// run the uplink. A build that names no service says nothing.
func (c *cloudSession) caps(caps []string) []string {
if link := c.load(); link != nil && link.Available() {
return append(slices.Clip(caps), appproto.CapCloudLink)
Expand Down
11 changes: 8 additions & 3 deletions go/internal/cloudlink/link.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,14 @@ var (
Endpoint = "wss://cloud.ftw.energy/v1/box"
// ServiceKeys are the service's P-256 public keys, base64url of the
// 65-byte point, comma separated. Up to two, so the key can rotate
// across a release. Empty means no service exists for this build: the
// box refuses a grant and never dials.
ServiceKeys = ""
// across a release (docs/protocol-cloud.md, "Keys and ids"). Empty
// means no service exists for this build: the box refuses a grant and
// never dials.
//
// The one key is cloud.ftw.energy's, in Secrets Manager as
// ftw-cloud/service-key since srcfl/ftw-cloud#3. Only its public half is
// here; a box checks every hello_ok and downlink message against it.
ServiceKeys = "BG6EvBxFzSHPVsQOES6PYaKFo5vHQmte9lJ6D5OBIT9JgcBrJAOxAucdqKXxS76pToQ7orI1Ivsno0OtxbMuliQ"
// DevRootCA is a PEM file of extra roots, for a local service with its
// own certificate. Empty in every release build.
DevRootCA = ""
Expand Down
42 changes: 41 additions & 1 deletion go/internal/cloudlink/link_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -295,14 +295,21 @@ type harness struct {
}

func newHarness(t *testing.T, svc *fakeService) *harness {
t.Helper()
return newHarnessKeys(t, svc, []*ecdsa.PublicKey{&svc.key.PublicKey})
}

// newHarnessKeys is a box that trusts keys; nil means the keys this build
// carries.
func newHarnessKeys(t *testing.T, svc *fakeService, keys []*ecdsa.PublicKey) *harness {
t.Helper()
h := &harness{svc: svc, site: &fakeSite{grid: 1200, state: protocol.SourceLive}, kv: &memKV{},
keyPath: filepath.Join(t.TempDir(), "cloud.key"), pushed: make(chan string, 8)}
link, err := New(Options{
Site: h.site, Roster: fakeRoster{}, KV: h.kv, KeyPath: h.keyPath, Build: "test",
Started: time.Now().Add(-time.Minute), ClockSynced: func() bool { return true },
Notify: func(kind string) { h.pushed <- kind },
Endpoint: svc.endpoint(), ServiceKeys: []*ecdsa.PublicKey{&svc.key.PublicKey},
Endpoint: svc.endpoint(), ServiceKeys: keys,
RootCAs: svc.roots(), Tick: 20 * time.Millisecond,
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
})
Expand Down Expand Up @@ -469,6 +476,39 @@ func TestAServiceThatFailsTheBindingGetsOnlyHello(t *testing.T) {
}
}

// A release build knows FTW Cloud at cloud.ftw.energy by one key. A
// service that answers with any other key hears the box's hello and
// nothing after it, and the box does not call itself connected.
func TestAReleaseBuildTrustsOnlyTheServicesKey(t *testing.T) {
if Endpoint != "wss://cloud.ftw.energy/v1/box" {
t.Fatalf("release endpoint %q", Endpoint)
}
keys, err := parseServiceKeys(ServiceKeys)
if err != nil || len(keys) != 1 {
t.Fatalf("release keys: %d, %v", len(keys), err)
}

svc := newFakeService(t) // its own key, not the release key
h := newHarnessKeys(t, svc, nil)
if !h.link.Available() {
t.Fatal("a release build says FTW Cloud is unavailable")
}
h.grant(t)
time.Sleep(time.Second)
svc.mu.Lock()
dialled := svc.hello != nil
svc.mu.Unlock()
if !dialled {
t.Fatal("the box never said hello, so the key was not what refused it")
}
if got := svc.received(); len(got) != 0 {
t.Fatalf("the box sent %d messages to a service with another key", len(got))
}
if h.link.Status().Connected {
t.Fatal("the link reports connected to a service with another key")
}
}

func TestUnknownDownlinkClosesTheUplink(t *testing.T) {
svc := newFakeService(t)
svc.afterHello = func(conn *websocket.Conn, home string) {
Expand Down
Loading