Skip to content
Closed
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
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ maintains it.
- **The home id comes from the key that signed hello** on this connection,
never from a body field. Every later box message must verify with that key.
- **No readings on disk.** Memory holds readings; SQLite holds only the home
id, the box key, the signed roster and tombstones. Nothing logs addresses.
id, the box key, the signed roster, owners' suspensions and tombstones.
Nothing logs addresses.
- **A phone can only narrow.** On `/v1/app` it can say whether it is looking,
and an owner can suspend a roster device. The service admits a phone by the
key its Noise handshake proved, and only if the box's roster lists it.
- **TLS ends in `ftwcloudd`.** The handshake is bound to the TLS session; a
terminating proxy breaks it on purpose. A TCP load balancer is fine.
- **Forward the box's bytes unchanged.** The service cannot sign for a box,
Expand Down
41 changes: 32 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ both in `srcfl/ftw`. Both are proposed. The service runs at
`cloud.ftw.energy` ([deploy/README.md](deploy/README.md)), but no box sends
to it yet.

## What works today (C2)
## What works today (C2, C3)

- A box with FTW Cloud on keeps one WebSocket at `/v1/box`. The service
checks that the box signed `hello` over this TLS session's exporter value,
Expand All @@ -21,34 +21,48 @@ to it yet.
- Each home's newest message of each class lives in memory, as the exact bytes
the box sent. Readings are dropped an hour after the box goes quiet and are
never written to disk.
- The registry (SQLite) holds the home id, the box key and the signed roster.
A box that turns FTW Cloud off gets its home deleted and a signed receipt.
- The registry (SQLite) holds the home id, the box key, the signed roster and
owners' suspensions. A box that turns FTW Cloud off gets its home deleted
and a signed receipt.
- `/source` serves the source of the running build, as AGPL-3.0 asks.
- A phone reads its home at `/v1/app?home=<id>`: Noise IK to the service's
Noise key, with the home id in the prologue. The service admits only a
phone whose key the box listed in its signed roster, and no owner has
suspended. Handshake message 2 carries the snapshot from memory; after it
each new box envelope the phone may read follows on the same socket,
unchanged. A roster change, a suspension, an expired roster or a purge
closes the sessions it touches at once.
- While a phone looks, the service asks the box for one-second updates and
asks again every 50 s; when nobody looks, it stops.
- Limits per home: 16 sessions, one per device, 256 envelopes queued per
phone, and a small message rate from each phone.

Not built yet, with a marked place in the code:

| Step | What | Where |
|---|---|---|
| C3 | The app's Noise session, `/v1/app` | `internal/server`, `store.For` with a `Device` principal |
| C4 | Alerts over APNs, `box.unreachable` | not started |
| C5 | Widget token reads, `/v1/now`, tiles | `internal/server` |
| C7–C8 | Statistics | not started |

## How it holds a home

```
box ──wss, TLS 1.3, signed frames──▶ /v1/box ──▶ store (memory) ◀── store.For(principal)
└────▶ registry (SQLite: key, roster, tombstones)
box ──wss, TLS 1.3, signed frames──▶ /v1/box ──▶ store (memory) ◀── store.For(principal) ◀── /v1/app ◀──Noise IK── phone
└────▶ registry (SQLite: key, roster, tombstones, suspensions)
```

- **The home id is a hash of the box's key.** It comes from the key that
signed `hello`, never from a field anyone sends. A second box cannot claim
a home that is not its own.
- **One way in.** `store.For(principal)` is the only access to a home: a
`Box` principal may write its own home, and a `Device` principal (C3) may
`Box` principal may write its own home, and a `Device` principal may
read the classes its role and the owner's grant both allow. There is no
listing and no principal for staff; `TestNoRouteReadsAHome` checks every
route.
- **A phone can narrow and nothing else.** It can say whether it is looking,
and an owner can suspend a device in the roster. Commands go from the app
to the box over the relay, never through here.
- **The service sends five things.** `challenge`, `hello_ok`, `watch`,
`resync` and `purged` are the whole downlink, and none asks the box to act.
The box closes the uplink on anything else.
Expand All @@ -60,9 +74,10 @@ box ──wss, TLS 1.3, signed frames──▶ /v1/box ──▶ store (memory)

```bash
go build -tags ftwclouddev -o ftwcloudd ./cmd/ftwcloudd
./ftwcloudd keygen -out service.key # prints the public key
./ftwcloudd keygen -out service.key # prints the public key, for Core
./ftwcloudd keygen -noise -out noise.key # prints the Noise public key, for the app
./ftwcloudd -listen 127.0.0.1:18443 -state ./state -service-key service.key \
-tls-cert leaf.pem -tls-key leaf.key
-noise-key noise.key -tls-cert leaf.pem -tls-key leaf.key
```

