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/retire-frankfurt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": minor
---

Use FTW Cloud for remote app sessions, anonymous daily totals and encrypted browser offline notices. The box no longer connects to the Frankfurt relay. Older web and phone pairings may need a new QR code.
2 changes: 1 addition & 1 deletion config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ app_link:
# default; `enabled: false` turns it off without a restart.
fleet_ping:
enabled: true
# endpoint: https://relay.ftw.energy/fleet
# endpoint: https://cloud.ftw.energy/v1/fleet

# Independently distributed Lua drivers. The signed stable catalog is refreshed
# by default, but refresh never downloads, activates, or restarts a driver.
Expand Down
23 changes: 23 additions & 0 deletions docs/protocol-cloud.md
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,29 @@ The service never forwards these to a phone.
| `pending` | [Pending] | The pairing codes the box shows now, at most one |
| `noise` | bstr, 32 B | The box's X25519 Noise static key, the one in its pairing QR code. FTW Cloud's `keys` part opens an agent's session to it, and trusts it only as signed here |

| `webpush` | [WebPushRow], optional | Box-encrypted browser offline notices. Service only; no browser key leaves the box |

**WebPushRow**

| Field | Type | Meaning |
|---|---|---|
| `id` | text | Subscription id, scoped to this home |
| `dev` | text | Current roster device id |
| `epoch` | uint | Its current grant epoch |
| `endpoint` | text | HTTPS address at a supported browser push vendor |
| `ct` | bstr | RFC 8291 encrypted box.unreachable payload |
| `auth` | text | Box-signed VAPID Authorization, renewed within six hours |

At most 16 rows and 32 KiB of encoded rows fit in one roster. Each ciphertext
has at most 1,024 bytes, endpoint 2,048 bytes and Authorization 512 bytes.

The service sends these only after the box has been away ten minutes, using
the same timer and update grace as APNs. It checks the current device, epoch,
events scope and suspension before each send. It needs no readable-copy grant.
The rows never reach another phone. It stores only a sent marker per outage
and subscription; purge removes it. Removed subscriptions leave the next roster.
A deploy drains and starts no offline alert.

**Device**

