Skip to content
Draft
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
17 changes: 12 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,29 @@ maintains it.

## Invariants

- **FTW Cloud reads and never controls.** It sends a box only `challenge`,
`hello_ok`, `watch`, `resync` and `purged` (`internal/uplink/downlink.go`).
Commands go from the app to the box over Noise, never through here.
- **The readable path reads and never controls.** On `/v1/box` it sends a
box only `challenge`, `hello_ok`, `watch`, `resync` and `purged`
(`internal/uplink/downlink.go`). Remote commands are a separate, blind
path (`internal/relay`): the service forwards end-to-end Noise frames and
cannot open or originate one. ADR 0008's "commands stay on the Frankfurt
relay" is being revised; the ADR text is in `srcfl/ftw` and is not edited
here.
- **One way to a home:** `store.For(principal)`. No listing, no second lookup,
no principal for staff. A new route needs a row in `server.Routes` and must
pass `TestNoRouteReadsAHome`.
- **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, owners' suspensions, tombstones, APNs
registrations and the hashes of extension tokens. Nothing logs addresses.
registrations and the hashes of extension tokens. The command path's
dead-man file holds sealed push ciphertext, not readings. Nothing logs
addresses, rendezvous handles or frame bodies.
- **A phone can only narrow.** On `/v1/app` it can say whether it is looking,
and an owner can suspend a roster device. On `/v1/app/once` a watch can
read, ask for its token and register for push, and nothing else. The
service admits a phone or a watch by the key its Noise handshake proved,
and only if the box's roster lists it.
and only if the box's roster lists it. A command the phone sends is on
`/r/` or `/x/`, and that path does not admit it to a home.
- **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
45 changes: 36 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
# FTW Cloud