Build Core from `srcfl/ftw` against it. A release build names no service, so
Expand All @@ -84,6 +99,11 @@ curl -X POST http://127.0.0.1:8080/api/cloud/grant \
curl http://127.0.0.1:8080/api/cloud/preview
```

Without `-noise-key` (or with the file missing) the service still serves
boxes, and `/v1/app` answers 501: phones read through the relay until a
Noise key is installed. A host that should serve phones needs the key as a
second secret beside the service key.

`-tags ftwclouddev` adds `/dev/held?home=<id>` on loopback, which shows what
the service holds so a test can compare it with the preview. Release builds
do not have it.
Expand All @@ -93,6 +113,9 @@ do not have it.
```bash
go vet ./... && go test -race ./...
go test -run XXX -fuzz FuzzBoxMessage -fuzztime 30s ./internal/uplink
go test -run XXX -fuzz FuzzPhoneMessage -fuzztime 30s ./internal/appsession
go test -run TestTheSnapshotArrivesFast -v ./internal/appsession # loopback p50/p95
go test -run XXX -bench BenchmarkSnapshot ./internal/appsession
bash scripts/check-imports.sh
```

Expand Down
95 changes: 80 additions & 15 deletions cmd/ftwcloudd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@
// home that turned it on, in memory, and serves it to that home's phones. It
// reads and never controls. See README.md.
//
// ftwcloudd keygen -out service.key make the service key; prints its public half
// ftwcloudd [flags] serve
// ftwcloudd keygen -out service.key make the service key; prints its public half
// ftwcloudd keygen -noise -out noise.key make the Noise key phones dial; prints its public half
// ftwcloudd [flags] serve
package main

import (
"context"
"crypto/ecdh"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
Expand All @@ -28,10 +30,12 @@ import (
"syscall"
"time"

"github.com/srcfl/ftw-cloud/internal/appsession"
"github.com/srcfl/ftw-cloud/internal/registry"
"github.com/srcfl/ftw-cloud/internal/server"
"github.com/srcfl/ftw-cloud/internal/store"
"github.com/srcfl/ftw-cloud/internal/uplink"
"github.com/srcfl/ftw/go/protocol/appwire"
"github.com/srcfl/ftw/go/protocol/cloudwire"
"golang.org/x/crypto/acme/autocert"
)
Expand All @@ -56,6 +60,7 @@ func serve(args []string) error {
domain := fs.String("domain", "cloud.ftw.energy", "name to get a certificate for")
stateDir := fs.String("state", "/state", "directory for the registry and certificates")
keyPath := fs.String("service-key", "/run/secrets/ftw-cloud-service.key", "service key, PEM")
noisePath := fs.String("noise-key", "/run/secrets/ftw-cloud-noise.key", "Noise key phones dial, PEM; without it /v1/app is off")
certFile := fs.String("tls-cert", "", "certificate file, instead of ACME (local tests)")
keyFile := fs.String("tls-key", "", "certificate key file, with -tls-cert")
email := fs.String("acme-email", "", "contact for the certificate authority")
Expand All @@ -67,14 +72,29 @@ func serve(args []string) error {
if err != nil {
return err
}
// Phones read through /v1/app only with a Noise key. A host without one
// still serves boxes, and the app reads through the relay meanwhile.
noise, err := loadNoiseKey(*noisePath)
noiseOn := err == nil
if errors.Is(err, os.ErrNotExist) {
log.Warn("no Noise key: phones cannot read from FTW Cloud until one is installed", "path", *noisePath)
} else if err != nil {
return err
}
reg, err := registry.Open(filepath.Join(*stateDir, "registry.db"))
if err != nil {
return err
}
defer reg.Close()

st := store.New(nil)
srv := &server.Server{Store: st, Registry: reg, Uplink: uplink.New(st, reg, key, log), Log: log}
st.UseSuspensions(reg.Suspended)
up := uplink.New(st, reg, key, log)
srv := &server.Server{Store: st, Registry: reg, Uplink: up, Log: log}
if noiseOn {
srv.App = appsession.New(st, reg, up, noise, log)
up.OnConnect = srv.App.BoxConnected
}

// TLS ends here and nowhere else: each box binds its handshake to this
// TLS session, so a terminating proxy in front would break it. A plain
Expand Down Expand Up @@ -119,7 +139,8 @@ func serve(args []string) error {
}()

pub, _ := cloudwire.PublicKeyBytesOf(&key.PublicKey)
log.Info("ftwcloudd listening", "addr", *listen, "service_key", base64.RawURLEncoding.EncodeToString(pub))
log.Info("ftwcloudd listening", "addr", *listen, "service_key", base64.RawURLEncoding.EncodeToString(pub),
"noise_key", base64.RawURLEncoding.EncodeToString(noise.Public()))
if err := httpSrv.ListenAndServeTLS("", ""); err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
Expand Down Expand Up @@ -147,15 +168,53 @@ func loadServiceKey(path string) (*ecdsa.PrivateKey, error) {
return key, nil
}

// keygen writes a new service key and prints its public half, which Core
// carries in cloudlink.ServiceKeys.
// loadNoiseKey reads the X25519 key phones run Noise IK against, PKCS #8 PEM.
func loadNoiseKey(path string) (appwire.KeyPair, error) {
raw, err := os.ReadFile(path)
if err != nil {
return appwire.KeyPair{}, fmt.Errorf("noise key: %w", err)
}
block, _ := pem.Decode(raw)
if block == nil || block.Type != "PRIVATE KEY" {
return appwire.KeyPair{}, fmt.Errorf("noise key: %s is not a PKCS #8 private key", path)
}
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return appwire.KeyPair{}, fmt.Errorf("noise key: %w", err)
}
x, ok := parsed.(*ecdh.PrivateKey)
if !ok || x.Curve() != ecdh.X25519() {
return appwire.KeyPair{}, fmt.Errorf("noise key: %s is not an X25519 key", path)
}
return appwire.KeyPairFromSecret(x.Bytes())
}

// keygen writes a new key and prints its public half. The service key's goes
// into Core's cloudlink.ServiceKeys; with -noise, the Noise key's goes into
// the app.
func keygen(args []string) error {
fs := flag.NewFlagSet("keygen", flag.ExitOnError)
out := fs.String("out", "", "file to write the key to")
noise := fs.Bool("noise", false, "make the X25519 key phones dial instead of the P-256 service key")
_ = fs.Parse(args)
if *out == "" {
return errors.New("keygen: -out is required")
}
if *noise {
x, err := ecdh.X25519().GenerateKey(rand.Reader)
if err != nil {
return err
}
der, err := x509.MarshalPKCS8PrivateKey(x)
if err != nil {
return err
}
if err := writePEM(*out, "PRIVATE KEY", der); err != nil {
return err
}
fmt.Println(base64.RawURLEncoding.EncodeToString(x.PublicKey().Bytes()))
return nil
}
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return err
Expand All @@ -164,15 +223,7 @@ func keygen(args []string) error {
if err != nil {
return err
}
f, err := os.OpenFile(*out, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return err
}
if err := pem.Encode(f, &pem.Block{Type: "EC PRIVATE KEY", Bytes: der}); err != nil {
_ = f.Close()
return err
}
if err := f.Close(); err != nil {
if err := writePEM(*out, "EC PRIVATE KEY", der); err != nil {
return err
}
pub, err := cloudwire.PublicKeyBytesOf(&key.PublicKey)
Expand All @@ -182,3 +233,17 @@ func keygen(args []string) error {
fmt.Println(base64.RawURLEncoding.EncodeToString(pub))
return nil
}

// writePEM writes a new key file that only its owner can read, and never over
// an existing one.
func writePEM(path, kind string, der []byte) error {
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return err
}
if err := pem.Encode(f, &pem.Block{Type: kind, Bytes: der}); err != nil {
_ = f.Close()
return err
}
return f.Close()
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ go 1.26.0
require (
github.com/fxamacker/cbor/v2 v2.9.4
github.com/gorilla/websocket v1.5.3
github.com/srcfl/ftw/go/protocol v0.0.0-20261012183740-8a030e2e4805
github.com/srcfl/ftw/go/protocol v0.0.0-20261013055540-c6301a0bb1f7
golang.org/x/crypto v0.57.0
golang.org/x/sys v0.48.0
modernc.org/sqlite v1.59.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/srcfl/ftw/go/protocol v0.0.0-20261012183740-8a030e2e4805 h1:DmeVlx+LhctSDD4sQS1ltcN8QD2EdaKiHtqU1Qn3K1U=
github.com/srcfl/ftw/go/protocol v0.0.0-20261012183740-8a030e2e4805/go.mod h1:VEjr/o0yZijJ1nApuJK+nak6FxJ/fubAUBNOunqNrUU=
github.com/srcfl/ftw/go/protocol v0.0.0-20261013055540-c6301a0bb1f7 h1:+2OArNncGqfjaoBXbaZ2FJXi5S9t6iw7sh6ndAVGmeE=
github.com/srcfl/ftw/go/protocol v0.0.0-20261013055540-c6301a0bb1f7/go.mod h1:VEjr/o0yZijJ1nApuJK+nak6FxJ/fubAUBNOunqNrUU=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
Expand Down
Loading