| Field | Type | Meaning |
Expand Down
35 changes: 6 additions & 29 deletions go/cmd/ftw/app_link.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import (
"github.com/srcfl/ftw/go/internal/loadpoint"
"github.com/srcfl/ftw/go/internal/localzone"
"github.com/srcfl/ftw/go/internal/mpc"
"github.com/srcfl/ftw/go/internal/notifications"
"github.com/srcfl/ftw/go/internal/prices"
"github.com/srcfl/ftw/go/internal/state"
"github.com/srcfl/ftw/go/internal/telemetry"
Expand Down Expand Up @@ -672,7 +671,6 @@ func startAppLink(
revision *control.Revision,
siteMeterStale time.Duration,
gateway *lateAPI,
webPush *notifications.WebPush,
cloud *cloudSession,
agents *agentPorts,
) (*appenroll.Identity, *appuplink.Uplink, bool, error) {
Expand Down Expand Up @@ -759,23 +757,10 @@ func startAppLink(
// starting, which is true.
caps = append(caps, appproto.CapApiPassthrough)

// The dead man's switch rides on the uplink exactly when there is
// state to hold subscriptions in and a VAPID key to sign for them —
// a row the push service would refuse is not a farewell. With neither
// it produces no rows and the uplink says no extra word.
var deadman appuplink.DeadmanSource
if st != nil && webPush != nil {
deadman = deadmanFromState{st: st, wp: webPush}
}

// One command log for the box. Sessions come and go with their sockets,
// and a retry in the next session must meet the id the last one took.
cmds := appproto.NewCmdLog()

// The box takes a watch through the relay's exchange (ADR 0009), and
// says so, so a phone offers "Add watch".
caps = append(caps, appproto.CapCmdExchange)

// Agents (docs/agents.md): an owner's grants to an assistant, Sourceful
// support or the release check. They reach the box through FTW Cloud's
// tunnel only, and every call meets its grant here.
Expand All @@ -794,7 +779,7 @@ func startAppLink(
// watch's exchange. They share the ports above, which are read-only
// apart from the mode and the command log, and the mode goes through
// control's own validation.
handlerFor := func(exchange bool) appuplink.HandlerBuilder {
handlerFor := func() appuplink.HandlerBuilder {
return func(
sender appproto.Sender,
caller apiauth.Caller,
Expand All @@ -817,7 +802,6 @@ func startAppLink(
Caller: caller,
Grants: grants,
Commands: cmds,
Exchange: exchange,
Access: agents.access,
Agents: agents.state,
Audit: agents.audit,
Expand All @@ -843,18 +827,16 @@ func startAppLink(
}

uplink, err := appuplink.New(appuplink.Options{
Enroll: enroll,
Deadman: deadman,
Handler: handlerFor(false),
ExchangeHandler: handlerFor(true),
Arrivals: arrivalsFor(ctx, tel),
Enroll: enroll,
Handler: handlerFor(),
Arrivals: arrivalsFor(ctx, tel),
})
if err != nil {
return enroll, nil, enabled, err
}
agents.access.Drop = func(key []byte) { uplink.DropSessionsByAppKey(key) }
agents.boxPage = func(by string) (*appproto.Handler, error) {
return handlerFor(false)(discardSender{}, boxPageCaller(by), boxPageGrant{})
return handlerFor()(discardSender{}, boxPageCaller(by), boxPageGrant{})
}
go sweepAgents(ctx, agents)

Expand Down Expand Up @@ -1053,7 +1035,6 @@ func (a *appLinkAPI) RevokeDevice(id string, atTheBox bool) error {
a.agents.agentGone(gone.ID, "app-link")
}
}
var swept int64
for _, gone := range removed {
// Forgetting the key locks the next handshake out; dropping the
// live sessions locks out the one running now. Both, or "remove"
Expand All @@ -1063,21 +1044,17 @@ func (a *appLinkAPI) RevokeDevice(id string, atTheBox bool) error {
}
// A device that may no longer read the house may no longer be told
// about it: its push subscriptions go with the grant, and the
// relay's pre-encrypted farewell rows for them go on the resync.
// Cloud roster's encrypted offline notices for them go on the resync.
if a.st != nil {
n, err := a.st.DeletePushSubscriptionsByDevice(gone.ID)
switch {
case err != nil:
slog.Warn("could not sweep a revoked device's push subscriptions", "device", gone.ID, "err", err)
case n > 0:
slog.Info("swept a revoked device's push subscriptions", "device", gone.ID, "rows", n)
swept += n
}
}
}
if swept > 0 && a.uplink != nil {
go a.uplink.ResyncDeadman()
}
return nil
}

Expand Down
6 changes: 5 additions & 1 deletion go/cmd/ftw/cloud_link.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/srcfl/ftw/go/internal/appenroll"
"github.com/srcfl/ftw/go/internal/appproto"
"github.com/srcfl/ftw/go/internal/appuplink"

"github.com/srcfl/ftw/go/internal/cars"
"github.com/srcfl/ftw/go/internal/cloudlink"
"github.com/srcfl/ftw/go/internal/control"
Expand Down Expand Up @@ -381,7 +382,7 @@ func startCloudLink(
ctx context.Context, statePath, build string, st *state.Store, enroll *appenroll.Identity,
tel *telemetry.Store, ctrl *control.State, ctrlMu *sync.Mutex, revision *control.Revision,
siteMeterStale time.Duration, priceSvc *prices.Service, gateway *lateAPI, notices *cloudNotices,
tunnel *appuplink.Tunnel, carsRT *cars.Runtime,
tunnel *appuplink.Tunnel, carsRT *cars.Runtime, webPush *notifications.WebPush,
) *cloudlink.Link {
if st == nil || enroll == nil {
return nil
Expand All @@ -404,6 +405,9 @@ func startCloudLink(
Notify: notices.notify,
Arrivals: arrivalsFor(ctx, tel),
}
if webPush != nil {
opts.WebPush = webPushFromState{st: st, wp: webPush, enroll: enroll}
}
// Set only with a table: a typed nil would read as a tunnel.
if tunnel != nil {
opts.Tunnel = tunnel
Expand Down
12 changes: 6 additions & 6 deletions go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2404,7 +2404,7 @@ func main() {
appEnroll, appUplink, appLinkEnabled, appLinkErr := startAppLink(
ctx, cfg, identityKeyPath, boxID, Version,
st, tel, mpcSvc, lpMgr, lpController, carsRT, priceSvc, ctrl, ctrlMu, plannerPrefs,
controlRev, appLinkWatchdog, appAPI, webPush, appCloud, agents,
controlRev, appLinkWatchdog, appAPI, appCloud, agents,
)
switch {
case appLinkErr != nil:
Expand All @@ -2419,21 +2419,21 @@ func main() {
// FTW Cloud: the phones' own sessions with the box, carried as
// ciphertext the service cannot read, from the first pairing on; and a
// signed, readable copy of the house, off until an owner turns it on.
// The relay keeps running beside it.
// Both use the one FTW Cloud connection.
var appTunnel *appuplink.Tunnel
if appUplink != nil {
appTunnel = appUplink.Tunnel()
}
cloudLink := startCloudLink(ctx, statePath, Version, st, appEnroll,
tel, ctrl, ctrlMu, controlRev, appLinkWatchdog, priceSvc, appAPI, cloudNotify, appTunnel, carsRT)
tel, ctrl, ctrlMu, controlRev, appLinkWatchdog, priceSvc, appAPI, cloudNotify, appTunnel, carsRT, webPush)
appCloud.bind(cloudLink)
if cloudLink != nil {
agents.home = cloudLink.Home
agents.notice = cloudLink.Notice
}

// The anonymous fleet ping: how many boxes, which versions, which drivers.
// Its separate relay HTTP door keeps daily totals; the encrypted app path
// Its separate Cloud HTTP route keeps daily totals; the encrypted app path
// stays opaque and stores no routed traffic.
// The bundled drivers dir is deliberately not passed: which drivers this
// build ships with is compiled in, not discovered on disk. See
Expand Down Expand Up @@ -2661,8 +2661,8 @@ func main() {
Plugged: lp.PluggedIn, LevelKnown: lp.PluggedIn && lp.SoCSource != "assumed", HasGoal: lp.TargetSoC > 0,
}, true
})
if appUplink != nil {
deps.PushResync = appUplink.ResyncDeadman
if cloudLink != nil {
deps.PushResync = cloudLink.RosterChanged
}
// The loadpoint manager publishes its session latches (completion,
// interruption) on the same bus the engine listens to.
Expand Down
48 changes: 30 additions & 18 deletions go/cmd/ftw/webpush.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ import (
"fmt"
"log/slog"

"github.com/srcfl/ftw/go/internal/appuplink"
"github.com/srcfl/ftw/go/internal/appenroll"
"github.com/srcfl/ftw/go/internal/notifications"
"github.com/srcfl/ftw/go/internal/state"
"github.com/srcfl/ftw/go/protocol/cloudwire"
)

// The web-push glue: state.Store adapted onto the narrow interfaces the
Expand Down Expand Up @@ -37,20 +38,21 @@ func (p pushSubscriptionSource) DeletePushSubscription(id string) error {
return p.st.DeletePushSubscription(id)
}

// deadmanFromState renders and encrypts the box.unreachable farewell, one
// webPushFromState renders and encrypts the box.unreachable farewell, one
// row per stored subscription. The sentence comes from the push catalogue
// and the encryption is the same RFC 8291 construction every live push
// uses — the relay stores it and can read none of it. Each row also carries
// uses — FTW Cloud stores it and can read none of it. Each row also carries
// the VAPID Authorization header the push service will demand, signed here
// by the provider's own key because the relay must never hold that key; the
// uplink calls back every six hours, so the signature is re-made long
// by the provider's own key because the service must never hold that key; the
// roster calls back every six hours, so the signature is re-made long
// before its 24 hours run out.
type deadmanFromState struct {
st *state.Store
wp *notifications.WebPush
type webPushFromState struct {
st *state.Store
wp *notifications.WebPush
enroll *appenroll.Identity
}

func (d deadmanFromState) DeadmanRows() ([]appuplink.DeadmanRow, error) {
func (d webPushFromState) WebPushRows() ([]cloudwire.WebPushRow, error) {
subs, err := d.st.PushSubscriptions()
if err != nil {
return nil, err
Expand All @@ -66,25 +68,35 @@ func (d deadmanFromState) DeadmanRows() ([]appuplink.DeadmanRow, error) {
if err != nil {
return nil, err
}
rows := make([]appuplink.DeadmanRow, 0, len(subs))
rows := make([]cloudwire.WebPushRow, 0, len(subs))
for _, sub := range subs {
device, ok := d.enroll.GrantFor(sub.DeviceID)
if !ok {
continue
}
ct, err := notifications.EncryptForSubscription(sub.P256dh, sub.Auth, payload)
if err != nil {
// One unencryptable subscription must not disarm the rest.
slog.Warn("dead man's switch: skipping a subscription", "id", sub.ID, "err", err)
slog.Warn("dead man's switch: skipping a subscription", "err", err)
continue
}
auth, err := d.wp.DeadmanAuthorization(sub.Endpoint)
if err != nil {
slog.Warn("dead man's switch: skipping a subscription", "id", sub.ID, "err", err)
slog.Warn("dead man's switch: skipping a subscription", "err", err)
continue
}
rows = append(rows, appuplink.DeadmanRow{
SubscriptionID: sub.ID,
Endpoint: sub.Endpoint,
CT: ct,
Auth: auth,
})
row := cloudwire.WebPushRow{
ID: sub.ID, Device: sub.DeviceID, Epoch: device.Epoch,
Endpoint: sub.Endpoint,
CT: ct,
Auth: auth,
}
proposed := append(rows, row)
if cloudwire.ValidateWebPush(proposed) != nil {
slog.Warn("browser offline notice limit reached")
break
}
rows = proposed
}
return rows, nil
}
66 changes: 66 additions & 0 deletions go/cmd/ftw/webpush_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package main

import (
"bytes"
"crypto/ecdh"
"crypto/rand"
"encoding/base64"
"github.com/srcfl/ftw/go/internal/apiauth"
"github.com/srcfl/ftw/go/internal/appenroll"
"github.com/srcfl/ftw/go/internal/notifications"
"github.com/srcfl/ftw/go/internal/nova"
"github.com/srcfl/ftw/go/internal/state"
"github.com/srcfl/ftw/go/protocol/appwire"
"path/filepath"
"testing"
)

func TestBrowserFarewellsStayEncryptedAndFollowTheCurrentDeviceEpoch(t *testing.T) {
dir := t.TempDir()
st, err := state.Open(filepath.Join(dir, "state.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
enroll, err := appenroll.LoadOrCreate(filepath.Join(dir, "app.key"))
if err != nil {
t.Fatal(err)
}
app, _ := appwire.GenerateKeyPair()
code, _, _ := enroll.MintPairingCode(apiauth.RoleOwner, appenroll.PairingTTL)
grant, err := enroll.Authorise(app.Public(), code)
if err != nil {
t.Fatal(err)
}
key, err := nova.LoadOrCreateIdentity(filepath.Join(dir, "webpush.key"))
if err != nil {
t.Fatal(err)
}
wp, err := notifications.NewWebPush(key, pushSubscriptionSource{st: st})
if err != nil {
t.Fatal(err)
}
browser, _ := ecdh.P256().GenerateKey(rand.Reader)
auth := make([]byte, 16)
rand.Read(auth)
_, err = st.SavePushSubscription("https://push.example/opaque", base64.RawURLEncoding.EncodeToString(browser.PublicKey().Bytes()), base64.RawURLEncoding.EncodeToString(auth), grant.DeviceID)
if err != nil {
t.Fatal(err)
}
source := webPushFromState{st: st, wp: wp, enroll: enroll}
rows, err := source.WebPushRows()
if err != nil || len(rows) != 1 {
t.Fatal(rows, err)
}
if rows[0].Device != grant.DeviceID || rows[0].Epoch != grant.Epoch || bytes.Contains(rows[0].CT, []byte("title")) {
t.Fatal("grant or encryption")
}
// A revoke makes the row disappear even before the subscription sweep.
if _, err = enroll.Revoke(grant.DeviceID, appenroll.Presence(true)); err != nil {
t.Fatal(err)
}
rows, err = source.WebPushRows()
if err != nil || len(rows) != 0 {
t.Fatal(rows, err)
}
}
Loading
Loading