Files
echolot/server/internal/runs/runs.go
T
mrambossekandClaude Fable 5 4e6f2da3fb
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 36s
server-release / release (push) Successful in 38s
runs: scope by account; app: the PKCE half of signing in
Three phones on one account now produce one history, which is the main reason to
have accounts beyond upload permission. GET /v1/runs returns the account's runs
and says how many devices contributed; fetching and deleting resolve a run id
against the caller's own devices, so an id from another account is not found
rather than fetched from wherever it happens to live.

The rule that needed stating: the empty account is never a group. Devices nobody
has signed in on are unrelated devices that share the absence of an owner, and
matching on "" would let any anonymous device read every other one's runs.
Tested, along with sibling-device access working and cross-account access not.

App side: authorization code with PKCE. The app is a public client - anything
compiled into an APK can be read out with unzip and strings - and the redirect
returns through a custom URI scheme that any app on the device may register, so
an intercepted code is a real risk. PKCE makes a stolen code worthless: it can
only be exchanged by presenting a verifier that never left the process.

A callback whose state does not match is refused before the code is spent and
before any network call, since that is exactly how someone gets a victim to
complete the attacker's sign-in.

Nothing from the IdP is retained. The ID token is used once to prove who is
signing in and then discarded; the device credential authenticates everything
afterwards. No access tokens to store, no refresh tokens to rotate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 19:46:32 +02:00

337 lines
11 KiB
Go

// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Package runs stores uploaded measurement documents.
//
// The premise (and the reason uploads exist at all) is that an engineer runs their own server:
// uploading a run there is how history, diffing and "it was fine last Tuesday" work. That makes
// the storage deliberately dumb — one JSON file per run, on disk, greppable, deletable with rm —
// and puts the interesting policy in two places instead:
//
// - who may upload (Policy.Mode), because a public server is a different proposition from a
// private one; and
// - how much identifying detail the client must strip first (Policy.MinAnonymization), because
// someone measuring against a stranger's server should not be shipping their SSIDs there.
//
// Retention is enforced on every upload, not by a sweeper, so a server left alone does not grow.
package runs
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
// Mode says who may upload.
type Mode string
const (
// ModeOff refuses every upload. The endpoint still answers, with 403 and a reason, so the
// app can say "this server does not accept uploads" instead of showing a network error.
ModeOff Mode = "off"
// ModeAnonymous accepts uploads from any enrolled device. The default: enrollment already
// required an admin-minted token, so "anyone enrolled" is not "anyone".
ModeAnonymous Mode = "anonymous"
// ModeAccount accepts uploads only from a device where somebody has signed in (see
// /v1/account/link). Enrollment alone is not enough: the operator's token admits a device,
// an account attributes it to a person.
ModeAccount Mode = "account"
)
// Anonymization levels, mirroring the client's redaction levels (measurement-schema.md §8).
// Ordered: full < balanced < strict.
const (
AnonFull = "full" // nothing removed — for your own server
AnonBalanced = "balanced" // network names and device identity pseudonymized, neighbours dropped
AnonStrict = "strict" // metrics and findings only
)
func anonRank(level string) int {
switch level {
case AnonStrict:
return 2
case AnonBalanced:
return 1
case AnonFull:
return 0
}
return -1 // unknown
}
// Policy is the operator's upload configuration.
type Policy struct {
Mode Mode `json:"mode"`
MaxBytes int64 `json:"max_bytes"`
RetentionDays int `json:"retention_days"`
MaxRunsPerDevice int `json:"max_runs_per_device"`
MinAnonymization string `json:"min_anonymization"`
}
func DefaultPolicy() Policy {
return Policy{
Mode: ModeAnonymous,
MaxBytes: 4 << 20,
RetentionDays: 90,
MaxRunsPerDevice: 200,
MinAnonymization: AnonFull,
}
}
var (
ErrDisabled = errors.New("uploads are disabled on this server")
ErrNeedAccount = errors.New("this server only accepts uploads from signed-in accounts")
ErrTooLarge = errors.New("run exceeds the server's upload size limit")
ErrNotAnonEnough = errors.New("run is less anonymized than this server requires")
ErrMalformed = errors.New("run is not a measurement document")
)
// Meta is the index entry for one stored run — enough to list history without opening the files.
type Meta struct {
ID string `json:"id"`
DeviceID string `json:"device_id"`
UploadedAt time.Time `json:"uploaded_at"`
StartedAt string `json:"started_at,omitempty"`
Anonymization string `json:"anonymization"`
SizeBytes int64 `json:"size_bytes"`
Verdict string `json:"verdict,omitempty"`
FindingCount int `json:"finding_count"`
}
type Store struct {
mu sync.Mutex
dir string
policy Policy
}
func Open(stateDir string, p Policy) (*Store, error) {
dir := filepath.Join(stateDir, "runs")
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, err
}
return &Store{dir: dir, policy: p}, nil
}
func (s *Store) Policy() Policy { return s.policy }
// Accepts reports whether an upload from this caller would be allowed at all, so callers can
// answer the capability question without a body.
//
// linked says whether a person has signed in on the uploading device. It is the only thing that
// distinguishes ModeAccount from ModeOff — and the reason the check takes an argument at all.
func (s *Store) Accepts(linked bool) error {
switch s.policy.Mode {
case ModeOff:
return ErrDisabled
case ModeAccount:
if !linked {
return ErrNeedAccount
}
}
return nil
}
// Put validates and stores one uploaded document. body is the raw JSON as received: it is stored
// byte-for-byte so what the device signed off on is what sits on disk.
func (s *Store) Put(deviceID string, body []byte, linked bool) (Meta, error) {
if err := s.Accepts(linked); err != nil {
return Meta{}, err
}
if s.policy.MaxBytes > 0 && int64(len(body)) > s.policy.MaxBytes {
return Meta{}, ErrTooLarge
}
// Peek at the parts we index on. Unknown fields are ignored: the server must not become a
// second schema authority that rejects documents a newer client legitimately produces.
var doc struct {
Run struct {
ID string `json:"id"`
StartedAt string `json:"started_at"`
Privacy struct {
Anonymization string `json:"anonymization"`
} `json:"privacy"`
} `json:"run"`
Findings []json.RawMessage `json:"findings"`
Summary struct {
Verdict string `json:"verdict"`
} `json:"summary"`
}
if err := json.Unmarshal(body, &doc); err != nil || doc.Run.ID == "" {
return Meta{}, ErrMalformed
}
level := doc.Run.Privacy.Anonymization
if level == "" {
level = AnonFull // no declaration means nothing was stripped
}
if anonRank(level) < anonRank(s.policy.MinAnonymization) {
return Meta{}, fmt.Errorf("%w: got %q, need at least %q",
ErrNotAnonEnough, level, s.policy.MinAnonymization)
}
id := sanitizeID(doc.Run.ID)
if id == "" {
return Meta{}, ErrMalformed
}
s.mu.Lock()
defer s.mu.Unlock()
devDir := filepath.Join(s.dir, sanitizeID(deviceID))
if err := os.MkdirAll(devDir, 0o700); err != nil {
return Meta{}, err
}
if err := os.WriteFile(filepath.Join(devDir, id+".json"), body, 0o600); err != nil {
return Meta{}, err
}
meta := Meta{
ID: id, DeviceID: deviceID, UploadedAt: time.Now().UTC(),
StartedAt: doc.Run.StartedAt, Anonymization: level,
SizeBytes: int64(len(body)), Verdict: doc.Summary.Verdict,
FindingCount: len(doc.Findings),
}
if err := os.WriteFile(filepath.Join(devDir, id+".meta.json"), mustJSON(meta), 0o600); err != nil {
return Meta{}, err
}
s.enforceRetentionLocked(devDir)
return meta, nil
}
// List returns one device's runs, newest first.
func (s *Store) List(deviceID string) []Meta {
s.mu.Lock()
defer s.mu.Unlock()
return s.listLocked(filepath.Join(s.dir, sanitizeID(deviceID)))
}
// ListFor returns the runs of several devices at once, newest first.
//
// This is what makes an account mean something: three phones signed in to one account produce one
// history, which is the main reason to have accounts beyond upload permission.
func (s *Store) ListFor(deviceIDs []string) []Meta {
s.mu.Lock()
defer s.mu.Unlock()
var out []Meta
for _, id := range deviceIDs {
out = append(out, s.listLocked(filepath.Join(s.dir, sanitizeID(id)))...)
}
sort.Slice(out, func(i, j int) bool { return out[i].UploadedAt.After(out[j].UploadedAt) })
return out
}
// OwnerOf reports which of these devices holds runID, so a caller can be granted access to a run
// belonging to a sibling device without being able to name an arbitrary device.
//
// The search is over an allow-list the caller never supplies directly — it comes from the account
// — so a run id from another account simply is not found.
func (s *Store) OwnerOf(deviceIDs []string, runID string) (string, bool) {
s.mu.Lock()
defer s.mu.Unlock()
for _, id := range deviceIDs {
p := filepath.Join(s.dir, sanitizeID(id), sanitizeID(runID)+".json")
if fi, err := os.Stat(p); err == nil && !fi.IsDir() {
return id, true
}
}
return "", false
}
// Get returns the stored document bytes for one run.
func (s *Store) Get(deviceID, runID string) ([]byte, error) {
s.mu.Lock()
defer s.mu.Unlock()
return os.ReadFile(filepath.Join(s.dir, sanitizeID(deviceID), sanitizeID(runID)+".json"))
}
// Delete removes one run. Missing is not an error: delete is idempotent so a client retrying
// after a dropped response does not see a spurious failure.
func (s *Store) Delete(deviceID, runID string) error {
s.mu.Lock()
defer s.mu.Unlock()
dev, run := sanitizeID(deviceID), sanitizeID(runID)
for _, suffix := range []string{".json", ".meta.json"} {
if err := os.Remove(filepath.Join(s.dir, dev, run+suffix)); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
}
return nil
}
func (s *Store) listLocked(devDir string) []Meta {
entries, err := os.ReadDir(devDir)
if err != nil {
return nil
}
out := make([]Meta, 0, len(entries))
for _, e := range entries {
if !strings.HasSuffix(e.Name(), ".meta.json") {
continue
}
b, err := os.ReadFile(filepath.Join(devDir, e.Name()))
if err != nil {
continue
}
var m Meta
if json.Unmarshal(b, &m) == nil {
out = append(out, m)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].UploadedAt.After(out[j].UploadedAt) })
return out
}
// enforceRetentionLocked drops runs past the age limit, then past the count limit. Age first, so
// a burst of uploads cannot push out runs that are still inside the retention window.
func (s *Store) enforceRetentionLocked(devDir string) {
metas := s.listLocked(devDir)
drop := func(m Meta) {
_ = os.Remove(filepath.Join(devDir, m.ID+".json"))
_ = os.Remove(filepath.Join(devDir, m.ID+".meta.json"))
}
kept := metas[:0]
if s.policy.RetentionDays > 0 {
cutoff := time.Now().Add(-time.Duration(s.policy.RetentionDays) * 24 * time.Hour)
for _, m := range metas {
if m.UploadedAt.Before(cutoff) {
drop(m)
continue
}
kept = append(kept, m)
}
} else {
kept = metas
}
if s.policy.MaxRunsPerDevice > 0 && len(kept) > s.policy.MaxRunsPerDevice {
for _, m := range kept[s.policy.MaxRunsPerDevice:] { // listLocked is newest-first
drop(m)
}
}
}
// sanitizeID keeps ids to characters that cannot escape the directory or collide with the
// .meta.json suffix convention. Ids are uuids and device ids in practice; anything else is
// truncated to nothing and rejected upstream.
func sanitizeID(s string) string {
var b strings.Builder
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
b.WriteRune(r)
}
if b.Len() >= 64 {
break
}
}
return b.String()
}
func mustJSON(v any) []byte {
b, _ := json.Marshal(v)
return b
}