FTW Cloud keeps a signed, readable copy of each home that turns it on, and
serves it to that home's phones. It reads and never controls. The box opens
the connection, picks and signs every message, and can revoke at any time.
serves it to that home's phones. On that path it reads and never controls:
the box opens the connection, picks and signs every message, and can revoke
at any time. A second path on the same server carries remote commands. Those
frames are end-to-end Noise between the app and the box; this service
forwards the bytes and cannot open them, and it does not originate a
command. [ADR 0008](https://github.com/srcfl/ftw/blob/master/docs/adr/0008-ftw-cloud.md)
said commands stay on the Frankfurt relay. That stance is being revised.
The ADR text lives in `srcfl/ftw` and is not changed here.

This is `ftwcloudd`, the service. The decision is
[ADR 0008](https://github.com/srcfl/ftw/blob/master/docs/adr/0008-ftw-cloud.md)
Expand Down Expand Up @@ -81,13 +87,28 @@ to it yet.
token, valid for 7 days; a new one replaces the old. It stops reading when
the roster drops the device, moves its epoch or runs out, an owner
suspends the device, or the home is purged.
- **Remote commands.** The Frankfurt relay's protocol, on this server.
A box dials out to `/r/<epoch>/<handle>/box`, an app to `…/app`, and the
box's exchange socket to `…/exchange`. A watch posts one command at
`/x/<epoch>/<handle>`. Binary frames pass through unchanged and stay in
the room named by the handle. The handle rotates every hour, spread over
five minutes, and `/x/` accepts the previous epoch during that window.
`/deadman` holds the box's sealed "I am gone" row and posts it when the
claim stays down past the deadline. The handle is the capability today's
clients already present, so an app, a watch or a box switches by changing
the origin and nothing else. A box may also send `X-FTW-Box-Key` and
`X-FTW-Box-Sig`; a proof that does not verify is refused, and a join with
neither header is still admitted, because current boxes send none. This
path is not `/v1/box`. That socket's downlink is still only `challenge`,
`hello_ok`, `watch`, `resync` and `purged`.

Not built yet:

| Step | What | Where |
|---|---|---|
| C6 | Tiles | `internal/server` |
| C7–C8 | Statistics | not started |
| — | Anonymous fleet ping (`POST /fleet`) | still only on `relay.ftw.energy`; Core's default URL is not this origin |

## How it holds a home

Expand All @@ -96,6 +117,10 @@ box ──wss, TLS 1.3, signed frames──▶ /v1/box ──▶ store (memory)
│ ◀── /v1/app/once ◀──Noise IK in one POST── watch
│ ◀── /v1/now ◀──token──── widget
└────▶ registry (SQLite: key, roster, tombstones, suspensions, APNs, token hashes)

box ──wss, dials out──▶ /r/…/box ──┐
app ──wss──────────▶ /r/…/app ─────┼──▶ relay (blind; frames not stored) ──▶ the other peer
watch ──POST──────▶ /x/… ─────────┘
```

- **The home id is a hash of the box's key.** It comes from the key that
Expand All @@ -107,13 +132,15 @@ box ──wss, TLS 1.3, signed frames──▶ /v1/box ──▶ store (memory)
principal reads only `live`, `prices` and `hb` within that same scope. There is no
listing and no principal for staff; `TestNoRouteReadsAHome` checks every
route.
- **A phone or a watch can narrow and nothing else.** A phone can say
whether it is looking, and an owner can suspend a device in the roster. A
watch can read, ask for its token and register for push. Commands go 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.
- **A phone or a watch can narrow and nothing else** on the readable path.
A phone can say whether it is looking, and an owner can suspend a device
in the roster. A watch can read, ask for its token and register for push.
A command is Noise between the app and the box. It crosses this process
on `/r/` and `/x/` and is not written into the home.
- **The service sends five things on `/v1/box`.** `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. `ready` and `gone`
are words of the command path, and they are not sent on `/v1/box`.
- **No TLS terminator in front.** Box and service bind the handshake to the
TLS session, so `ftwcloudd` ends TLS itself. A TCP load balancer is fine.
- **Root on the host can read memory.** The privacy notice says so.
Expand Down
17 changes: 14 additions & 3 deletions cmd/ftwcloudd/main.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Command ftwcloudd is FTW Cloud: it keeps a signed, readable copy of each
// home that turned it on, in memory, and serves it to that home's phones. It
// reads and never controls. See README.md.
// home that turned it on, in memory, and serves it to that home's phones.
// On that path it reads and never controls. On /r/, /x/ and /deadman it
// forwards command frames it cannot open. See README.md.
//
// 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
Expand Down Expand Up @@ -35,6 +36,7 @@ import (
"github.com/srcfl/ftw-cloud/internal/appsession"
"github.com/srcfl/ftw-cloud/internal/push"
"github.com/srcfl/ftw-cloud/internal/registry"
"github.com/srcfl/ftw-cloud/internal/relay"
"github.com/srcfl/ftw-cloud/internal/server"
"github.com/srcfl/ftw-cloud/internal/store"
"github.com/srcfl/ftw-cloud/internal/token"
Expand Down Expand Up @@ -100,7 +102,15 @@ func serve(args []string) error {
st.UseSuspensions(reg.Suspended)
up := uplink.New(st, reg, key, log)
tokens := token.New(st, reg, log)
srv := &server.Server{Store: st, Registry: reg, Uplink: up, Tokens: tokens, Log: log}
// The command path is the blind relay. It shares this process and this
// listener, and nothing else: it cannot see the store. Dead-man rows
// sit beside the registry as sealed push ciphertext.
commands := relay.New(relay.Options{
Log: log,
DeadmanPath: filepath.Join(*stateDir, "deadman.json"),
BoxKeys: reg,
})
srv := &server.Server{Store: st, Registry: reg, Uplink: up, Tokens: tokens, Relay: commands, Log: log}

// Alerts: the box's signed events to its phones through Apple, and
// box.unreachable when a box is away. Widgets: a push with no data when
Expand Down Expand Up @@ -179,6 +189,7 @@ func serve(args []string) error {
defer stop()
go srv.Upkeep(ctx, time.Minute)
go pushSvc.Run(ctx)
go commands.Run(ctx)
if *devPush != "" {
if err := serveDevPush(ctx, *devPush, reg, pushSvc, sender, log); err != nil {
return err
Expand Down
31 changes: 30 additions & 1 deletion deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ Every AWS resource carries the tag `Project=ftw-cloud`.
`/opt/ftw-cloud` is checked out at the commit that image was built from and
supplies `compose.yml`.
- The Docker volume `ftw-cloud_ftw-cloud-state` holds the SQLite registry
(home ids, box keys, signed rosters, tombstones) and the ACME cache, on the
(home ids, box keys, signed rosters, tombstones), the ACME cache, and
`deadman.json` (sealed push ciphertext for the command path), on the
encrypted root disk. No readings. No backup.

## The service key
Expand Down Expand Up @@ -196,6 +197,34 @@ keeps the service key for 30 days after `delete-secret`, so a mistaken
teardown can still be undone, and then deletes it. Nothing else holds FTW
Cloud data; the images on `ghcr.io` hold only code.

## Remote commands

The same process answers the Frankfurt relay's paths: `/r/`, `/x/` and
`/deadman`. Frames are the bytes the app and the box already exchange, and
the service cannot read them. This file describes the host. It does not
change DNS, and it does not stop `relay.ftw.energy`. A client switches by
building against `cloud.ftw.energy`. Until the box, the app and the watch do
that, Frankfurt is still where commands go.

Deploying the image that contains the path is a later step, by the owner.
Nothing here changes the running container, its command, its ports or its
secrets.

Before Frankfurt can be turned off:

- Box, webapp and watch builds must dial this origin. The paths and the
frames stay the same; the constants live in `srcfl/ftw` and `srcfl/ftw-webapp`.
- `POST /fleet` is not served here. Core's default fleet URL is
`https://relay.ftw.energy/fleet`, and it is not derived from the command
socket's origin.
- Dead-man rows already stored in Frankfurt are not copied. A box posts its
row again when it connects, so a box that is offline at the cutover has no
switch until it returns. Frankfurt's file stays until that has happened.
- A box-key proof is accepted and not required. Requiring it waits on boxes
sending `X-FTW-Box-Key` and `X-FTW-Box-Sig`.
- ADR 0008 in `srcfl/ftw` still says commands stay on the relay. That text
is the decision record to revise.

## Not here yet

The APNs key ([apns.md](apns.md)) and the Noise key phones dial
Expand Down
3 changes: 2 additions & 1 deletion deploy/compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ services:
# /source publishes it, so anyone can compare it with what CI built.
FTW_CLOUD_IMAGE: ${FTW_CLOUD_IMAGE}
volumes:
# The registry and certificates. No readings are ever written here.
# The registry, certificates, and sealed dead-man rows (deadman.json).
# No readings are ever written here.
- ftw-cloud-state:/state
# Apple's push key, read from Parameter Store into tmpfs at boot by
# deploy/apns.sh. Empty means no alerts, and the service runs.
Expand Down
16 changes: 16 additions & 0 deletions internal/registry/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@
package registry

import (
"bytes"
"database/sql"
"errors"
"fmt"
"time"

"github.com/srcfl/ftw/go/protocol/cloudwire"
_ "modernc.org/sqlite"
)

Expand Down Expand Up @@ -108,6 +110,20 @@ func (r *Registry) Claim(home string, pk []byte, now time.Time) error {
// ErrPurged is a home whose box asked for it to be deleted.
var ErrPurged = errors.New("registry: home was purged")

// RecognizesBoxKey reports whether pk is the key a live home registered.
// A purged home is not recognised. The command path uses this only to
// check a box-key proof; it is not a way to read the home.
func (r *Registry) RecognizesBoxKey(pk []byte) bool {
if len(pk) != cloudwire.PublicKeyBytes {
return false
}
h, ok, err := r.Get(cloudwire.HomeID(pk))
if err != nil || !ok {
return false
}
return bytes.Equal(h.PK, pk)
}

// Get returns one home.
func (r *Registry) Get(home string) (Home, bool, error) {
var h Home
Expand Down
37 changes: 37 additions & 0 deletions internal/registry/registry_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
package registry

import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"errors"
"path/filepath"
"testing"
"time"

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

func open(t *testing.T) *Registry {
Expand Down Expand Up @@ -35,6 +40,38 @@ func TestAHomeIsClaimedOnceAndKeepsItsRoster(t *testing.T) {
}
}

func TestRecognizesBoxKeyIsTheRegisteredKeyOnly(t *testing.T) {
r := open(t)
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
pk, err := key.PublicKey.Bytes()
if err != nil {
t.Fatal(err)
}
if r.RecognizesBoxKey(pk) {
t.Fatal("an unregistered key was recognised")
}
if err := r.Claim(cloudwire.HomeID(pk), pk, time.UnixMilli(1_700_000_000_000)); err != nil {
t.Fatal(err)
}
if !r.RecognizesBoxKey(pk) {
t.Fatal("the registered key was not recognised")
}
other := append([]byte(nil), pk...)
other[len(other)-1] ^= 0xff
if r.RecognizesBoxKey(other) || r.RecognizesBoxKey(nil) || r.RecognizesBoxKey(pk[:8]) {
t.Fatal("a key that is not the home's was recognised")
}
if err := r.Purge(cloudwire.HomeID(pk), nil, time.UnixMilli(1_700_000_000_000)); err != nil {
t.Fatal(err)
}
if r.RecognizesBoxKey(pk) {
t.Fatal("a purged home's key was still recognised")
}
}

// A purged home leaves only a tombstone, and its box cannot claim it again.
func TestAPurgeLeavesATombstone(t *testing.T) {
r := open(t)
Expand Down
96 changes: 96 additions & 0 deletions internal/relay/boxkey.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package relay

import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"math/big"
"net/http"
"strconv"
)

// BoxKeys is the registry's answer to one question: does this P-256 key
// belong to a home that has turned FTW Cloud on? The command path never
// asks it for a reading, a roster or a home id.
type BoxKeys interface {
RecognizesBoxKey(pk []byte) bool
}

// boxProofInput is what a box signs to prove its cloud key on a join.
//
// Existing clients send no proof. A join without one is admitted on the
// rendezvous handle, which is the capability those clients already present
// and what an endpoint-only switch requires. A join that does send one is
// admitted only when the signature verifies. The handle stays in the path
// either way: the proof does not name the room.
func boxProofInput(epoch int64, handle, role string) []byte {
return []byte("ftw.cloud.relay.v1\n" + strconv.FormatInt(epoch, 10) + "\n" + handle + "\n" + role)
}

// proofResult is the outcome of looking at the two optional headers.
type proofResult int

const (
proofAbsent proofResult = iota
proofOK
proofBad
)

// checkBoxProof verifies X-FTW-Box-Key and X-FTW-Box-Sig when the box sends
// them. App joins never carry a cloud key; the headers are ignored there.
func checkBoxProof(r *http.Request, keys BoxKeys, epoch int64, handle, role string) proofResult {
if role == roleApp {
return proofAbsent
}
keyHdr := r.Header.Get("X-FTW-Box-Key")
sigHdr := r.Header.Get("X-FTW-Box-Sig")
if keyHdr == "" && sigHdr == "" {
return proofAbsent
}
if keyHdr == "" || sigHdr == "" {
return proofBad
}
pk, err := base64.RawURLEncoding.DecodeString(keyHdr)
if err != nil {
return proofBad
}
sig, err := base64.RawURLEncoding.DecodeString(sigHdr)
if err != nil || len(sig) != 64 {
return proofBad
}
pub, err := ecdsa.ParseUncompressedPublicKey(elliptic.P256(), pk)
if err != nil {
return proofBad
}
sum := sha256.Sum256(boxProofInput(epoch, handle, role))
if !ecdsa.Verify(pub, sum[:], new(big.Int).SetBytes(sig[:32]), new(big.Int).SetBytes(sig[32:])) {
return proofBad
}
if keys != nil && !keys.RecognizesBoxKey(pk) {
return proofBad
}
return proofOK
}

// signBoxProof is the client half, used by tests and available to a later
// box build. It returns the two header values.
func signBoxProof(key *ecdsa.PrivateKey, epoch int64, handle, role string) (keyHdr, sigHdr string, err error) {
pub, err := key.PublicKey.Bytes()
if err != nil {
return "", "", err
}
sum := sha256.Sum256(boxProofInput(epoch, handle, role))
r, s, err := ecdsa.Sign(rand.Reader, key, sum[:])
if err != nil {
return "", "", err
}
sig := make([]byte, 64)
r.FillBytes(sig[:32])
s.FillBytes(sig[32:])
return base64.RawURLEncoding.EncodeToString(pub), base64.RawURLEncoding.EncodeToString(sig), nil
}

// boxKeyReason is the close reason for a proof the service will not accept.
func boxKeyReason() string { return "box key" }
Loading
Loading