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
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,6 @@ jobs:
- name: What a watch can post never panics the service
run: go test -run XXX -fuzz FuzzOnceMessage -fuzztime 30s ./internal/appsession
- name: Builds for the host
run: GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o /dev/null ./cmd/ftwcloudd
run: |
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o /dev/null ./cmd/ftwcloudd
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o /dev/null ./cmd/ftwkeysd
18 changes: 12 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,18 @@ maintains it.

## Invariants

- **FTW Cloud forwards ciphertext it cannot read, and never chooses an
action.** It sends a box only `challenge`, `hello_ok`, `watch`, `resync`,
`purged` and `tun` (`internal/uplink/downlink.go`). Commands go from the app
to the box inside the phone's own Noise session, which rides here as `tun`
under the device id the service's own handshake proved, never one a phone
names.
- **FTW Cloud forwards ciphertext it cannot read. It acts on a home only as
an agent its owner approved, within limits the box enforces.** It sends a
box only `challenge`, `hello_ok`, `watch`, `resync`, `purged` and `tun`
(`internal/uplink/downlink.go`). Commands go from the app to the box inside
the phone's own Noise session, which rides here as `tun` under the device
id the service's own handshake proved, never one a phone names. An agent's
requests ride the agent's own Noise session the same way, and the box
admits it only while its signed roster lists the agent's key.
- **Agent keys live only in `ftwkeysd`** (`internal/keys`, `cmd/ftwkeysd`). It
answers on a local unix socket, seals keys at rest under KMS, unseals a key
only to open one session, and logs no key. `ftwcloudd` never holds an
agent's static key or session keys. Keep it small: nothing else goes in.
- **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 one other way in is `store.Seed`, called
Expand Down
18 changes: 14 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,25 @@ COPY . .
# No cgo: the SQLite driver is pure Go, so the binary runs on a static base.
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath \
-ldflags "-s -w -X github.com/srcfl/ftw-cloud/internal/server.Revision=${REVISION}" \
-o /out/ftwcloudd ./cmd/ftwcloudd
-o /out/ftwcloudd ./cmd/ftwcloudd \
&& CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath -ldflags "-s -w" \
-o /out/ftwkeysd ./cmd/ftwkeysd
RUN mkdir -p /out/share && tar -czf /out/share/source.tar.gz \
--exclude=./.git --exclude=./state . \
&& mkdir -p /out/state
&& mkdir -p /out/state /out/keys

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/ftwcloudd /usr/local/bin/ftwcloudd
# The keys part, run as its own container and user (deploy/keys.md).
COPY --from=build /out/ftwkeysd /usr/local/bin/ftwkeysd
COPY --from=build /out/share/source.tar.gz /usr/share/ftw-cloud/source.tar.gz
COPY --from=build --chown=nonroot:nonroot /out/state /state
USER nonroot
# ftwcloudd runs as a user of its own, 65530, not distroless's shared
# nonroot. The host gives its files to the user the image names
# (ftw-cloud-own in deploy/bootstrap.sh).
COPY --from=build --chown=65530:65530 /out/state /state
# Owned by ftwkeysd's user, so a new volume mounted here starts out its own
# and ftwcloudd cannot read it.
COPY --from=build --chown=65533:65533 /out/keys /keys
USER 65530:65530
EXPOSE 443
ENTRYPOINT ["/usr/local/bin/ftwcloudd"]
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,16 @@ to it yet.
the roster drops the device, moves its epoch or runs out, an owner
suspends the device, or the home is purged.

- **The keys part (`ftwkeysd`).** A second process on the same host, as
another user, answering only on a unix socket. It makes each assistant's
agent key (X25519) when an authorization starts, seals it at rest with a
data key from AWS KMS, and keeps it pending until the owner approves. It
unseals a key only to open that agent's Noise session to its box, keeps
the session's transport keys, and seals and opens each frame for
`ftwcloudd`, which carries them as `tun`. Forgetting a key ends its
sessions. [deploy/keys.md](deploy/keys.md) has the shape and how to turn
it on.

Not built yet:

| Step | What | Where |
Expand Down
13 changes: 13 additions & 0 deletions cmd/ftwkeysd/dev_off.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//go:build !ftwclouddev

package main

import "flag"

// A production build has no file key and no local offer key: both keys live
// in KMS.
const devBuild = false

func addFileKeyFlag(*flag.FlagSet) *string { return new(string) }

func devCommand(string, []string) bool { return false }
39 changes: 39 additions & 0 deletions cmd/ftwkeysd/dev_on.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
//go:build ftwclouddev

package main

import (
"flag"
"fmt"
"os"

"github.com/srcfl/ftw-cloud/internal/keys"
)

// A development build seals keys with a local file key and signs offers with
// a local key. Whoever reads the file and the state directory has every
// agent key, so a production build has neither.
const devBuild = true

func addFileKeyFlag(fs *flag.FlagSet) *string {
return fs.String("file-key", "", "a local file key instead of KMS (development only)")
}

// devCommand runs "ftwkeysd filekey -out dev.key".
func devCommand(name string, args []string) bool {
if name != "filekey" {
return false
}
fs := flag.NewFlagSet("filekey", flag.ExitOnError)
out := fs.String("out", "", "file to write the key to")
_ = fs.Parse(args)
if *out == "" {
fmt.Fprintln(os.Stderr, "filekey: -out is required")
os.Exit(1)
}
if err := keys.MakeFileKey(*out); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
return true
}
16 changes: 16 additions & 0 deletions cmd/ftwkeysd/harden_linux.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package main

import (
"log/slog"

"golang.org/x/sys/unix"
)

// harden makes the process not dumpable: no core file, and no ptrace attach
// from another process of the same user. Agent keys and session keys live in
// this process's memory while a session is open.
func harden() {
if err := unix.Prctl(unix.PR_SET_DUMPABLE, 0, 0, 0, 0); err != nil {
slog.Warn("could not turn off core dumps", "err", err)
}
}
6 changes: 6 additions & 0 deletions cmd/ftwkeysd/harden_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
//go:build !linux

package main

// harden has nothing to set outside Linux, where the service runs.
func harden() {}
Loading
Loading