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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ maintains it.
grant serves a copy; SQLite holds only the home id, the box key, the signed
roster, owners' suspensions, tombstones, APNs registrations, the hashes of
extension tokens when each away box's alert is due, browser notice sent markers and
anonymous daily fleet totals. Browser endpoints and box-encrypted offline
anonymous daily fleet and energy totals. Browser endpoints and box-encrypted offline
notices live only in the signed roster. Nothing logs
addresses.
- **A phone can only narrow, or talk to its own box.** On `/v1/app` it can
Expand Down
79 changes: 76 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -452,9 +452,12 @@ with `go list -deps`, so no control code can reach this build.
only (AGPL-3.0-only). See [LICENSE](LICENSE) and
[CONTRIBUTING.md](CONTRIBUTING.md).

Anonymous box reports use `POST /v1/fleet`. It accepts only the seven
`ftw.fleet/1` fields and reduces each report into bounded daily counts for
90 days. It stores no raw report, address or home id. An operator can read
Anonymous box reports use `POST /v1/fleet`, or, from a Core release that
names a relay, `POST /v1/ohttp` (see "Anonymous daily reports" below). The
direct route sees the box's address; it stays for older releases and can go
one stable Core release after the first that pins the relay. Either way it
accepts only the seven `ftw.fleet/1` fields and reduces each report into
bounded daily counts for 90 days. It stores no raw report, address or home id. An operator can read
`/fleet/stats` only on the loopback metrics listener, with
`docker exec ftw-cloud-ftwcloudd-1 ftwcloudd fleet`. These counts measure
reports, not unique boxes.
Expand Down Expand Up @@ -512,3 +515,73 @@ consumers replace the old archive rather than adding both. It needs no hourly
energy activation. Updated Core sends it only after the current project
statistics answer; legacy versions retain their existing daily-report setting. Averaging seven complete UTC days gives an estimate of active
reporting installations, never people or all installations ever made.

## Anonymous daily reports

Statistics from a box are two anonymous daily reports, each sent alone and at
its own random time, never in one message. With FTW Cloud they are part of
Cloud; without it they are on until the owner turns them off on the box. The
rule lives in Core (`cloudlink.SharesStatistics` in srcfl/ftw).

- The fleet report (`ftw.fleet/1`): version, channel, drivers, battery size,
price zone and install age, into the counts `/v1/fleet/public` publishes.
- The energy report (`ftw.energy/1`): once per closed UTC day, 1 to 12 hours
after it closes, the date and six whole-kWh sums (solar generation, grid
import and export, battery charge and discharge, vehicle charge). A day
with a gap in the box's energy ledger is skipped.

They never travel together because, with a fleet of a few boxes, a driver set
and a price zone can point at one home, and energy beside them would then be
that home's.

From a Core release that names a relay, the box seals each report with
Oblivious HTTP (RFC 9458) to the gateway key its release pins, and posts it
to an independent relay ([deploy/ohttp.md](deploy/ohttp.md)). The relay sees
the box's address and a ciphertext of fixed length (1,591 bytes).
`POST /v1/ohttp` (`internal/gateway`) sees the report and the relay's
address. It reads the one padded inner request, chooses the decoder by
schema, adds the report to its own day totals and answers a sealed 204, or
400 for anything else: exact fields, and for energy at most 1,000 kWh each
and a date in the last three closed days. Each request carries a random
report id and the UTC date it was sent. The gateway refuses one sent before
yesterday, and keeps a hash of each id under its date for three days, in a
table with no time or arrival order, so a replay or a retry counts once and
is still answered 204 (RFC 9458, section 6.5). Beyond that it keeps nothing
per report: no row, logged body or address. Each kind has its own rate
budget, spent only by a report that opened and decoded, so junk cannot
silence real reports. A day takes at most 100,000 reports of each kind, and
a body has 10 seconds to arrive.
`GET /.well-known/ohttp-gateway` serves the key configuration for the relay
operator and for anyone checking the pin.

`GET /v1/energy/public` lists the closed dates of the last 30 days, newest
first: the report count and the six sums. A date appears only once no report
may name it any more, so two readings never differ by one report. A date with
fewer than 5 reports shows null, as `/v1/fleet/public` does. So does a sum that
between one and four of them had any of: five reports with one car is that
car's charge. A sum nobody had any of shows 0. Rows, which hold for each sum
how many reports had any of it, go after 30 days. These are reports, not
homes.

