diff --git a/.changeset/ftw-cloud-release-key.md b/.changeset/ftw-cloud-release-key.md new file mode 100644 index 00000000..ed069573 --- /dev/null +++ b/.changeset/ftw-cloud-release-key.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +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. diff --git a/.changeset/ftw-cloud-uplink.md b/.changeset/ftw-cloud-uplink.md index f882da72..b7badbd2 100644 --- a/.changeset/ftw-cloud-uplink.md +++ b/.changeset/ftw-cloud-uplink.md @@ -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`. diff --git a/docs/protocol-cloud.md b/docs/protocol-cloud.md index 7b13584c..abd50896 100644 --- a/docs/protocol-cloud.md +++ b/docs/protocol-cloud.md @@ -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 diff --git a/go/cmd/ftw/cloud_link.go b/go/cmd/ftw/cloud_link.go index 9f04f106..fbb5eb03 100644 --- a/go/cmd/ftw/cloud_link.go +++ b/go/cmd/ftw/cloud_link.go @@ -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) diff --git a/go/internal/cloudlink/link.go b/go/internal/cloudlink/link.go index 9e794d05..01cdb7fa 100644 --- a/go/internal/cloudlink/link.go +++ b/go/internal/cloudlink/link.go @@ -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 = "" diff --git a/go/internal/cloudlink/link_test.go b/go/internal/cloudlink/link_test.go index 51bba7d7..7d64849d 100644 --- a/go/internal/cloudlink/link_test.go +++ b/go/internal/cloudlink/link_test.go @@ -295,6 +295,13 @@ 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)} @@ -302,7 +309,7 @@ func newHarness(t *testing.T, svc *fakeService) *harness { 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)), }) @@ -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) {