`/v1/fleet/public` also lists closed days only, so whoever forwarded a
report, the relay above all, cannot poll the view and see which count it
moved.

Limits, plainly. The floor of five counts reports, not boxes; Fredrik
accepted this on 24 October 2026. A report cannot prove that a real box sent
it. The relay alone can drop the other reports and add fakes to isolate one
home's day, and that shows on our side as missing reports. Anyone else can
do it only on a day when one real home reported. Rejecting replays and
listing closed days only remove the cheap attacks. Anonymous tokens (Privacy
Pass) are the later fix, once the fleet grows or someone shows a need. The
relay and Sourceful together could join reports to addresses by time. This
service also holds readings from homes with the readable copy on, including
48 hours of history tiles, from which the same daily sums could be computed;
the gateway does not do this and keeps nothing to match with, but that is a
rule in code, not one the cryptography enforces.

Open for Fredrik's lawyer: reports that are anonymous to Sourceful through
OHTTP should be outside GDPR, but reading data from the owner's equipment to
make them may still need consent under the ePrivacy rule (Swedish LEK,
chapter 9, section 28). If it does, neither the opt-out for boxes without
Cloud nor the requirement with Cloud holds.
24 changes: 22 additions & 2 deletions cmd/ftwcloudd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// reads and never controls. See README.md.
//
// ftwcloudd keygen -out service.key make the service key; prints its public half
// ftwcloudd keygen -ohttp -out ohttp.key make a gateway key; prints the configuration Core pins
// ftwcloudd keygen -noise -out noise.key make the Noise key phones dial; prints its public half
// ftwcloudd metrics print the running service's counts, from inside its container
// ftwcloudd health https://host/healthz check /healthz over TCP and HTTP/3
Expand Down Expand Up @@ -38,6 +39,7 @@ import (
"github.com/srcfl/ftw-cloud/internal/apns"
"github.com/srcfl/ftw-cloud/internal/appsession"
"github.com/srcfl/ftw-cloud/internal/fleet"
"github.com/srcfl/ftw-cloud/internal/gateway"
"github.com/srcfl/ftw-cloud/internal/keys"
"github.com/srcfl/ftw-cloud/internal/mcp"
"github.com/srcfl/ftw-cloud/internal/metrics"
Expand Down Expand Up @@ -89,6 +91,7 @@ func serve(args []string) error {
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")
ohttpPath := fs.String("ohttp-key", "/run/secrets/ftw-cloud-ohttp.key", "Oblivious HTTP gateway keys, PEM; without them /v1/ohttp 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 Down Expand Up @@ -122,6 +125,14 @@ func serve(args []string) error {
} else if err != nil {
return err
}
// The anonymous reports' gateway takes its keys the same way. Without them
// no report reaches here, and the public totals still answer.
ohttpKeys, err := loadOHTTPKeys(*ohttpPath)
if errors.Is(err, os.ErrNotExist) {
log.Warn("no OHTTP key: the gateway for the anonymous reports is off until one is installed", "path", *ohttpPath)
} else if err != nil {
return err
}
if *statsPolicy != "" && *statsPolicy != cloudwire.StatsPolicy {
return fmt.Errorf("unknown statistics policy")
}
Expand Down Expand Up @@ -164,9 +175,13 @@ func serve(args []string) error {
if err != nil {
return err
}
gatewaySvc, err := gateway.New(reg, ohttpKeys)
if err != nil {
return err
}
tokens := token.New(st, reg, log)
tokens.Shards = shards
srv := &server.Server{Store: st, Registry: reg, Uplink: up, Tokens: tokens, Fleet: fleetSvc, Statistics: usageSvc, Log: log}
srv := &server.Server{Store: st, Registry: reg, Uplink: up, Tokens: tokens, Fleet: fleetSvc, Gateway: gatewaySvc, Statistics: usageSvc, Log: log}

// Assistants: the authorization server, with agent keys in ftwkeysd.
agentKeys := keys.Dial(*keysSocket)
Expand Down Expand Up @@ -386,7 +401,8 @@ func serve(args []string) error {
pub, _ := cloudwire.PublicKeyBytesOf(&key.PublicKey)
log.Info("ftwcloudd listening", "addr", *listen, "h3", srv.AltSvc != "", "quic_sessions", door != nil && door.Box != nil,
"shard", shards.Self(), "shards", shards.Shards(),
"service_key", base64.RawURLEncoding.EncodeToString(pub), "noise_key", base64.RawURLEncoding.EncodeToString(noise.Public()))
"service_key", base64.RawURLEncoding.EncodeToString(pub), "noise_key", base64.RawURLEncoding.EncodeToString(noise.Public()),
"ohttp_keys", ohttpPins(ohttpKeys))
err = httpSrv.ListenAndServeTLS("", "")
if err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
Expand Down Expand Up @@ -549,10 +565,14 @@ 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")
gatewayKey := fs.Bool("ohttp", false, "make an Oblivious HTTP gateway key; prints the key configuration Core pins")
_ = fs.Parse(args)
if *out == "" {
return errors.New("keygen: -out is required")
}
if *gatewayKey {
return keygenOHTTP(*out)
}
if *noise {
x, err := ecdh.X25519().GenerateKey(rand.Reader)
if err != nil {
Expand Down
106 changes: 106 additions & 0 deletions cmd/ftwcloudd/ohttp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package main

import (
"bytes"
"crypto/ecdh"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"os"
"strings"

"github.com/srcfl/ftw/go/protocol/ohttp"
)

// The Oblivious HTTP gateway's keys (deploy/ohttp.md). Core pins the public
// half of each as a key configuration, so a key here that no release pins
// opens nothing.

// ohttpKeyID is the first byte of SHA-256 of the public key, so a key's id
// needs no second file.
func ohttpKeyID(pub []byte) uint8 {
sum := sha256.Sum256(pub)
return sum[0]
}

// loadOHTTPKeys reads the gateway's X25519 keys: PKCS #8 PEM, one block per
// key. A second block lets a key rotate across a Core release.
func loadOHTTPKeys(path string) (*ohttp.Gateway, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("ohttp key: %w", err)
}
// Each key is one whole PEM block, with nothing but blank space around
// it. Anything else, such as a block cut short in a rotation, is refused
// wherever it sits: pem.Decode would skip it and load the rest.
const begin, end = "-----BEGIN PRIVATE KEY-----", "-----END PRIVATE KEY-----"
var keys []ohttp.Key
for rest := bytes.TrimSpace(raw); len(rest) > 0; rest = bytes.TrimSpace(rest) {
stop := bytes.Index(rest, []byte(end))
if !bytes.HasPrefix(rest, []byte(begin)) || stop < 0 || bytes.Count(rest[:stop], []byte("-----BEGIN")) != 1 {
return nil, fmt.Errorf("ohttp key: %s holds data that is not a whole PKCS #8 key", path)
}
block, after := pem.Decode(rest[:stop+len(end)])
if block == nil || len(bytes.TrimSpace(after)) > 0 {
return nil, fmt.Errorf("ohttp key: %s holds data that is not a whole PKCS #8 key", path)
}
rest = rest[stop+len(end):]
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("ohttp key: %w", err)
}
x, ok := parsed.(*ecdh.PrivateKey)
if !ok || x.Curve() != ecdh.X25519() {
return nil, fmt.Errorf("ohttp key: %s holds a key that is not X25519", path)
}
keys = append(keys, ohttp.Key{ID: ohttpKeyID(x.PublicKey().Bytes()), Private: x})
}
if len(keys) == 0 {
return nil, fmt.Errorf("ohttp key: %s holds no key", path)
}
gw, err := ohttp.NewGateway(keys...)
if err != nil {
return nil, fmt.Errorf("ohttp key: %s holds two keys under one id; make a new one", path)
}
return gw, nil
}

// ohttpPins is each key configuration as Core pins it, comma separated, for
// the start line. Empty when the gateway is off.
func ohttpPins(gw *ohttp.Gateway) string {
if gw == nil {
return ""
}
var pins []string
for _, c := range gw.Configs() {
pins = append(pins, base64.RawURLEncoding.EncodeToString(c.Marshal()))
}
return strings.Join(pins, ",")
}

// keygenOHTTP writes a new gateway key, never over an existing file, and
// prints its configuration: the string statsrelay.GatewayKey in srcfl/ftw
// pins. For a rotation, put the new block after the old one in the same
// parameter; ftwcloudd refuses two keys under one id, and then a new key is
// needed.
func keygenOHTTP(path string) error {
x, err := ecdh.X25519().GenerateKey(nil)
if err != nil {
return err
}
der, err := x509.MarshalPKCS8PrivateKey(x)
if err != nil {
return err
}
if err := writePEM(path, "PRIVATE KEY", der); err != nil {
return err
}
gw, err := ohttp.NewGateway(ohttp.Key{ID: ohttpKeyID(x.PublicKey().Bytes()), Private: x})
if err != nil {
return err
}
fmt.Println(ohttpPins(gw))
return nil
}
109 changes: 109 additions & 0 deletions cmd/ftwcloudd/ohttp_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package main

import (
"bytes"
"encoding/base64"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"

"github.com/srcfl/ftw/go/protocol/ohttp"
)

// keygen prints the configuration Core pins, and ftwcloudd loads the same
// key back from the file under the same id.
func TestAGatewayKeyLoadsAsThePinSays(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "ohttp.key")
pin := captureStdout(t, func() error { return keygenOHTTP(path) })
raw, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(pin))
if err != nil {
t.Fatal(err)
}
cfg, err := ohttp.ParseKeyConfig(raw)
if err != nil {
t.Fatal(err)
}
gw, err := loadOHTTPKeys(path)
if err != nil {
t.Fatal(err)
}
if got := gw.Configs(); len(got) != 1 || !bytes.Equal(got[0].Marshal(), raw) || cfg.ID != ohttpKeyID(cfg.PublicKey) {
t.Fatalf("loaded %+v, pinned %+v", got, cfg)
}
if info, _ := os.Stat(path); info.Mode().Perm() != 0o600 {
t.Fatalf("key file mode %v", info.Mode())
}
if err := keygenOHTTP(path); err == nil {
t.Fatal("keygen wrote over a key")
}

// A rotation is a second block in the same file.
second := filepath.Join(dir, "second.key")
captureStdout(t, func() error { return keygenOHTTP(second) })
a, _ := os.ReadFile(path)
b, _ := os.ReadFile(second)
both := filepath.Join(dir, "both.key")
if err := os.WriteFile(both, append(a, b...), 0o600); err != nil {
t.Fatal(err)
}
if gw, err := loadOHTTPKeys(both); err != nil && !strings.Contains(err.Error(), "one id") || err == nil && len(gw.Configs()) != 2 {
t.Fatalf("two keys: %v", err)
}
if pins := ohttpPins(nil); pins != "" {
t.Fatal(pins)
}

// A second block cut short, as a rotation pasted in part leaves it, is
// refused rather than quietly left unloaded.
for name, data := range map[string][]byte{
"cut after a whole key": append(append([]byte{}, a...), b[:len(b)/2]...),
"cut before a whole key": append(append([]byte{}, b[:len(b)/2]...), a...),
"text between keys": append(append(append([]byte{}, a...), []byte("note\n")...), b...),
} {
cut := filepath.Join(dir, "cut.key")
if err := os.WriteFile(cut, data, 0o600); err != nil {
t.Fatal(err)
}
if _, err := loadOHTTPKeys(cut); err == nil || !strings.Contains(err.Error(), "not a whole PKCS #8 key") {
t.Fatalf("%s: %v", name, err)
}
}
}

func TestOnlyX25519GatewayKeysLoad(t *testing.T) {
dir := t.TempDir()
if _, err := loadOHTTPKeys(filepath.Join(dir, "missing.key")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("missing: %v", err)
}
service := filepath.Join(dir, "service.key")
captureStdout(t, func() error { return keygen([]string{"-out", service}) })
empty := filepath.Join(dir, "empty.key")
_ = os.WriteFile(empty, []byte("no key here\n"), 0o600)
for _, p := range []string{service, empty} {
if _, err := loadOHTTPKeys(p); err == nil {
t.Fatalf("%s loaded", p)
}
}
}

func captureStdout(t *testing.T, f func() error) string {
t.Helper()
old := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
os.Stdout = w
runErr := f()
os.Stdout = old
_ = w.Close()
out, _ := io.ReadAll(r)
if runErr != nil {
t.Fatal(runErr)
}
return string(out)
}
Loading
Loading