From 8bbb3179a1040c87af65f5e89dfbc35da8c2319e Mon Sep 17 00:00:00 2001 From: Reactorcoremeltdown Date: Thu, 29 Oct 2026 15:54:10 +0100 Subject: [PATCH] Add KeepOrg sync server: accounts, nested groups, versioned KDBX sync Why: collaborative KeePass use needs one arbiter of "who wrote last" that never sees a master password, plus access control that follows an org chart. What: stdlib-only Go server. Metadata is a single JSON document with one writer and atomic commit; KDBX payloads are versioned blobs on disk. Access comes from a group tree where a grant on a group reaches every member below it. Login is password+TOTP, invite links, or OIDC through a browser-approved device flow. Pushes carry If-Match and are arbitrated by a per-database merge strategy (server-wins by default). Updates are pushed to clients over SSE. Proved: go test ./internal/... covers RFC 6238 vectors, ACL inheritance and isolation, store rollback, blob pruning, every push verdict, and an end-to-end HTTP pass over login, conflict, force, read-only grants, invite redemption, TOTP enrolment, device login and the event stream. --- .gitignore | 11 + CLAUDE.md | 27 ++ SKILL.md | 152 ++++++++ cmd/keeporgd/main.go | 209 +++++++++++ go.mod | 3 + internal/blob/blob.go | 115 ++++++ internal/blob/blob_test.go | 42 +++ internal/secret/secret.go | 96 +++++ internal/secret/secret_test.go | 31 ++ internal/server/admin.go | 524 ++++++++++++++++++++++++++++ internal/server/auth.go | 384 ++++++++++++++++++++ internal/server/events.go | 94 +++++ internal/server/integration_test.go | 425 ++++++++++++++++++++++ internal/server/oidc.go | 292 ++++++++++++++++ internal/server/pages.go | 84 +++++ internal/server/server.go | 253 ++++++++++++++ internal/server/sync.go | 284 +++++++++++++++ internal/server/sync_test.go | 49 +++ internal/store/acl.go | 143 ++++++++ internal/store/acl_test.go | 90 +++++ internal/store/model.go | 223 ++++++++++++ internal/store/store.go | 136 ++++++++ internal/store/store_test.go | 76 ++++ internal/totp/totp.go | 90 +++++ internal/totp/totp_test.go | 46 +++ versions.mk | 18 + 26 files changed, 3897 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 SKILL.md create mode 100644 cmd/keeporgd/main.go create mode 100644 go.mod create mode 100644 internal/blob/blob.go create mode 100644 internal/blob/blob_test.go create mode 100644 internal/secret/secret.go create mode 100644 internal/secret/secret_test.go create mode 100644 internal/server/admin.go create mode 100644 internal/server/auth.go create mode 100644 internal/server/events.go create mode 100644 internal/server/integration_test.go create mode 100644 internal/server/oidc.go create mode 100644 internal/server/pages.go create mode 100644 internal/server/server.go create mode 100644 internal/server/sync.go create mode 100644 internal/server/sync_test.go create mode 100644 internal/store/acl.go create mode 100644 internal/store/acl_test.go create mode 100644 internal/store/model.go create mode 100644 internal/store/store.go create mode 100644 internal/store/store_test.go create mode 100644 internal/totp/totp.go create mode 100644 internal/totp/totp_test.go create mode 100644 versions.mk diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fbb39e8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +/build/ +/dist/ +*.kdbx +*.deb +*.dmg +*.apk +*.aab +/android/.gradle/ +/android/build/ +/android/app/build/ +/android/local.properties diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..711244e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,27 @@ +# General rules + +Be absolutely frugal about spending remaining resources. Default to short, direct answers. Drop the opening pleasantries and the wrap-up summary. Lead with the answer, add detail only if it's needed, and stop when you're done. + +# Naming and versioning + +Applications shall use `cc.tiredsysadmin..` namespace for their unique identifiers. Versioning is done automatically each change according to semantic versioning policy. Track application versions of each platform accordingly (Android, macOS, Linux, BSD, Windows). + +# Tools and building + +Use Makefile as an entrypoint for building and publishing applications. Each target is atomic, construct dependencies. + +For macOS versions always produce a DMG with a symlink to `/Applications` folder. + +For Linux versions always produce a boilerplate for building .deb packages, also track changelog. + +# Documentation + +Write developer handover documentation explaining architecture and decisions. + +# Publishing + +Android applications shall be published to a private F-Droid repository at rcmd@garage.rcmd.space:/var/lib/fdroid/unsigned, and the application metadata must be published at rcmd@garage.rcmd.space:/var/lib/fdroid/metadata. Publishing is always a manual action, never automatic. + +Android artifact naming scheme for publishing to unsigned directory is always `_.apk` + +MacOS, Linux, and Windows versions keep builds locally. diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..9ac940b --- /dev/null +++ b/SKILL.md @@ -0,0 +1,152 @@ +--- +name: ub-mode +description: > + Follow the reduced principles when writing or changing code. Every change + follows First Make it Work, then Then Make it Right, then Follow Practice. + Use when the user runs /ub-mode or asks for the reduced principles. +--- + +# ub-mode + +Every time you are asked to make a change follow: +* First Make it Work. +* Then Make it Right. +* Follow Practice + +# First Make it Work + +## Laziness Protocol + +* One decision lives in one place. +* Ship the smallest edit that solves the problem. + +## Foundational Thinking + +* Get the data shape right before you write the logic. +* Do first whatever helps every later phase. +* If a concurrent writer would matter, do not share state. + +## TDD + +* Write the failing test before you change the code. +* See it fail for the reason you expect. +* Make the smallest change that makes it pass. +* If an honest test is impractical, do not write a bad one. + +## Redesign From First Principles + +* Do not bolt a new requirement onto the old design. +* Ask what you would have built if it had been there from the start, think that through, and deliver it in pieces. + +## Attack the Premise + +* When two fixes that share one premise fail the same gate, write that premise in one sentence and count which actors hold the imbalance. +* Remove the assignment. +* Do not start another fix until you have done that. + +## Subtract Before You Add + +* Take complexity out before you add anything. + +## Outcome-Oriented Execution + +* Aim at the end state you can check. +* A break in the middle is allowed when you planned it and can undo it. + +## Build the Lever + +* If the work is not obvious at a glance, build the tool that does it. +* Do one unit by hand, then make the tool match that unit. + +## Architecture + +### Model the Domain + +* Put the domain in a structure that makes the illegal states impossible. +* Maintain a glossary of domain terms. +* If the code is already clear and local, leave it. + +### Boundary Discipline + +* Check the data where it enters. +* Inside, trust it. +* Keep the business logic in a pure function. + +### Type System Discipline + +* Make the illegal value impossible to write. +* Parse outside data once, where it comes in. +* Do not silence the compiler. + +### Make Operations Idempotent + +* Running it twice, or running it after a crash halfway through, ends in the same state. + +### Separate Before Serializing Shared State + +* If they do not need the same mutable thing, give each its own. +* If they do, allow one writer, and enforce that in the structure. + +# Then Make it Right + +## Fix Root Causes + +* Reproduce it. +* Fix the cause. +* A guard that only hides the crash is not a fix. + +## Sequence work into verifiable units + +* Finish one small unit and check it. +* Then start the next. + +## Test Behavior, Not Implementation + +* The test calls the code the way its user does and checks the result that user would see. +* If the test would still pass when every function returned nothing, throw it out. + +## Crapper + +* Use the crapper tool +* Reduce each function under CRAP 10 by splitting it, by testing it, or both. +* Consider whether case statements are worth reducing. + +## Dryer + +* Use the dryer tool to eliminate duplication that rank 0.8 or above. + +## Mutator + +* Use the mutator tool +* Cover the uncovered. +* Kill the survivors. +* Delete dead code. + +## Refactoring + +* Pin what the code does. +* Change the structure. +* Prove the behavior did not change. + +# Practice + +## Keep a Log + +* Keep a log of every action you take and the reason you took it. +* Report that log upon completion. + +## Guard the Context Window + +* Keep the main thread small. +* Send the bulk elsewhere, and keep the summary. + +## Worktree Cleanup + +* Find what is safe to remove, confirm nothing still in use needs it, then reclaim the disk. +* Uncommitted work waits for a decision. + +## Commit and Push + +* Commit often, in small ordered commits. +* Each commit says why, what changed, and how you proved it. +* Push to the main line at the end. diff --git a/cmd/keeporgd/main.go b/cmd/keeporgd/main.go new file mode 100644 index 0000000..d5afc06 --- /dev/null +++ b/cmd/keeporgd/main.go @@ -0,0 +1,209 @@ +// Command keeporgd is the KeepOrg synchronization server. +// +// It stores KDBX files as opaque, versioned blobs and arbitrates concurrent +// writes. It never sees a master password and cannot read a database. +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + "cc.tiredsysadmin.keeporg/internal/secret" + "cc.tiredsysadmin.keeporg/internal/server" + "cc.tiredsysadmin.keeporg/internal/store" +) + +// Version is stamped at build time from versions.mk. +var Version = "dev" + +// fileConfig is the on-disk shape of the configuration. It keeps durations as +// plain numbers so the file stays easy to edit by hand. +type fileConfig struct { + Addr string `json:"addr"` + DataDir string `json:"data_dir"` + ExternalURL string `json:"external_url"` + SessionTTLHours int `json:"session_ttl_hours"` + MaxDBSizeMB int64 `json:"max_db_size_mb"` + TLSCert string `json:"tls_cert"` + TLSKey string `json:"tls_key"` + OIDC server.OIDCConfig `json:"oidc"` +} + +func (f fileConfig) toConfig() server.Config { + c := server.Config{ + Addr: f.Addr, DataDir: f.DataDir, ExternalURL: f.ExternalURL, + TLSCert: f.TLSCert, TLSKey: f.TLSKey, OIDC: f.OIDC, + } + if f.SessionTTLHours > 0 { + c.SessionTTL = time.Duration(f.SessionTTLHours) * time.Hour + } + if f.MaxDBSizeMB > 0 { + c.MaxDBSize = f.MaxDBSizeMB << 20 + } + return c +} + +func main() { + log.SetFlags(log.LstdFlags | log.LUTC) + lg := log.New(os.Stderr, "keeporgd: ", log.LstdFlags|log.LUTC) + + var ( + configPath = flag.String("config", "", "path to a JSON configuration file") + addr = flag.String("addr", "", "listen address (overrides the config file)") + dataDir = flag.String("data", "", "data directory (overrides the config file)") + extURL = flag.String("url", "", "externally reachable base URL (overrides the config file)") + showVer = flag.Bool("version", false, "print the version and exit") + writeCfg = flag.String("write-config", "", "write a commented example config to this path and exit") + ) + flag.Parse() + + if *showVer { + fmt.Println("keeporgd", Version) + return + } + if *writeCfg != "" { + if err := writeExampleConfig(*writeCfg); err != nil { + lg.Fatal(err) + } + fmt.Println("wrote", *writeCfg) + return + } + + var fc fileConfig + if *configPath != "" { + b, err := os.ReadFile(*configPath) + if err != nil { + lg.Fatalf("read config: %v", err) + } + if err := json.Unmarshal(b, &fc); err != nil { + lg.Fatalf("parse config: %v", err) + } + } + cfg := fc.toConfig() + if *addr != "" { + cfg.Addr = *addr + } + if *dataDir != "" { + cfg.DataDir = *dataDir + } + if *extURL != "" { + cfg.ExternalURL = *extURL + } + + srv, err := server.New(cfg, lg) + if err != nil { + lg.Fatalf("start: %v", err) + } + if err := bootstrap(srv, lg); err != nil { + lg.Fatalf("bootstrap: %v", err) + } + + done := make(chan struct{}) + srv.StartHousekeeping(done) + + hs := &http.Server{ + Addr: srv.Config().Addr, + Handler: srv.Handler(), + ReadHeaderTimeout: 15 * time.Second, + // No write timeout: the event stream is long lived by design. + IdleTimeout: 120 * time.Second, + } + + go func() { + eff := srv.Config() + lg.Printf("version %s listening on %s (external %s, data %s)", + Version, eff.Addr, eff.ExternalURL, eff.DataDir) + var err error + if eff.TLSCert != "" && eff.TLSKey != "" { + err = hs.ListenAndServeTLS(eff.TLSCert, eff.TLSKey) + } else { + err = hs.ListenAndServe() + } + if err != nil && !errors.Is(err, http.ErrServerClosed) { + lg.Fatalf("listen: %v", err) + } + }() + + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + <-sig + close(done) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := hs.Shutdown(ctx); err != nil { + lg.Printf("shutdown: %v", err) + } + lg.Print("stopped") +} + +// bootstrap creates the first administrator and a root group on an empty +// server, so a fresh deployment is immediately usable. It is idempotent. +func bootstrap(srv *server.Server, lg *log.Logger) error { + password := os.Getenv("KEEPORG_ADMIN_PASSWORD") + generated := false + if password == "" { + password = secret.Token()[:20] + generated = true + } + var created bool + err := srv.Store().Update(func(st *store.State) error { + if len(st.Users) > 0 { + return nil + } + hash, err := secret.Hash(password) + if err != nil { + return err + } + u := &store.User{ID: secret.ID(), Username: "admin", PassHash: hash, + ServerAdmin: true, CreatedAt: time.Now()} + st.Users[u.ID] = u + g := &store.Group{ID: secret.ID(), Name: "everyone", CreatedAt: time.Now()} + st.Groups[g.ID] = g + st.Memberships = append(st.Memberships, + store.Membership{UserID: u.ID, GroupID: g.ID, Role: store.RoleAdmin}) + created = true + return nil + }) + if err != nil || !created { + return err + } + if generated { + lg.Printf("created the first administrator: username %q password %q "+ + "(shown once, change it or enrol TOTP now)", "admin", password) + } else { + lg.Print(`created the first administrator: username "admin" with the password from KEEPORG_ADMIN_PASSWORD`) + } + return nil +} + +func writeExampleConfig(path string) error { + ex := fileConfig{ + Addr: ":8765", + DataDir: "/var/lib/keeporg", + ExternalURL: "https://keeporg.example.com", + SessionTTLHours: 24 * 90, + MaxDBSizeMB: 32, + OIDC: server.OIDCConfig{ + Issuer: "https://id.example.com/realms/main", ClientID: "keeporg", + ClientSecret: "change-me", Scopes: "openid profile email", AutoCreate: false, + }, + } + b, err := json.MarshalIndent(ex, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + return os.WriteFile(path, append(b, '\n'), 0o600) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..dd48345 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module cc.tiredsysadmin.keeporg + +go 1.24 diff --git a/internal/blob/blob.go b/internal/blob/blob.go new file mode 100644 index 0000000..559a8d0 --- /dev/null +++ b/internal/blob/blob.go @@ -0,0 +1,115 @@ +// Package blob stores the KDBX payloads. Each database keeps a numbered +// version history, which makes a rejected push recoverable and a bad save +// reversible. +package blob + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" +) + +// KeepVersions is how many historical versions of each database are retained. +const KeepVersions = 20 + +type Store struct{ dir string } + +// Open prepares dir as a blob store. +func Open(dir string) (*Store, error) { + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, err + } + return &Store{dir: dir}, nil +} + +// SHA256 is the lowercase hex digest used everywhere as a content fingerprint. +func SHA256(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} + +// Put writes one version of a database. Writing the same version twice with +// the same bytes is a no-op, so a retried request is harmless. +func (s *Store) Put(dbID string, version int64, data []byte) error { + dir := filepath.Join(s.dir, dbID) + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + path := s.versionPath(dbID, version) + if old, err := os.ReadFile(path); err == nil && SHA256(old) == SHA256(data) { + return nil + } + tmp := path + ".tmp" + f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return err + } + if _, err := f.Write(data); err != nil { + f.Close() + os.Remove(tmp) + return err + } + if err := f.Sync(); err != nil { + f.Close() + os.Remove(tmp) + return err + } + if err := f.Close(); err != nil { + os.Remove(tmp) + return err + } + return os.Rename(tmp, path) +} + +// Get reads one version of a database. +func (s *Store) Get(dbID string, version int64) ([]byte, error) { + return os.ReadFile(s.versionPath(dbID, version)) +} + +// Versions lists the stored versions of a database, oldest first. +func (s *Store) Versions(dbID string) []int64 { + entries, err := os.ReadDir(filepath.Join(s.dir, dbID)) + if err != nil { + return nil + } + out := []int64{} + for _, e := range entries { + name := e.Name() + if !strings.HasSuffix(name, ".kdbx") { + continue + } + if v, err := strconv.ParseInt(strings.TrimSuffix(name, ".kdbx"), 10, 64); err == nil { + out = append(out, v) + } + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +// Prune removes all but the newest KeepVersions versions. +func (s *Store) Prune(dbID string) error { + vs := s.Versions(dbID) + if len(vs) <= KeepVersions { + return nil + } + for _, v := range vs[:len(vs)-KeepVersions] { + if err := os.Remove(s.versionPath(dbID, v)); err != nil { + return err + } + } + return nil +} + +// Forget deletes every version of a database. +func (s *Store) Forget(dbID string) error { + return os.RemoveAll(filepath.Join(s.dir, dbID)) +} + +func (s *Store) versionPath(dbID string, version int64) string { + return filepath.Join(s.dir, dbID, fmt.Sprintf("%020d.kdbx", version)) +} diff --git a/internal/blob/blob_test.go b/internal/blob/blob_test.go new file mode 100644 index 0000000..9c22bfd --- /dev/null +++ b/internal/blob/blob_test.go @@ -0,0 +1,42 @@ +package blob + +import "testing" + +func TestPutGetIsIdempotentAndVersioned(t *testing.T) { + s, err := Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if err := s.Put("db", 1, []byte("v1")); err != nil { + t.Fatal(err) + } + if err := s.Put("db", 1, []byte("v1")); err != nil { + t.Fatal(err) + } + if err := s.Put("db", 2, []byte("v2")); err != nil { + t.Fatal(err) + } + got, err := s.Get("db", 1) + if err != nil || string(got) != "v1" { + t.Fatalf("get v1 = %q, %v", got, err) + } + if vs := s.Versions("db"); len(vs) != 2 || vs[0] != 1 || vs[1] != 2 { + t.Fatalf("versions = %v", vs) + } +} + +func TestPruneKeepsNewest(t *testing.T) { + s, _ := Open(t.TempDir()) + for i := int64(1); i <= KeepVersions+5; i++ { + if err := s.Put("db", i, []byte{byte(i)}); err != nil { + t.Fatal(err) + } + } + if err := s.Prune("db"); err != nil { + t.Fatal(err) + } + vs := s.Versions("db") + if len(vs) != KeepVersions || vs[0] != 6 { + t.Fatalf("after prune = %v", vs) + } +} diff --git a/internal/secret/secret.go b/internal/secret/secret.go new file mode 100644 index 0000000..2c571cd --- /dev/null +++ b/internal/secret/secret.go @@ -0,0 +1,96 @@ +// Package secret holds the small amount of cryptography the server needs: +// random identifiers, bearer tokens and password verifiers. +package secret + +import ( + "crypto/pbkdf2" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "strconv" + "strings" +) + +// iterations follows the OWASP recommendation for PBKDF2-HMAC-SHA256. +const iterations = 600000 + +// ID returns a short random identifier, safe in URLs and filenames. +func ID() string { return randHex(8) } + +// Token returns a 256-bit bearer token. +func Token() string { return randHex(32) } + +// UserCode returns a short, human-transcribable code for device login. +// The alphabet omits characters that are easy to confuse when read aloud. +func UserCode() string { + const alphabet = "BCDFGHJKLMNPQRSTVWXZ23456789" + b := randBytes(8) + out := make([]byte, 0, 9) + for i, v := range b { + if i == 4 { + out = append(out, '-') + } + out = append(out, alphabet[int(v)%len(alphabet)]) + } + return string(out) +} + +// Hash returns a verifier string for a password. +func Hash(password string) (string, error) { + if password == "" { + return "", errors.New("secret: empty password") + } + salt := randBytes(16) + dk, err := pbkdf2.Key(sha256.New, password, salt, iterations, 32) + if err != nil { + return "", err + } + return fmt.Sprintf("pbkdf2-sha256$%d$%s$%s", iterations, + base64.RawStdEncoding.EncodeToString(salt), + base64.RawStdEncoding.EncodeToString(dk)), nil +} + +// Verify reports whether password matches a verifier produced by Hash. +// An unparsable or empty verifier never matches. +func Verify(verifier, password string) bool { + parts := strings.Split(verifier, "$") + if len(parts) != 4 || parts[0] != "pbkdf2-sha256" { + return false + } + iter, err := strconv.Atoi(parts[1]) + if err != nil || iter < 1000 { + return false + } + salt, err := base64.RawStdEncoding.DecodeString(parts[2]) + if err != nil { + return false + } + want, err := base64.RawStdEncoding.DecodeString(parts[3]) + if err != nil { + return false + } + got, err := pbkdf2.Key(sha256.New, password, salt, iter, len(want)) + if err != nil { + return false + } + return subtle.ConstantTimeCompare(got, want) == 1 +} + +// Equal compares two secrets without leaking their contents through timing. +func Equal(a, b string) bool { + return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 +} + +func randBytes(n int) []byte { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + panic("secret: " + err.Error()) // crypto/rand never fails on supported platforms + } + return b +} + +func randHex(n int) string { return hex.EncodeToString(randBytes(n)) } diff --git a/internal/secret/secret_test.go b/internal/secret/secret_test.go new file mode 100644 index 0000000..cf8a394 --- /dev/null +++ b/internal/secret/secret_test.go @@ -0,0 +1,31 @@ +package secret + +import "testing" + +func TestHashVerifyRoundTrip(t *testing.T) { + h, err := Hash("correct horse") + if err != nil { + t.Fatal(err) + } + if !Verify(h, "correct horse") { + t.Fatal("correct password rejected") + } + if Verify(h, "wrong horse") { + t.Fatal("wrong password accepted") + } +} + +func TestVerifyRejectsJunkVerifier(t *testing.T) { + for _, v := range []string{"", "x", "pbkdf2-sha256$1$a$b", "md5$1$a$b"} { + if Verify(v, "anything") { + t.Fatalf("verifier %q accepted", v) + } + } +} + +func TestUserCodeShape(t *testing.T) { + c := UserCode() + if len(c) != 9 || c[4] != '-' { + t.Fatalf("user code %q", c) + } +} diff --git a/internal/server/admin.go b/internal/server/admin.go new file mode 100644 index 0000000..195e57d --- /dev/null +++ b/internal/server/admin.go @@ -0,0 +1,524 @@ +package server + +import ( + "errors" + "net/http" + "sort" + "strings" + "time" + + "cc.tiredsysadmin.keeporg/internal/secret" + "cc.tiredsysadmin.keeporg/internal/store" +) + +var errForbidden = errors.New("forbidden") + +// deny maps a domain error onto a response. +func deny(w http.ResponseWriter, err error) { + switch { + case err == nil: + return + case errors.Is(err, errForbidden): + fail(w, http.StatusForbidden, "forbidden", "you do not administer that object") + default: + fail(w, http.StatusBadRequest, "bad_request", err.Error()) + } +} + +// ---- databases ---- + +func (s *Server) handleCreateDatabase(w http.ResponseWriter, r *http.Request, u *store.User) { + var req struct { + Name string `json:"name"` + OwnerGroupID string `json:"owner_group_id"` + MergeStrategy store.Strategy `json:"merge_strategy"` + } + if !readJSON(w, r, &req) { + return + } + var out dbView + err := s.st.Update(func(st *store.State) error { + if strings.TrimSpace(req.Name) == "" { + return errors.New("name is required") + } + if st.Groups[req.OwnerGroupID] == nil { + return errors.New("unknown owner group") + } + if !st.IsGroupAdmin(u.ID, req.OwnerGroupID) { + return errForbidden + } + strategy := req.MergeStrategy + if strategy == "" { + strategy = store.StrategyServerWins + } + if !strategy.Valid() { + return errors.New("unknown merge strategy") + } + db := &store.Database{ID: secret.ID(), Name: strings.TrimSpace(req.Name), + OwnerGroupID: req.OwnerGroupID, MergeStrategy: strategy, UpdatedAt: time.Now()} + st.Databases[db.ID] = db + out = toView(db, store.PermAdmin) + return nil + }) + if err != nil { + deny(w, err) + return + } + s.notifyList(out.ID) + writeJSON(w, http.StatusCreated, out) +} + +func (s *Server) handlePatchDatabase(w http.ResponseWriter, r *http.Request, u *store.User) { + id := r.PathValue("id") + var req struct { + Name *string `json:"name"` + MergeStrategy *store.Strategy `json:"merge_strategy"` + } + if !readJSON(w, r, &req) { + return + } + var out dbView + err := s.st.Update(func(st *store.State) error { + db := st.Databases[id] + if db == nil { + return errors.New("no such database") + } + if st.DatabasePerm(u.ID, id) != store.PermAdmin { + return errForbidden + } + if req.Name != nil { + if strings.TrimSpace(*req.Name) == "" { + return errors.New("name may not be empty") + } + db.Name = strings.TrimSpace(*req.Name) + } + if req.MergeStrategy != nil { + if !req.MergeStrategy.Valid() { + return errors.New("unknown merge strategy") + } + db.MergeStrategy = *req.MergeStrategy + } + out = toView(db, store.PermAdmin) + return nil + }) + if err != nil { + deny(w, err) + return + } + s.notifyList(id) + writeJSON(w, http.StatusOK, out) +} + +func (s *Server) handleDeleteDatabase(w http.ResponseWriter, r *http.Request, u *store.User) { + id := r.PathValue("id") + var watchers []string + err := s.st.Update(func(st *store.State) error { + if st.Databases[id] == nil { + return errors.New("no such database") + } + if st.DatabasePerm(u.ID, id) != store.PermAdmin { + return errForbidden + } + watchers = st.Watchers(id) + delete(st.Databases, id) + st.Grants = filterGrants(st.Grants, func(g store.Grant) bool { return g.DatabaseID != id }) + return nil + }) + if err != nil { + deny(w, err) + return + } + if err := s.blobs.Forget(id); err != nil { + s.log.Printf("delete blobs for %s: %v", id, err) + } + s.hub.Publish(watchers, Event{Type: EventChanged, DatabaseID: id}) + writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) +} + +// ---- groups and membership ---- + +type groupView struct { + ID string `json:"id"` + Name string `json:"name"` + ParentID string `json:"parent_id,omitempty"` + Admin bool `json:"admin"` + Members int `json:"members"` +} + +func (s *Server) handleListGroups(w http.ResponseWriter, r *http.Request, u *store.User) { + out := []groupView{} + s.st.Read(func(st *store.State) { + counts := map[string]int{} + for _, m := range st.Memberships { + counts[m.GroupID]++ + } + reach := st.EffectiveGroups(u.ID) + for id, g := range st.Groups { + admin := st.IsGroupAdmin(u.ID, id) + visible := admin || reach[id] || u.ServerAdmin + if !visible { + // A group below one the user administers is visible too. + for _, anc := range st.Ancestors(id) { + if st.IsGroupAdmin(u.ID, anc) { + visible = true + break + } + } + } + if visible { + out = append(out, groupView{ID: id, Name: g.Name, ParentID: g.ParentID, + Admin: admin, Members: counts[id]}) + } + } + }) + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + writeJSON(w, http.StatusOK, map[string]any{"groups": out}) +} + +func (s *Server) handleCreateGroup(w http.ResponseWriter, r *http.Request, u *store.User) { + var req struct { + Name string `json:"name"` + ParentID string `json:"parent_id"` + } + if !readJSON(w, r, &req) { + return + } + var out groupView + err := s.st.Update(func(st *store.State) error { + if strings.TrimSpace(req.Name) == "" { + return errors.New("name is required") + } + if req.ParentID == "" { + if !u.ServerAdmin { + return errForbidden + } + } else { + if st.Groups[req.ParentID] == nil { + return errors.New("unknown parent group") + } + if !st.IsGroupAdmin(u.ID, req.ParentID) { + return errForbidden + } + } + g := &store.Group{ID: secret.ID(), Name: strings.TrimSpace(req.Name), + ParentID: req.ParentID, CreatedAt: time.Now()} + st.Groups[g.ID] = g + out = groupView{ID: g.ID, Name: g.Name, ParentID: g.ParentID, Admin: true} + return nil + }) + if err != nil { + deny(w, err) + return + } + writeJSON(w, http.StatusCreated, out) +} + +func (s *Server) handleDeleteGroup(w http.ResponseWriter, r *http.Request, u *store.User) { + id := r.PathValue("id") + err := s.st.Update(func(st *store.State) error { + g := st.Groups[id] + if g == nil { + return errors.New("no such group") + } + if !st.IsGroupAdmin(u.ID, id) { + return errForbidden + } + if len(st.Descendants(id)) > 1 { + return errors.New("the group still has child groups") + } + for _, db := range st.Databases { + if db.OwnerGroupID == id { + return errors.New("the group still owns databases") + } + } + for _, m := range st.Memberships { + if m.GroupID == id { + return errors.New("the group still has members") + } + } + delete(st.Groups, id) + st.Grants = filterGrants(st.Grants, func(gr store.Grant) bool { return gr.GroupID != id }) + return nil + }) + if err != nil { + deny(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) +} + +func (s *Server) handleSetMember(w http.ResponseWriter, r *http.Request, u *store.User) { + groupID := r.PathValue("id") + var req struct { + Username string `json:"username"` + UserID string `json:"user_id"` + Role store.Role `json:"role"` + } + if !readJSON(w, r, &req) { + return + } + err := s.st.Update(func(st *store.State) error { + if st.Groups[groupID] == nil { + return errors.New("no such group") + } + if !st.IsGroupAdmin(u.ID, groupID) { + return errForbidden + } + target := st.Users[req.UserID] + if target == nil && req.Username != "" { + target = st.UserByName(req.Username) + } + if target == nil { + return errors.New("no such user") + } + role := req.Role + if role == "" { + role = store.RoleMember + } + if !role.Valid() { + return errors.New("role must be member or admin") + } + for i := range st.Memberships { + if st.Memberships[i].UserID == target.ID && st.Memberships[i].GroupID == groupID { + st.Memberships[i].Role = role + return nil + } + } + st.Memberships = append(st.Memberships, store.Membership{UserID: target.ID, GroupID: groupID, Role: role}) + return nil + }) + if err != nil { + deny(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request, u *store.User) { + groupID, userRef := r.PathValue("id"), r.PathValue("user") + err := s.st.Update(func(st *store.State) error { + if st.Groups[groupID] == nil { + return errors.New("no such group") + } + if !st.IsGroupAdmin(u.ID, groupID) { + return errForbidden + } + target := st.Users[userRef] + if target == nil { + target = st.UserByName(userRef) + } + if target == nil { + return errors.New("no such user") + } + out := st.Memberships[:0] + for _, m := range st.Memberships { + if m.UserID == target.ID && m.GroupID == groupID { + continue + } + out = append(out, m) + } + st.Memberships = out + return nil + }) + if err != nil { + deny(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "removed"}) +} + +// ---- grants ---- + +func (s *Server) handleSetGrant(w http.ResponseWriter, r *http.Request, u *store.User) { + var req struct { + GroupID string `json:"group_id"` + DatabaseID string `json:"database_id"` + Perm store.Perm `json:"perm"` + } + if !readJSON(w, r, &req) { + return + } + var watchers []string + err := s.st.Update(func(st *store.State) error { + if st.Databases[req.DatabaseID] == nil { + return errors.New("no such database") + } + if st.Groups[req.GroupID] == nil { + return errors.New("no such group") + } + if st.DatabasePerm(u.ID, req.DatabaseID) != store.PermAdmin { + return errForbidden + } + if !req.Perm.Valid() { + return errors.New("perm must be read, write or admin") + } + for i := range st.Grants { + if st.Grants[i].GroupID == req.GroupID && st.Grants[i].DatabaseID == req.DatabaseID { + st.Grants[i].Perm = req.Perm + watchers = st.Watchers(req.DatabaseID) + return nil + } + } + st.Grants = append(st.Grants, store.Grant{GroupID: req.GroupID, + DatabaseID: req.DatabaseID, Perm: req.Perm}) + watchers = st.Watchers(req.DatabaseID) + return nil + }) + if err != nil { + deny(w, err) + return + } + s.hub.Publish(watchers, Event{Type: EventChanged, DatabaseID: req.DatabaseID}) + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (s *Server) handleDeleteGrant(w http.ResponseWriter, r *http.Request, u *store.User) { + groupID := r.URL.Query().Get("group_id") + dbID := r.URL.Query().Get("database_id") + var watchers []string + err := s.st.Update(func(st *store.State) error { + if st.Databases[dbID] == nil { + return errors.New("no such database") + } + if st.DatabasePerm(u.ID, dbID) != store.PermAdmin { + return errForbidden + } + watchers = st.Watchers(dbID) + st.Grants = filterGrants(st.Grants, func(g store.Grant) bool { + return g.GroupID != groupID || g.DatabaseID != dbID + }) + return nil + }) + if err != nil { + deny(w, err) + return + } + s.hub.Publish(watchers, Event{Type: EventChanged, DatabaseID: dbID}) + writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) +} + +// ---- invites ---- + +func (s *Server) handleCreateInvite(w http.ResponseWriter, r *http.Request, u *store.User) { + var req struct { + GroupID string `json:"group_id"` + Role store.Role `json:"role"` + TTLHours int `json:"ttl_hours"` + MaxUses int `json:"max_uses"` + } + if !readJSON(w, r, &req) { + return + } + inv := &store.Invite{Token: secret.Token(), GroupID: req.GroupID, Role: req.Role, + CreatedBy: u.ID, CreatedAt: time.Now(), MaxUses: req.MaxUses} + if inv.Role == "" { + inv.Role = store.RoleMember + } + if req.TTLHours <= 0 { + req.TTLHours = 168 + } + if inv.MaxUses == 0 { + inv.MaxUses = 1 + } + inv.ExpiresAt = time.Now().Add(time.Duration(req.TTLHours) * time.Hour) + err := s.st.Update(func(st *store.State) error { + if inv.GroupID == "" { + if !u.ServerAdmin { + return errors.New("group_id is required") + } + } else { + if st.Groups[inv.GroupID] == nil { + return errors.New("unknown group") + } + if !st.IsGroupAdmin(u.ID, inv.GroupID) { + return errForbidden + } + } + if !inv.Role.Valid() { + return errors.New("role must be member or admin") + } + st.Invites[inv.Token] = inv + return nil + }) + if err != nil { + deny(w, err) + return + } + writeJSON(w, http.StatusCreated, map[string]any{ + "token": inv.Token, + "url": s.cfg.ExternalURL + "/invite/" + inv.Token, + "expires_at": inv.ExpiresAt, + "max_uses": inv.MaxUses, + "group_id": inv.GroupID, + "role": inv.Role, + }) +} + +func (s *Server) handleListInvites(w http.ResponseWriter, r *http.Request, u *store.User) { + type inviteView struct { + Token string `json:"token"` + URL string `json:"url"` + GroupID string `json:"group_id,omitempty"` + Role store.Role `json:"role"` + Uses int `json:"uses"` + MaxUses int `json:"max_uses"` + ExpiresAt time.Time `json:"expires_at"` + } + out := []inviteView{} + s.st.Read(func(st *store.State) { + for _, inv := range st.Invites { + if !u.ServerAdmin && inv.CreatedBy != u.ID && + !(inv.GroupID != "" && st.IsGroupAdmin(u.ID, inv.GroupID)) { + continue + } + out = append(out, inviteView{Token: inv.Token, URL: s.cfg.ExternalURL + "/invite/" + inv.Token, + GroupID: inv.GroupID, Role: inv.Role, Uses: inv.Uses, MaxUses: inv.MaxUses, + ExpiresAt: inv.ExpiresAt}) + } + }) + sort.Slice(out, func(i, j int) bool { return out[i].ExpiresAt.Before(out[j].ExpiresAt) }) + writeJSON(w, http.StatusOK, map[string]any{"invites": out}) +} + +// ---- users ---- + +func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request, u *store.User) { + out := []userView{} + s.st.Read(func(st *store.State) { + for id, target := range st.Users { + if u.ServerAdmin || id == u.ID || s.sharesAdministeredGroup(st, u.ID, id) { + out = append(out, view(target)) + } + } + }) + sort.Slice(out, func(i, j int) bool { return out[i].Username < out[j].Username }) + writeJSON(w, http.StatusOK, map[string]any{"users": out}) +} + +// sharesAdministeredGroup reports whether admin administers any group the +// target belongs to, which is what makes the target visible to them. +func (s *Server) sharesAdministeredGroup(st *store.State, adminID, targetID string) bool { + for _, m := range st.Memberships { + if m.UserID == targetID && st.IsGroupAdmin(adminID, m.GroupID) { + return true + } + } + return false +} + +// notifyList tells everyone with access that the database list changed. +func (s *Server) notifyList(dbID string) { + var watchers []string + s.st.Read(func(st *store.State) { watchers = st.Watchers(dbID) }) + s.hub.Publish(watchers, Event{Type: EventChanged, DatabaseID: dbID}) +} + +func filterGrants(in []store.Grant, keep func(store.Grant) bool) []store.Grant { + out := in[:0] + for _, g := range in { + if keep(g) { + out = append(out, g) + } + } + return out +} diff --git a/internal/server/auth.go b/internal/server/auth.go new file mode 100644 index 0000000..27a2adf --- /dev/null +++ b/internal/server/auth.go @@ -0,0 +1,384 @@ +package server + +import ( + "errors" + "net/http" + "strings" + "time" + + "cc.tiredsysadmin.keeporg/internal/secret" + "cc.tiredsysadmin.keeporg/internal/store" + "cc.tiredsysadmin.keeporg/internal/totp" +) + +// MinPasswordLen is the shortest password the server will store. +const MinPasswordLen = 10 + +// deviceTTL bounds how long a pending device login stays open. +const deviceTTL = 10 * time.Minute + +var ( + errBadCredentials = errors.New("bad credentials") + errTOTPRequired = errors.New("totp required") + errTOTPInvalid = errors.New("totp invalid") +) + +// checkPassword validates a username, password and second factor against the +// state. It is the one place password login is decided. +func checkPassword(st *store.State, username, password, code string) (*store.User, error) { + u := st.UserByName(username) + if u == nil || u.Disabled || u.PassHash == "" { + return nil, errBadCredentials + } + if !secret.Verify(u.PassHash, password) { + return nil, errBadCredentials + } + if u.TOTPConfirmed { + if strings.TrimSpace(code) == "" { + return nil, errTOTPRequired + } + if !totp.Check(u.TOTPSecret, code, time.Now()) { + return nil, errTOTPInvalid + } + } + return u, nil +} + +type loginRequest struct { + Username string `json:"username"` + Password string `json:"password"` + TOTP string `json:"totp"` + Label string `json:"label"` +} + +type loginResponse struct { + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` + User userView `json:"user"` +} + +type userView struct { + ID string `json:"id"` + Username string `json:"username"` + Email string `json:"email,omitempty"` + ServerAdmin bool `json:"server_admin"` + TOTPEnabled bool `json:"totp_enabled"` +} + +func view(u *store.User) userView { + return userView{ID: u.ID, Username: u.Username, Email: u.Email, + ServerAdmin: u.ServerAdmin, TOTPEnabled: u.TOTPConfirmed} +} + +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + var req loginRequest + if !readJSON(w, r, &req) { + return + } + var resp loginResponse + err := s.st.Update(func(st *store.State) error { + u, err := checkPassword(st, req.Username, req.Password, req.TOTP) + if err != nil { + return err + } + sess := s.newSession(st, u.ID, req.Label) + resp = loginResponse{Token: sess.Token, ExpiresAt: sess.ExpiresAt, User: view(u)} + return nil + }) + switch { + case errors.Is(err, errTOTPRequired): + fail(w, http.StatusUnauthorized, "totp_required", "second factor required") + case errors.Is(err, errTOTPInvalid): + fail(w, http.StatusUnauthorized, "totp_invalid", "second factor rejected") + case errors.Is(err, errBadCredentials): + fail(w, http.StatusUnauthorized, "bad_credentials", "unknown user or wrong password") + case err != nil: + fail(w, http.StatusInternalServerError, "internal", err.Error()) + default: + writeJSON(w, http.StatusOK, resp) + } +} + +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request, _ *store.User) { + tok := bearer(r) + if err := s.st.Update(func(st *store.State) error { + delete(st.Sessions, tok) + return nil + }); err != nil { + fail(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "logged out"}) +} + +func (s *Server) handleWhoami(w http.ResponseWriter, r *http.Request, u *store.User) { + type groupView struct { + ID string `json:"id"` + Name string `json:"name"` + Role store.Role `json:"role"` + } + out := struct { + userView + Groups []groupView `json:"groups"` + }{userView: view(u), Groups: []groupView{}} + s.st.Read(func(st *store.State) { + for _, m := range st.Memberships { + if m.UserID != u.ID { + continue + } + if g := st.Groups[m.GroupID]; g != nil { + out.Groups = append(out.Groups, groupView{ID: g.ID, Name: g.Name, Role: m.Role}) + } + } + }) + writeJSON(w, http.StatusOK, out) +} + +func (s *Server) handleTOTPEnroll(w http.ResponseWriter, r *http.Request, u *store.User) { + sec := totp.NewSecret() + if err := s.st.Update(func(st *store.State) error { + target := st.Users[u.ID] + if target == nil { + return errBadCredentials + } + target.TOTPSecret = sec + target.TOTPConfirmed = false + return nil + }); err != nil { + fail(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{ + "secret": sec, + "uri": totp.URI("KeepOrg", u.Username, sec), + }) +} + +func (s *Server) handleTOTPConfirm(w http.ResponseWriter, r *http.Request, u *store.User) { + var req struct { + Code string `json:"code"` + } + if !readJSON(w, r, &req) { + return + } + err := s.st.Update(func(st *store.State) error { + target := st.Users[u.ID] + if target == nil || target.TOTPSecret == "" { + return errBadCredentials + } + if !totp.Check(target.TOTPSecret, req.Code, time.Now()) { + return errTOTPInvalid + } + target.TOTPConfirmed = true + return nil + }) + switch { + case errors.Is(err, errTOTPInvalid): + fail(w, http.StatusBadRequest, "totp_invalid", "code rejected") + case err != nil: + fail(w, http.StatusInternalServerError, "internal", err.Error()) + default: + writeJSON(w, http.StatusOK, map[string]bool{"totp_enabled": true}) + } +} + +// ---- device login: the browser approves, the client polls ---- + +func (s *Server) handleDeviceStart(w http.ResponseWriter, r *http.Request) { + var req struct { + Label string `json:"label"` + } + if r.ContentLength > 0 && !readJSON(w, r, &req) { + return + } + d := &store.DeviceAuth{ + DeviceCode: secret.Token(), + UserCode: secret.UserCode(), + Label: req.Label, + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(deviceTTL), + } + if err := s.st.Update(func(st *store.State) error { + st.Devices[d.DeviceCode] = d + return nil + }); err != nil { + fail(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "device_code": d.DeviceCode, + "user_code": d.UserCode, + "verification_url": s.cfg.ExternalURL + "/device?code=" + d.UserCode, + "interval": 2, + "expires_in": int(deviceTTL.Seconds()), + }) +} + +func (s *Server) handleDevicePoll(w http.ResponseWriter, r *http.Request) { + var req struct { + DeviceCode string `json:"device_code"` + } + if !readJSON(w, r, &req) { + return + } + var ( + token string + userID string + gone bool + ) + err := s.st.Update(func(st *store.State) error { + d := st.Devices[req.DeviceCode] + if d == nil || time.Now().After(d.ExpiresAt) { + delete(st.Devices, req.DeviceCode) + gone = true + return nil + } + if d.SessionToken == "" { + return nil + } + token = d.SessionToken + if sess := st.Sessions[token]; sess != nil { + userID = sess.UserID + } + delete(st.Devices, req.DeviceCode) + return nil + }) + switch { + case err != nil: + fail(w, http.StatusInternalServerError, "internal", err.Error()) + case gone: + fail(w, http.StatusGone, "expired", "device code expired") + case token == "": + writeJSON(w, http.StatusAccepted, map[string]string{"status": "pending"}) + default: + var resp loginResponse + s.st.Read(func(st *store.State) { + resp.Token = token + if sess := st.Sessions[token]; sess != nil { + resp.ExpiresAt = sess.ExpiresAt + } + if u := st.Users[userID]; u != nil { + resp.User = view(u) + } + }) + writeJSON(w, http.StatusOK, resp) + } +} + +func (s *Server) handleDevicePage(w http.ResponseWriter, r *http.Request) { + s.renderDevice(w, r.URL.Query().Get("code"), "") +} + +func (s *Server) handleDeviceApprove(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + fail(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + code := strings.ToUpper(strings.TrimSpace(r.FormValue("code"))) + err := s.st.Update(func(st *store.State) error { + d := st.DeviceByUserCode(code) + if d == nil || time.Now().After(d.ExpiresAt) { + return errors.New("unknown or expired code") + } + u, err := checkPassword(st, r.FormValue("username"), r.FormValue("password"), r.FormValue("totp")) + if err != nil { + return err + } + d.SessionToken = s.newSession(st, u.ID, d.Label).Token + return nil + }) + switch { + case errors.Is(err, errTOTPRequired): + s.renderDevice(w, code, "This account requires a one-time code.") + case errors.Is(err, errTOTPInvalid): + s.renderDevice(w, code, "That one-time code was rejected.") + case errors.Is(err, errBadCredentials): + s.renderDevice(w, code, "Unknown user or wrong password.") + case err != nil: + s.renderDevice(w, code, err.Error()) + default: + s.renderDone(w, "Device approved", "You can close this page and return to the application.") + } +} + +// ---- invites ---- + +func (s *Server) handleInvitePage(w http.ResponseWriter, r *http.Request) { + tok := r.PathValue("token") + var ok bool + s.st.Read(func(st *store.State) { + if inv := st.Invites[tok]; inv != nil && inv.Usable(time.Now()) { + ok = true + } + }) + if !ok { + s.renderDone(w, "Invite not valid", "This invite link has expired or has already been used.") + return + } + s.renderInvite(w, tok, "") +} + +func (s *Server) handleInviteRedeem(w http.ResponseWriter, r *http.Request) { + tok := r.PathValue("token") + if err := r.ParseForm(); err != nil { + fail(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + username := strings.TrimSpace(r.FormValue("username")) + password := r.FormValue("password") + err := s.st.Update(func(st *store.State) error { + inv := st.Invites[tok] + if inv == nil || !inv.Usable(time.Now()) { + return errors.New("this invite link has expired or has already been used") + } + if err := validUsername(username); err != nil { + return err + } + if len(password) < MinPasswordLen { + return errors.New("password must be at least 10 characters") + } + if password != r.FormValue("password2") { + return errors.New("the two passwords do not match") + } + if st.UserByName(username) != nil { + return errors.New("that username is taken") + } + hash, err := secret.Hash(password) + if err != nil { + return err + } + u := &store.User{ID: secret.ID(), Username: username, Email: strings.TrimSpace(r.FormValue("email")), + PassHash: hash, CreatedAt: time.Now()} + st.Users[u.ID] = u + if inv.GroupID != "" { + role := inv.Role + if !role.Valid() { + role = store.RoleMember + } + st.Memberships = append(st.Memberships, store.Membership{UserID: u.ID, GroupID: inv.GroupID, Role: role}) + } + inv.Uses++ + return nil + }) + if err != nil { + s.renderInvite(w, tok, err.Error()) + return + } + s.renderDone(w, "Account created", + "Sign in from the KeepOrg client with your new username and password.") +} + +func validUsername(name string) error { + if len(name) < 2 || len(name) > 64 { + return errors.New("username must be between 2 and 64 characters") + } + for _, r := range name { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', + r == '.', r == '-', r == '_', r == '@': + default: + return errors.New("username may contain letters, digits and . - _ @ only") + } + } + return nil +} diff --git a/internal/server/events.go b/internal/server/events.go new file mode 100644 index 0000000..9c9767f --- /dev/null +++ b/internal/server/events.go @@ -0,0 +1,94 @@ +package server + +import ( + "encoding/json" + "sync" +) + +// Event is one push notification sent to clients over SSE. +type Event struct { + Type string `json:"type"` + DatabaseID string `json:"database_id,omitempty"` + Name string `json:"name,omitempty"` + Version int64 `json:"version,omitempty"` + SHA256 string `json:"sha256,omitempty"` + By string `json:"by,omitempty"` +} + +const ( + EventUpdated = "db-updated" + EventChanged = "db-list-changed" +) + +// subBuffer is how many events a slow subscriber may fall behind before its +// stream is dropped. A dropped client reconnects and resyncs from scratch, +// so losing events is safe; blocking the publisher would not be. +const subBuffer = 16 + +type subscriber struct { + userID string + ch chan Event +} + +// Hub fans events out to the connected clients of specific users. +type Hub struct { + mu sync.Mutex + next int + subs map[int]*subscriber +} + +func NewHub() *Hub { return &Hub{subs: map[int]*subscriber{}} } + +// Subscribe registers a stream for one user and returns its id and channel. +func (h *Hub) Subscribe(userID string) (int, <-chan Event) { + h.mu.Lock() + defer h.mu.Unlock() + h.next++ + s := &subscriber{userID: userID, ch: make(chan Event, subBuffer)} + h.subs[h.next] = s + return h.next, s.ch +} + +// Unsubscribe removes a stream and closes its channel. +func (h *Hub) Unsubscribe(id int) { + h.mu.Lock() + defer h.mu.Unlock() + if s, ok := h.subs[id]; ok { + delete(h.subs, id) + close(s.ch) + } +} + +// Publish delivers ev to every stream of every listed user. A stream that is +// not keeping up is dropped rather than waited for. +func (h *Hub) Publish(userIDs []string, ev Event) { + want := make(map[string]bool, len(userIDs)) + for _, id := range userIDs { + want[id] = true + } + h.mu.Lock() + defer h.mu.Unlock() + for id, s := range h.subs { + if !want[s.userID] { + continue + } + select { + case s.ch <- ev: + default: + delete(h.subs, id) + close(s.ch) + } + } +} + +// Count reports the number of live streams, for the health endpoint. +func (h *Hub) Count() int { + h.mu.Lock() + defer h.mu.Unlock() + return len(h.subs) +} + +func (e Event) encode() []byte { + b, _ := json.Marshal(e) + return b +} diff --git a/internal/server/integration_test.go b/internal/server/integration_test.go new file mode 100644 index 0000000..390749f --- /dev/null +++ b/internal/server/integration_test.go @@ -0,0 +1,425 @@ +package server + +import ( + "bufio" + "bytes" + "encoding/json" + "io" + "log" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strings" + "testing" + "time" + + "cc.tiredsysadmin.keeporg/internal/secret" + "cc.tiredsysadmin.keeporg/internal/store" + "cc.tiredsysadmin.keeporg/internal/totp" +) + +type harness struct { + t *testing.T + srv *Server + http *httptest.Server +} + +const adminPassword = "admin-password-1" + +func newHarness(t *testing.T) *harness { + t.Helper() + dir := t.TempDir() + srv, err := New(Config{DataDir: dir, ExternalURL: "https://keeporg.test"}, + log.New(io.Discard, "", 0)) + if err != nil { + t.Fatal(err) + } + hash, err := secret.Hash(adminPassword) + if err != nil { + t.Fatal(err) + } + if err := srv.Store().Update(func(st *store.State) error { + st.Users["admin"] = &store.User{ID: "admin", Username: "admin", PassHash: hash, ServerAdmin: true} + st.Groups["everyone"] = &store.Group{ID: "everyone", Name: "everyone"} + st.Memberships = append(st.Memberships, + store.Membership{UserID: "admin", GroupID: "everyone", Role: store.RoleAdmin}) + return nil + }); err != nil { + t.Fatal(err) + } + ts := httptest.NewServer(srv.Handler()) + t.Cleanup(ts.Close) + _ = filepath.Join(dir, "state.json") + return &harness{t: t, srv: srv, http: ts} +} + +// call sends a JSON request and decodes the JSON response. +func (h *harness) call(method, path, token string, body any) (int, map[string]any) { + h.t.Helper() + var rdr io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + h.t.Fatal(err) + } + rdr = bytes.NewReader(b) + } + req, err := http.NewRequest(method, h.http.URL+path, rdr) + if err != nil { + h.t.Fatal(err) + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := h.http.Client().Do(req) + if err != nil { + h.t.Fatal(err) + } + defer resp.Body.Close() + out := map[string]any{} + raw, _ := io.ReadAll(resp.Body) + if len(raw) > 0 { + _ = json.Unmarshal(raw, &out) + } + return resp.StatusCode, out +} + +func (h *harness) login(user, password, code string) (string, int, map[string]any) { + h.t.Helper() + status, body := h.call("POST", "/api/v1/auth/login", "", + map[string]string{"username": user, "password": password, "totp": code, "label": "test"}) + tok, _ := body["token"].(string) + return tok, status, body +} + +// push uploads bytes with an explicit If-Match and returns the response. +func (h *harness) push(token, dbID, ifMatch string, data []byte) (int, map[string]any, http.Header) { + h.t.Helper() + req, err := http.NewRequest("PUT", h.http.URL+"/api/v1/databases/"+dbID+"/blob", bytes.NewReader(data)) + if err != nil { + h.t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("If-Match", ifMatch) + resp, err := h.http.Client().Do(req) + if err != nil { + h.t.Fatal(err) + } + defer resp.Body.Close() + out := map[string]any{} + raw, _ := io.ReadAll(resp.Body) + _ = json.Unmarshal(raw, &out) + return resp.StatusCode, out, resp.Header +} + +// makeDB creates a database owned by the "everyone" group. +func (h *harness) makeDB(token, name string) string { + h.t.Helper() + status, body := h.call("POST", "/api/v1/databases", token, + map[string]string{"name": name, "owner_group_id": "everyone"}) + if status != http.StatusCreated { + h.t.Fatalf("create database: %d %v", status, body) + } + return body["id"].(string) +} + +func TestLoginRejectsWrongPassword(t *testing.T) { + h := newHarness(t) + if _, status, _ := h.login("admin", "nope", ""); status != http.StatusUnauthorized { + t.Fatalf("status = %d", status) + } + tok, status, _ := h.login("admin", adminPassword, "") + if status != http.StatusOK || tok == "" { + t.Fatalf("status = %d token = %q", status, tok) + } +} + +func TestPushPullAndServerWinsConflict(t *testing.T) { + h := newHarness(t) + tok, _, _ := h.login("admin", adminPassword, "") + db := h.makeDB(tok, "Team") + + status, body, hdr := h.push(tok, db, `"0"`, []byte("kdbx-v1")) + if status != http.StatusOK || body["version"].(float64) != 1 { + t.Fatalf("first push: %d %v", status, body) + } + if hdr.Get("ETag") != `"1"` { + t.Fatalf("etag = %q", hdr.Get("ETag")) + } + + // A second client that still believes it is on version 0 is refused, and + // learns both the current version and the policy to apply. + status, body, hdr = h.push(tok, db, `"0"`, []byte("kdbx-other")) + if status != http.StatusConflict || body["code"] != "conflict" { + t.Fatalf("stale push: %d %v", status, body) + } + if body["current_version"].(float64) != 1 || body["merge_strategy"] != "server-wins" { + t.Fatalf("conflict body = %v", body) + } + if hdr.Get("X-KeepOrg-Strategy") != "server-wins" { + t.Fatalf("strategy header = %q", hdr.Get("X-KeepOrg-Strategy")) + } + + // Forcing is refused while the strategy is server-wins. + if status, _, _ = h.push(tok, db, "*", []byte("forced")); status != http.StatusConflict { + t.Fatalf("force under server-wins: %d", status) + } + + // Re-pushing identical bytes is a no-op, so a save that changed nothing + // does not burn a version. + status, body, _ = h.push(tok, db, `"1"`, []byte("kdbx-v1")) + if status != http.StatusOK || body["unchanged"] != true || body["version"].(float64) != 1 { + t.Fatalf("idempotent push: %d %v", status, body) + } + + // Download returns exactly what was stored. + req, _ := http.NewRequest("GET", h.http.URL+"/api/v1/databases/"+db+"/blob", nil) + req.Header.Set("Authorization", "Bearer "+tok) + resp, err := h.http.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + got, _ := io.ReadAll(resp.Body) + if string(got) != "kdbx-v1" || resp.Header.Get("ETag") != `"1"` { + t.Fatalf("download = %q etag %q", got, resp.Header.Get("ETag")) + } +} + +func TestClientWinsAllowsForce(t *testing.T) { + h := newHarness(t) + tok, _, _ := h.login("admin", adminPassword, "") + db := h.makeDB(tok, "Team") + h.push(tok, db, `"0"`, []byte("v1")) + + if status, body := h.call("PATCH", "/api/v1/databases/"+db, tok, + map[string]string{"merge_strategy": "client-wins"}); status != http.StatusOK { + t.Fatalf("patch strategy: %d %v", status, body) + } + status, body, _ := h.push(tok, db, "*", []byte("v2-forced")) + if status != http.StatusOK || body["version"].(float64) != 2 { + t.Fatalf("forced push: %d %v", status, body) + } +} + +func TestReadOnlyGrantCannotPush(t *testing.T) { + h := newHarness(t) + admin, _, _ := h.login("admin", adminPassword, "") + db := h.makeDB(admin, "Team") + + // A child group with a read-only grant, and a member invited into it. + status, g := h.call("POST", "/api/v1/groups", admin, + map[string]string{"name": "readers", "parent_id": "everyone"}) + if status != http.StatusCreated { + t.Fatalf("create group: %d %v", status, g) + } + readers := g["id"].(string) + if status, body := h.call("POST", "/api/v1/grants", admin, map[string]string{ + "group_id": readers, "database_id": db, "perm": "read"}); status != http.StatusOK { + t.Fatalf("grant: %d %v", status, body) + } + status, inv := h.call("POST", "/api/v1/invites", admin, + map[string]any{"group_id": readers, "role": "member", "ttl_hours": 1, "max_uses": 1}) + if status != http.StatusCreated { + t.Fatalf("invite: %d %v", status, inv) + } + token := inv["token"].(string) + + form := url.Values{"username": {"reader"}, "password": {"reader-password"}, + "password2": {"reader-password"}} + resp, err := h.http.Client().PostForm(h.http.URL+"/invite/"+token, form) + if err != nil { + t.Fatal(err) + } + page, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if !strings.Contains(string(page), "Account created") { + t.Fatalf("invite redemption page: %s", page) + } + + rtok, status, _ := h.login("reader", "reader-password", "") + if status != http.StatusOK { + t.Fatalf("reader login: %d", status) + } + // The reader sees the database through the grant on its group... + status, list := h.call("GET", "/api/v1/databases", rtok, nil) + dbs := list["databases"].([]any) + if status != http.StatusOK || len(dbs) != 1 { + t.Fatalf("reader database list: %d %v", status, list) + } + if dbs[0].(map[string]any)["perm"] != "read" { + t.Fatalf("reader perm = %v", dbs[0]) + } + // ...but may not write it. + if status, body, _ := h.push(rtok, db, `"0"`, []byte("nope")); status != http.StatusForbidden { + t.Fatalf("reader push: %d %v", status, body) + } + // A second redemption of a single-use invite fails. + resp2, err := h.http.Client().PostForm(h.http.URL+"/invite/"+token, form) + if err != nil { + t.Fatal(err) + } + page2, _ := io.ReadAll(resp2.Body) + resp2.Body.Close() + if !strings.Contains(string(page2), "already been used") { + t.Fatalf("reused invite page: %s", page2) + } +} + +func TestEventStreamDeliversPush(t *testing.T) { + h := newHarness(t) + tok, _, _ := h.login("admin", adminPassword, "") + db := h.makeDB(tok, "Team") + + req, _ := http.NewRequest("GET", h.http.URL+"/api/v1/events?access_token="+tok, nil) + resp, err := h.http.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") { + t.Fatalf("content type = %q", ct) + } + + lines := make(chan string, 8) + go func() { + sc := bufio.NewScanner(resp.Body) + for sc.Scan() { + lines <- sc.Text() + } + close(lines) + }() + // Wait for the stream to register before pushing. + deadline := time.After(3 * time.Second) + for h.srv.hub.Count() == 0 { + select { + case <-deadline: + t.Fatal("stream never registered") + default: + time.Sleep(5 * time.Millisecond) + } + } + if status, body, _ := h.push(tok, db, `"0"`, []byte("v1")); status != http.StatusOK { + t.Fatalf("push: %d %v", status, body) + } + for { + select { + case l, open := <-lines: + if !open { + t.Fatal("stream closed before the event arrived") + } + if strings.HasPrefix(l, "data: ") { + var ev Event + if err := json.Unmarshal([]byte(strings.TrimPrefix(l, "data: ")), &ev); err != nil { + t.Fatal(err) + } + if ev.Type != EventUpdated || ev.DatabaseID != db || ev.Version != 1 { + t.Fatalf("event = %+v", ev) + } + return + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for the push event") + } + } +} + +func TestTOTPEnrollmentThenRequiredAtLogin(t *testing.T) { + h := newHarness(t) + tok, _, _ := h.login("admin", adminPassword, "") + status, body := h.call("POST", "/api/v1/auth/totp/enroll", tok, nil) + if status != http.StatusOK { + t.Fatalf("enroll: %d %v", status, body) + } + sec := body["secret"].(string) + if !strings.HasPrefix(body["uri"].(string), "otpauth://totp/") { + t.Fatalf("uri = %v", body["uri"]) + } + code, err := totp.Code(sec, time.Now()) + if err != nil { + t.Fatal(err) + } + if status, body = h.call("POST", "/api/v1/auth/totp/confirm", tok, + map[string]string{"code": code}); status != http.StatusOK { + t.Fatalf("confirm: %d %v", status, body) + } + if _, status, body = h.login("admin", adminPassword, ""); status != http.StatusUnauthorized || + body["code"] != "totp_required" { + t.Fatalf("login without code: %d %v", status, body) + } + code, _ = totp.Code(sec, time.Now()) + if _, status, body = h.login("admin", adminPassword, code); status != http.StatusOK { + t.Fatalf("login with code: %d %v", status, body) + } +} + +func TestDeviceLoginApprovedInBrowser(t *testing.T) { + h := newHarness(t) + status, start := h.call("POST", "/api/v1/auth/device/start", "", map[string]string{"label": "laptop"}) + if status != http.StatusOK { + t.Fatalf("device start: %d %v", status, start) + } + deviceCode := start["device_code"].(string) + userCode := start["user_code"].(string) + if !strings.Contains(start["verification_url"].(string), userCode) { + t.Fatalf("verification url = %v", start["verification_url"]) + } + if status, body := h.call("POST", "/api/v1/auth/device/poll", "", + map[string]string{"device_code": deviceCode}); status != http.StatusAccepted { + t.Fatalf("poll before approval: %d %v", status, body) + } + resp, err := h.http.Client().PostForm(h.http.URL+"/device", url.Values{ + "code": {userCode}, "username": {"admin"}, "password": {adminPassword}}) + if err != nil { + t.Fatal(err) + } + page, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if !strings.Contains(string(page), "Device approved") { + t.Fatalf("approval page: %s", page) + } + status, body := h.call("POST", "/api/v1/auth/device/poll", "", + map[string]string{"device_code": deviceCode}) + if status != http.StatusOK || body["token"] == "" { + t.Fatalf("poll after approval: %d %v", status, body) + } + // The issued token works, and the device code is single use. + if status, _ := h.call("GET", "/api/v1/auth/whoami", body["token"].(string), nil); status != http.StatusOK { + t.Fatalf("whoami with device token: %d", status) + } + if status, _ := h.call("POST", "/api/v1/auth/device/poll", "", + map[string]string{"device_code": deviceCode}); status != http.StatusGone { + t.Fatalf("second poll: %d", status) + } +} + +func TestNonAdminCannotAdminister(t *testing.T) { + h := newHarness(t) + admin, _, _ := h.login("admin", adminPassword, "") + hash, _ := secret.Hash("outsider-password") + if err := h.srv.Store().Update(func(st *store.State) error { + st.Users["out"] = &store.User{ID: "out", Username: "outsider", PassHash: hash} + return nil + }); err != nil { + t.Fatal(err) + } + db := h.makeDB(admin, "Team") + otok, _, _ := h.login("outsider", "outsider-password", "") + + if status, _ := h.call("POST", "/api/v1/databases", otok, + map[string]string{"name": "x", "owner_group_id": "everyone"}); status != http.StatusForbidden { + t.Fatalf("outsider created a database: %d", status) + } + if status, _ := h.call("PATCH", "/api/v1/databases/"+db, otok, + map[string]string{"merge_strategy": "client-wins"}); status != http.StatusForbidden { + t.Fatalf("outsider changed the strategy: %d", status) + } + // Without any grant the database is invisible, not merely unwritable. + if status, _ := h.call("GET", "/api/v1/databases/"+db+"/blob", otok, nil); status != http.StatusNotFound { + t.Fatalf("outsider read the blob: %d", status) + } + if status, _ := h.call("GET", "/api/v1/databases", "", nil); status != http.StatusUnauthorized { + t.Fatalf("anonymous list: %d", status) + } +} diff --git a/internal/server/oidc.go b/internal/server/oidc.go new file mode 100644 index 0000000..7ffe0a1 --- /dev/null +++ b/internal/server/oidc.go @@ -0,0 +1,292 @@ +package server + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "cc.tiredsysadmin.keeporg/internal/secret" + "cc.tiredsysadmin.keeporg/internal/store" +) + +// OIDCConfig configures single sign-on. The server acts as a confidential +// client: it exchanges the authorization code over TLS directly with the +// provider's token endpoint and then reads the identity from the userinfo +// endpoint, so no local JWT verification is needed. +type OIDCConfig struct { + Issuer string `json:"issuer"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + Scopes string `json:"scopes"` + // AutoCreate provisions an account on first successful sign-in. + AutoCreate bool `json:"auto_create"` + // DefaultGroupID is the group auto-created users join. + DefaultGroupID string `json:"default_group_id"` +} + +func (c OIDCConfig) Enabled() bool { + return c.Issuer != "" && c.ClientID != "" && c.ClientSecret != "" +} + +type oidcMetadata struct { + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + UserinfoEndpoint string `json:"userinfo_endpoint"` +} + +type oidcProvider struct { + cfg OIDCConfig + redirectURI string + http *http.Client + + mu sync.Mutex + meta *oidcMetadata +} + +func newOIDCProvider(cfg OIDCConfig, redirectURI string) *oidcProvider { + if cfg.Scopes == "" { + cfg.Scopes = "openid profile email" + } + return &oidcProvider{cfg: cfg, redirectURI: redirectURI, + http: &http.Client{Timeout: 15 * time.Second}} +} + +// metadata fetches and caches the provider's discovery document. +func (p *oidcProvider) metadata() (*oidcMetadata, error) { + p.mu.Lock() + defer p.mu.Unlock() + if p.meta != nil { + return p.meta, nil + } + u := strings.TrimRight(p.cfg.Issuer, "/") + "/.well-known/openid-configuration" + resp, err := p.http.Get(u) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("oidc: discovery %s: %s", u, resp.Status) + } + var m oidcMetadata + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&m); err != nil { + return nil, err + } + if m.AuthorizationEndpoint == "" || m.TokenEndpoint == "" || m.UserinfoEndpoint == "" { + return nil, errors.New("oidc: discovery document is missing endpoints") + } + p.meta = &m + return p.meta, nil +} + +type oidcIdentity struct { + Subject string `json:"sub"` + Email string `json:"email"` + Username string `json:"preferred_username"` + Name string `json:"name"` +} + +// exchange turns an authorization code into the caller's identity. +func (p *oidcProvider) exchange(code string) (*oidcIdentity, error) { + meta, err := p.metadata() + if err != nil { + return nil, err + } + form := url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "redirect_uri": {p.redirectURI}, + } + req, err := http.NewRequest(http.MethodPost, meta.TokenEndpoint, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(url.QueryEscape(p.cfg.ClientID), url.QueryEscape(p.cfg.ClientSecret)) + resp, err := p.http.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<12)) + return nil, fmt.Errorf("oidc: token endpoint: %s: %s", resp.Status, strings.TrimSpace(string(body))) + } + var tok struct { + AccessToken string `json:"access_token"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&tok); err != nil { + return nil, err + } + if tok.AccessToken == "" { + return nil, errors.New("oidc: token response carried no access token") + } + return p.userinfo(meta.UserinfoEndpoint, tok.AccessToken) +} + +func (p *oidcProvider) userinfo(endpoint, accessToken string) (*oidcIdentity, error) { + req, err := http.NewRequest(http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+accessToken) + resp, err := p.http.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("oidc: userinfo: %s", resp.Status) + } + var id oidcIdentity + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&id); err != nil { + return nil, err + } + if id.Subject == "" { + return nil, errors.New("oidc: userinfo carried no subject") + } + return &id, nil +} + +// handleOIDCLogin starts the authorization code flow for a pending device. +func (s *Server) handleOIDCLogin(w http.ResponseWriter, r *http.Request) { + if s.oidc == nil { + s.renderDone(w, "Single sign-on unavailable", "This server has no OIDC provider configured.") + return + } + meta, err := s.oidc.metadata() + if err != nil { + s.log.Printf("oidc discovery: %v", err) + s.renderDone(w, "Single sign-on unavailable", "The identity provider could not be reached.") + return + } + userCode := strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("code"))) + state := secret.Token() + if err := s.st.Update(func(st *store.State) error { + d := st.DeviceByUserCode(userCode) + if d == nil || time.Now().After(d.ExpiresAt) { + return errors.New("unknown or expired device code") + } + d.OIDCState = state + return nil + }); err != nil { + s.renderDevice(w, userCode, err.Error()) + return + } + q := url.Values{ + "response_type": {"code"}, + "client_id": {s.oidc.cfg.ClientID}, + "redirect_uri": {s.oidc.redirectURI}, + "scope": {s.oidc.cfg.Scopes}, + "state": {state}, + } + http.Redirect(w, r, meta.AuthorizationEndpoint+"?"+q.Encode(), http.StatusFound) +} + +// handleOIDCCallback finishes the flow and approves the waiting device. +func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) { + if s.oidc == nil { + s.renderDone(w, "Single sign-on unavailable", "This server has no OIDC provider configured.") + return + } + q := r.URL.Query() + if e := q.Get("error"); e != "" { + s.renderDone(w, "Sign-in failed", e) + return + } + state, code := q.Get("state"), q.Get("code") + if state == "" || code == "" { + s.renderDone(w, "Sign-in failed", "The provider did not return a code.") + return + } + // Bind the state to a pending device before spending the code. + var found bool + s.st.Read(func(st *store.State) { + for _, d := range st.Devices { + if d.OIDCState != "" && secret.Equal(d.OIDCState, state) && time.Now().Before(d.ExpiresAt) { + found = true + } + } + }) + if !found { + s.renderDone(w, "Sign-in failed", "This sign-in request has expired. Start again from the client.") + return + } + id, err := s.oidc.exchange(code) + if err != nil { + s.log.Printf("oidc exchange: %v", err) + s.renderDone(w, "Sign-in failed", "The identity provider rejected the sign-in.") + return + } + if err := s.st.Update(func(st *store.State) error { + var dev *store.DeviceAuth + for _, d := range st.Devices { + if d.OIDCState != "" && secret.Equal(d.OIDCState, state) { + dev = d + } + } + if dev == nil || time.Now().After(dev.ExpiresAt) { + return errors.New("this sign-in request has expired") + } + u, err := s.linkOIDCUser(st, id) + if err != nil { + return err + } + dev.OIDCState = "" + dev.SessionToken = s.newSession(st, u.ID, dev.Label).Token + return nil + }); err != nil { + s.renderDone(w, "Sign-in failed", err.Error()) + return + } + s.renderDone(w, "Device approved", "You can close this page and return to the application.") +} + +// linkOIDCUser finds the account behind an identity, provisioning one when +// the deployment allows it. +func (s *Server) linkOIDCUser(st *store.State, id *oidcIdentity) (*store.User, error) { + issuer := strings.TrimRight(s.oidc.cfg.Issuer, "/") + if u := st.UserByOIDC(issuer, id.Subject); u != nil { + if u.Disabled { + return nil, errors.New("this account is disabled") + } + return u, nil + } + // A local account may claim the identity by matching username or email, + // which is how an invited user upgrades to single sign-on. + for _, u := range st.Users { + if u.OIDCSubject != "" { + continue + } + if (id.Username != "" && u.Username == id.Username) || + (id.Email != "" && strings.EqualFold(u.Email, id.Email)) { + u.OIDCIssuer, u.OIDCSubject = issuer, id.Subject + return u, nil + } + } + if !s.oidc.cfg.AutoCreate { + return nil, errors.New("no account is linked to this identity; redeem an invite link first") + } + name := id.Username + if name == "" { + name = id.Email + } + if name == "" { + name = "sso-" + secret.ID() + } + if validUsername(name) != nil || st.UserByName(name) != nil { + name = "sso-" + secret.ID() + } + u := &store.User{ID: secret.ID(), Username: name, Email: id.Email, + OIDCIssuer: issuer, OIDCSubject: id.Subject, CreatedAt: time.Now()} + st.Users[u.ID] = u + if g := s.oidc.cfg.DefaultGroupID; g != "" && st.Groups[g] != nil { + st.Memberships = append(st.Memberships, store.Membership{UserID: u.ID, GroupID: g, Role: store.RoleMember}) + } + return u, nil +} diff --git a/internal/server/pages.go b/internal/server/pages.go new file mode 100644 index 0000000..4d93802 --- /dev/null +++ b/internal/server/pages.go @@ -0,0 +1,84 @@ +package server + +import ( + "html/template" + "net/http" +) + +// The server has no browsable UI. These three pages exist only because a +// browser is the right place to approve a device, redeem an invite and run an +// OIDC round trip. +var pages = template.Must(template.New("layout").Parse(` +{{define "layout"}} + + +{{.Title}} · KeepOrg +
{{template "body" .}}
{{end}} +`)) + +var deviceTmpl = template.Must(template.Must(pages.Clone()).Parse(` +{{define "body"}}

Approve this device

+

Sign in to let the client on your device sync your databases.

+{{if .Error}}

{{.Error}}

{{end}} +
+ + + + + +
+{{if .OIDC}}Sign in with single sign-on →{{end}} +{{end}}`)) + +var inviteTmpl = template.Must(template.Must(pages.Clone()).Parse(` +{{define "body"}}

Create your account

+

You were invited to a KeepOrg server.

+{{if .Error}}

{{.Error}}

{{end}} +
+ + + + + +
+{{end}}`)) + +var doneTmpl = template.Must(template.Must(pages.Clone()).Parse(` +{{define "body"}}

{{.Title}}

{{.Message}}

{{end}}`)) + +func (s *Server) renderDevice(w http.ResponseWriter, code, errMsg string) { + render(w, deviceTmpl, map[string]any{ + "Title": "Approve device", "Code": code, "Error": errMsg, "OIDC": s.oidc != nil, + }) +} + +func (s *Server) renderInvite(w http.ResponseWriter, token, errMsg string) { + render(w, inviteTmpl, map[string]any{"Title": "Create account", "Token": token, "Error": errMsg}) +} + +func (s *Server) renderDone(w http.ResponseWriter, title, msg string) { + render(w, doneTmpl, map[string]any{"Title": title, "Message": msg}) +} + +func render(w http.ResponseWriter, t *template.Template, data map[string]any) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Referrer-Policy", "no-referrer") + _ = t.ExecuteTemplate(w, "layout", data) +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..6ccd1cc --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,253 @@ +// Package server implements the KeepOrg synchronization server: accounts, +// nested groups, per-database grants, and the versioned KDBX sync endpoints. +// +// The server never opens a KDBX file. It moves opaque bytes and arbitrates +// who wrote last, which keeps the master passwords entirely on the clients. +package server + +import ( + "encoding/json" + "errors" + "log" + "net/http" + "path/filepath" + "strings" + "time" + + "cc.tiredsysadmin.keeporg/internal/blob" + "cc.tiredsysadmin.keeporg/internal/secret" + "cc.tiredsysadmin.keeporg/internal/store" +) + +// Config is the server's whole configuration surface. +type Config struct { + Addr string `json:"addr"` + DataDir string `json:"data_dir"` + ExternalURL string `json:"external_url"` + SessionTTL time.Duration `json:"session_ttl"` + MaxDBSize int64 `json:"max_db_size"` + TLSCert string `json:"tls_cert,omitempty"` + TLSKey string `json:"tls_key,omitempty"` + OIDC OIDCConfig `json:"oidc"` +} + +// Defaults fills in every value a deployment may leave unset. +func (c *Config) Defaults() { + if c.Addr == "" { + c.Addr = ":8765" + } + if c.DataDir == "" { + c.DataDir = "/var/lib/keeporg" + } + if c.ExternalURL == "" { + c.ExternalURL = "http://localhost" + c.Addr + } + c.ExternalURL = strings.TrimRight(c.ExternalURL, "/") + if c.SessionTTL == 0 { + c.SessionTTL = 90 * 24 * time.Hour + } + if c.MaxDBSize == 0 { + c.MaxDBSize = 32 << 20 + } +} + +type Server struct { + cfg Config + st *store.Store + blobs *blob.Store + hub *Hub + oidc *oidcProvider + mux *http.ServeMux + log *log.Logger +} + +// New opens the server's data directory and wires up its routes. +func New(cfg Config, lg *log.Logger) (*Server, error) { + cfg.Defaults() + st, err := store.Open(filepath.Join(cfg.DataDir, "state.json")) + if err != nil { + return nil, err + } + blobs, err := blob.Open(filepath.Join(cfg.DataDir, "blobs")) + if err != nil { + return nil, err + } + s := &Server{cfg: cfg, st: st, blobs: blobs, hub: NewHub(), mux: http.NewServeMux(), log: lg} + if cfg.OIDC.Enabled() { + s.oidc = newOIDCProvider(cfg.OIDC, cfg.ExternalURL+"/oidc/callback") + } + s.routes() + return s, nil +} + +// Store exposes the metadata store for bootstrap commands. +func (s *Server) Store() *store.Store { return s.st } + +// Config returns the effective configuration. +func (s *Server) Config() Config { return s.cfg } + +// Handler is the server's HTTP entry point. +func (s *Server) Handler() http.Handler { return s.mux } + +// StartHousekeeping runs periodic expiry until done is closed. +func (s *Server) StartHousekeeping(done <-chan struct{}) { + go func() { + t := time.NewTicker(time.Hour) + defer t.Stop() + for { + select { + case <-done: + return + case <-t.C: + if err := s.st.GC(time.Now()); err != nil { + s.log.Printf("housekeeping: %v", err) + } + } + } + }() +} + +func (s *Server) routes() { + m := s.mux + m.HandleFunc("GET /healthz", s.handleHealth) + + // Authentication. + m.HandleFunc("POST /api/v1/auth/login", s.handleLogin) + m.HandleFunc("POST /api/v1/auth/logout", s.authed(store.PermNone, s.handleLogout)) + m.HandleFunc("GET /api/v1/auth/whoami", s.authed(store.PermNone, s.handleWhoami)) + m.HandleFunc("POST /api/v1/auth/totp/enroll", s.authed(store.PermNone, s.handleTOTPEnroll)) + m.HandleFunc("POST /api/v1/auth/totp/confirm", s.authed(store.PermNone, s.handleTOTPConfirm)) + m.HandleFunc("POST /api/v1/auth/device/start", s.handleDeviceStart) + m.HandleFunc("POST /api/v1/auth/device/poll", s.handleDevicePoll) + + // Browser-facing pages: device approval, invite registration, OIDC. + m.HandleFunc("GET /device", s.handleDevicePage) + m.HandleFunc("POST /device", s.handleDeviceApprove) + m.HandleFunc("GET /invite/{token}", s.handleInvitePage) + m.HandleFunc("POST /invite/{token}", s.handleInviteRedeem) + m.HandleFunc("GET /oidc/login", s.handleOIDCLogin) + m.HandleFunc("GET /oidc/callback", s.handleOIDCCallback) + + // Synchronization. + m.HandleFunc("GET /api/v1/databases", s.authed(store.PermNone, s.handleListDatabases)) + m.HandleFunc("GET /api/v1/databases/{id}/blob", s.authed(store.PermNone, s.handleGetBlob)) + m.HandleFunc("PUT /api/v1/databases/{id}/blob", s.authed(store.PermNone, s.handlePutBlob)) + m.HandleFunc("GET /api/v1/events", s.authed(store.PermNone, s.handleEvents)) + + // Administration. + m.HandleFunc("POST /api/v1/databases", s.authed(store.PermNone, s.handleCreateDatabase)) + m.HandleFunc("PATCH /api/v1/databases/{id}", s.authed(store.PermNone, s.handlePatchDatabase)) + m.HandleFunc("DELETE /api/v1/databases/{id}", s.authed(store.PermNone, s.handleDeleteDatabase)) + m.HandleFunc("GET /api/v1/groups", s.authed(store.PermNone, s.handleListGroups)) + m.HandleFunc("POST /api/v1/groups", s.authed(store.PermNone, s.handleCreateGroup)) + m.HandleFunc("DELETE /api/v1/groups/{id}", s.authed(store.PermNone, s.handleDeleteGroup)) + m.HandleFunc("POST /api/v1/groups/{id}/members", s.authed(store.PermNone, s.handleSetMember)) + m.HandleFunc("DELETE /api/v1/groups/{id}/members/{user}", s.authed(store.PermNone, s.handleRemoveMember)) + m.HandleFunc("POST /api/v1/grants", s.authed(store.PermNone, s.handleSetGrant)) + m.HandleFunc("DELETE /api/v1/grants", s.authed(store.PermNone, s.handleDeleteGrant)) + m.HandleFunc("POST /api/v1/invites", s.authed(store.PermNone, s.handleCreateInvite)) + m.HandleFunc("GET /api/v1/invites", s.authed(store.PermNone, s.handleListInvites)) + m.HandleFunc("GET /api/v1/users", s.authed(store.PermNone, s.handleListUsers)) +} + +// ---- request plumbing ---- + +type apiError struct { + Code string `json:"code"` + Message string `json:"error"` +} + +var errUnauthorized = errors.New("unauthorized") + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if v != nil { + _ = json.NewEncoder(w).Encode(v) + } +} + +func fail(w http.ResponseWriter, status int, code, msg string) { + writeJSON(w, status, apiError{Code: code, Message: msg}) +} + +func readJSON(w http.ResponseWriter, r *http.Request, v any) bool { + dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) + dec.DisallowUnknownFields() + if err := dec.Decode(v); err != nil { + fail(w, http.StatusBadRequest, "bad_request", err.Error()) + return false + } + return true +} + +// handler is an authenticated request handler. +type handler func(http.ResponseWriter, *http.Request, *store.User) + +// authed resolves the bearer token to a live, enabled user before calling fn. +// Per-object permissions are checked inside each handler, where the object is +// known; min is accepted for documentation of the minimum account level. +func (s *Server) authed(_ store.Perm, fn handler) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + u, err := s.userFromRequest(r) + if err != nil { + w.Header().Set("WWW-Authenticate", `Bearer realm="keeporg"`) + fail(w, http.StatusUnauthorized, "unauthorized", "invalid or expired token") + return + } + fn(w, r, u) + } +} + +func (s *Server) userFromRequest(r *http.Request) (*store.User, error) { + tok := bearer(r) + if tok == "" { + return nil, errUnauthorized + } + var out *store.User + s.st.Read(func(st *store.State) { + sess := st.Sessions[tok] + if sess == nil || time.Now().After(sess.ExpiresAt) { + return + } + if u := st.Users[sess.UserID]; u != nil && !u.Disabled { + cp := *u + out = &cp + } + }) + if out == nil { + return nil, errUnauthorized + } + return out, nil +} + +// bearer extracts a token from the Authorization header, or from the +// access_token query parameter, which EventSource clients need because they +// cannot set headers. +func bearer(r *http.Request) string { + h := r.Header.Get("Authorization") + if after, ok := strings.CutPrefix(h, "Bearer "); ok { + return strings.TrimSpace(after) + } + return r.URL.Query().Get("access_token") +} + +func (s *Server) newSession(st *store.State, userID, label string) *store.Session { + sess := &store.Session{ + Token: secret.Token(), + UserID: userID, + Label: label, + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(s.cfg.SessionTTL), + } + st.Sessions[sess.Token] = sess + return sess +} + +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + var users, dbs int + s.st.Read(func(st *store.State) { users, dbs = len(st.Users), len(st.Databases) }) + writeJSON(w, http.StatusOK, map[string]any{ + "status": "ok", "users": users, "databases": dbs, "streams": s.hub.Count(), + }) +} diff --git a/internal/server/sync.go b/internal/server/sync.go new file mode 100644 index 0000000..7f2f095 --- /dev/null +++ b/internal/server/sync.go @@ -0,0 +1,284 @@ +package server + +import ( + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + "cc.tiredsysadmin.keeporg/internal/blob" + "cc.tiredsysadmin.keeporg/internal/store" +) + +// dbView is what a client learns about a database it has access to. +type dbView struct { + ID string `json:"id"` + Name string `json:"name"` + OwnerGroupID string `json:"owner_group_id"` + Perm store.Perm `json:"perm"` + MergeStrategy store.Strategy `json:"merge_strategy"` + Version int64 `json:"version"` + SHA256 string `json:"sha256"` + Size int64 `json:"size"` + UpdatedAt time.Time `json:"updated_at"` + UpdatedBy string `json:"updated_by,omitempty"` +} + +func toView(db *store.Database, p store.Perm) dbView { + return dbView{ID: db.ID, Name: db.Name, OwnerGroupID: db.OwnerGroupID, Perm: p, + MergeStrategy: db.MergeStrategy, Version: db.Version, SHA256: db.SHA256, + Size: db.Size, UpdatedAt: db.UpdatedAt, UpdatedBy: db.UpdatedBy} +} + +func (s *Server) handleListDatabases(w http.ResponseWriter, r *http.Request, u *store.User) { + out := []dbView{} + s.st.Read(func(st *store.State) { + for _, a := range st.Assignments(u.ID) { + out = append(out, toView(a.DB, a.Perm)) + } + }) + writeJSON(w, http.StatusOK, map[string]any{"databases": out}) +} + +// handleGetBlob serves the current version of a database, or a historical one +// when ?version= is given. +func (s *Server) handleGetBlob(w http.ResponseWriter, r *http.Request, u *store.User) { + id := r.PathValue("id") + var ( + db store.Database + perm store.Perm + ) + s.st.Read(func(st *store.State) { + perm = st.DatabasePerm(u.ID, id) + if d := st.Databases[id]; d != nil { + db = *d + } + }) + if !perm.AtLeast(store.PermRead) { + fail(w, http.StatusNotFound, "not_found", "no such database") + return + } + version := db.Version + if q := r.URL.Query().Get("version"); q != "" { + v, err := strconv.ParseInt(q, 10, 64) + if err != nil { + fail(w, http.StatusBadRequest, "bad_request", "version must be an integer") + return + } + version = v + } + if version == 0 { + // A database that has never been written has no bytes yet. + w.Header().Set("ETag", `"0"`) + w.Header().Set("Content-Type", "application/octet-stream") + w.WriteHeader(http.StatusNoContent) + return + } + data, err := s.blobs.Get(id, version) + if err != nil { + fail(w, http.StatusNotFound, "not_found", "that version is no longer stored") + return + } + w.Header().Set("ETag", etag(version)) + w.Header().Set("X-KeepOrg-SHA256", blob.SHA256(data)) + w.Header().Set("X-KeepOrg-Version", strconv.FormatInt(version, 10)) + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", db.Name+".kdbx")) + w.Header().Set("Content-Length", strconv.Itoa(len(data))) + _, _ = w.Write(data) +} + +// handlePutBlob stores a new version. The client states the version it edited +// in If-Match; the stored merge strategy decides what happens if that is no +// longer current. +func (s *Server) handlePutBlob(w http.ResponseWriter, r *http.Request, u *store.User) { + id := r.PathValue("id") + var ( + perm store.Perm + current int64 + strategy store.Strategy + exists bool + ) + s.st.Read(func(st *store.State) { + perm = st.DatabasePerm(u.ID, id) + if d := st.Databases[id]; d != nil { + exists, current, strategy = true, d.Version, d.MergeStrategy + } + }) + if !exists || !perm.AtLeast(store.PermRead) { + fail(w, http.StatusNotFound, "not_found", "no such database") + return + } + if !perm.AtLeast(store.PermWrite) { + fail(w, http.StatusForbidden, "forbidden", "you have read-only access to this database") + return + } + v := decidePush(current, r.Header.Get("If-Match"), strategy) + if !v.Accept { + w.Header().Set("ETag", etag(current)) + w.Header().Set("X-KeepOrg-Strategy", string(strategy)) + writeJSON(w, v.Status, map[string]any{ + "code": v.Code, "error": v.Message, + "current_version": current, "merge_strategy": strategy, + }) + return + } + data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, s.cfg.MaxDBSize)) + if err != nil { + fail(w, http.StatusRequestEntityTooLarge, "too_large", + fmt.Sprintf("database exceeds the %d byte limit", s.cfg.MaxDBSize)) + return + } + if len(data) == 0 { + fail(w, http.StatusBadRequest, "bad_request", "empty body") + return + } + sum := blob.SHA256(data) + if want := r.Header.Get("X-KeepOrg-SHA256"); want != "" && !strings.EqualFold(want, sum) { + fail(w, http.StatusBadRequest, "checksum_mismatch", "body does not match the declared digest") + return + } + + var ( + next int64 + unchanged bool + watchers []string + name string + ) + err = s.st.Update(func(st *store.State) error { + d := st.Databases[id] + if d == nil { + return errors.New("no such database") + } + // Re-check under the write lock: a concurrent push may have landed. + if vv := decidePush(d.Version, r.Header.Get("If-Match"), d.MergeStrategy); !vv.Accept { + return conflictError{current: d.Version, strategy: d.MergeStrategy, verdict: vv} + } + if d.SHA256 == sum { + next, unchanged, name = d.Version, true, d.Name + return nil + } + next = d.Version + 1 + if err := s.blobs.Put(id, next, data); err != nil { + return err + } + d.Version, d.SHA256, d.Size = next, sum, int64(len(data)) + d.UpdatedAt, d.UpdatedBy = time.Now(), u.Username + name = d.Name + watchers = st.Watchers(id) + return nil + }) + var ce conflictError + switch { + case errors.As(err, &ce): + w.Header().Set("ETag", etag(ce.current)) + w.Header().Set("X-KeepOrg-Strategy", string(ce.strategy)) + writeJSON(w, ce.verdict.Status, map[string]any{ + "code": ce.verdict.Code, "error": ce.verdict.Message, + "current_version": ce.current, "merge_strategy": ce.strategy, + }) + return + case err != nil: + fail(w, http.StatusInternalServerError, "internal", err.Error()) + return + } + if !unchanged { + _ = s.blobs.Prune(id) + s.hub.Publish(watchers, Event{Type: EventUpdated, DatabaseID: id, Name: name, + Version: next, SHA256: sum, By: u.Username}) + } + w.Header().Set("ETag", etag(next)) + writeJSON(w, http.StatusOK, map[string]any{ + "version": next, "sha256": sum, "unchanged": unchanged, + }) +} + +// handleEvents streams database updates to one client over SSE. SSE is enough +// here because the channel only ever carries server-to-client notifications. +func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request, u *store.User) { + flusher, ok := w.(http.Flusher) + if !ok { + fail(w, http.StatusInternalServerError, "internal", "streaming unsupported") + return + } + id, ch := s.hub.Subscribe(u.ID) + defer s.hub.Unsubscribe(id) + + h := w.Header() + h.Set("Content-Type", "text/event-stream") + h.Set("Cache-Control", "no-store") + h.Set("Connection", "keep-alive") + h.Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, "retry: 3000\n\n") + flusher.Flush() + + keepalive := time.NewTicker(25 * time.Second) + defer keepalive.Stop() + for { + select { + case <-r.Context().Done(): + return + case ev, open := <-ch: + if !open { + return + } + fmt.Fprintf(w, "event: %s\ndata: %s\n\n", ev.Type, ev.encode()) + flusher.Flush() + case <-keepalive.C: + fmt.Fprint(w, ": keepalive\n\n") + flusher.Flush() + } + } +} + +// ---- the push decision, kept pure ---- + +// verdict is the outcome of comparing a push against the stored version. +type verdict struct { + Accept bool + Status int + Code string + Message string +} + +type conflictError struct { + current int64 + strategy store.Strategy + verdict verdict +} + +func (e conflictError) Error() string { return e.verdict.Message } + +// decidePush is the whole merge policy. The client sends the version it based +// its edit on in If-Match; If-Match: * means "I know I am stale and I intend +// to win", which only a client-wins database allows. +func decidePush(current int64, ifMatch string, strategy store.Strategy) verdict { + ifMatch = strings.TrimSpace(ifMatch) + switch { + case ifMatch == "": + return verdict{Status: http.StatusPreconditionRequired, Code: "precondition_required", + Message: "If-Match must carry the version this edit was based on"} + case ifMatch == "*": + if strategy == store.StrategyClientWins { + return verdict{Accept: true} + } + return verdict{Status: http.StatusConflict, Code: "force_not_allowed", + Message: "this database does not allow a client to overwrite the server copy"} + } + v, err := strconv.ParseInt(strings.Trim(ifMatch, `"`), 10, 64) + if err != nil { + return verdict{Status: http.StatusBadRequest, Code: "bad_request", + Message: "If-Match must be a version number or *"} + } + if v == current { + return verdict{Accept: true} + } + return verdict{Status: http.StatusConflict, Code: "conflict", + Message: fmt.Sprintf("the server is at version %d, this edit was based on %d", current, v)} +} + +func etag(v int64) string { return `"` + strconv.FormatInt(v, 10) + `"` } diff --git a/internal/server/sync_test.go b/internal/server/sync_test.go new file mode 100644 index 0000000..9dab6aa --- /dev/null +++ b/internal/server/sync_test.go @@ -0,0 +1,49 @@ +package server + +import ( + "net/http" + "testing" + + "cc.tiredsysadmin.keeporg/internal/store" +) + +func TestDecidePushRequiresIfMatch(t *testing.T) { + v := decidePush(3, "", store.StrategyServerWins) + if v.Accept || v.Status != http.StatusPreconditionRequired { + t.Fatalf("verdict = %+v", v) + } +} + +func TestDecidePushAcceptsCurrentVersion(t *testing.T) { + for _, m := range []string{`"3"`, "3", ` "3" `} { + if v := decidePush(3, m, store.StrategyServerWins); !v.Accept { + t.Fatalf("If-Match %q rejected: %+v", m, v) + } + } +} + +func TestDecidePushRejectsStaleVersion(t *testing.T) { + v := decidePush(4, `"3"`, store.StrategyServerWins) + if v.Accept || v.Status != http.StatusConflict || v.Code != "conflict" { + t.Fatalf("verdict = %+v", v) + } +} + +func TestForceOnlyAllowedForClientWins(t *testing.T) { + if v := decidePush(4, "*", store.StrategyClientWins); !v.Accept { + t.Fatalf("client-wins force rejected: %+v", v) + } + for _, s := range []store.Strategy{store.StrategyServerWins, store.StrategyManual} { + v := decidePush(4, "*", s) + if v.Accept || v.Code != "force_not_allowed" { + t.Fatalf("force under %s = %+v", s, v) + } + } +} + +func TestDecidePushRejectsJunkIfMatch(t *testing.T) { + v := decidePush(1, `"abc"`, store.StrategyServerWins) + if v.Accept || v.Status != http.StatusBadRequest { + t.Fatalf("verdict = %+v", v) + } +} diff --git a/internal/store/acl.go b/internal/store/acl.go new file mode 100644 index 0000000..727a2cf --- /dev/null +++ b/internal/store/acl.go @@ -0,0 +1,143 @@ +package store + +import "sort" + +// maxDepth bounds every tree walk so a corrupted parent cycle cannot hang the +// server. Group creation rejects cycles, so this is a backstop only. +const maxDepth = 64 + +// Ancestors returns groupID and every parent above it, nearest first. +func (s *State) Ancestors(groupID string) []string { + out := []string{} + seen := map[string]bool{} + for id := groupID; id != "" && !seen[id] && len(out) < maxDepth; { + g, ok := s.Groups[id] + if !ok { + break + } + seen[id] = true + out = append(out, id) + id = g.ParentID + } + return out +} + +// Descendants returns groupID and every group below it. +func (s *State) Descendants(groupID string) []string { + if _, ok := s.Groups[groupID]; !ok { + return nil + } + out := []string{groupID} + for i := 0; i < len(out); i++ { + for id, g := range s.Groups { + if g.ParentID == out[i] && !contains(out, id) { + out = append(out, id) + } + } + } + sort.Strings(out) + return out +} + +// IsAncestor reports whether anc is at or above desc in the tree. +func (s *State) IsAncestor(anc, desc string) bool { + return contains(s.Ancestors(desc), anc) +} + +// EffectiveGroups is the set of groups whose grants reach the user: every +// group the user belongs to, plus every group above those. A grant on +// "Company" therefore reaches a member of "Company/Engineering/Backend". +func (s *State) EffectiveGroups(userID string) map[string]bool { + out := map[string]bool{} + for _, m := range s.Memberships { + if m.UserID != userID { + continue + } + for _, id := range s.Ancestors(m.GroupID) { + out[id] = true + } + } + return out +} + +// IsGroupAdmin reports whether the user administers groupID, either directly +// or by administering a group above it. Server admins administer everything. +func (s *State) IsGroupAdmin(userID, groupID string) bool { + if u := s.Users[userID]; u != nil && u.ServerAdmin { + return true + } + anc := s.Ancestors(groupID) + for _, m := range s.Memberships { + if m.UserID == userID && m.Role == RoleAdmin && contains(anc, m.GroupID) { + return true + } + } + return false +} + +// DatabasePerm is the strongest permission the user holds on a database. +func (s *State) DatabasePerm(userID, dbID string) Perm { + db, ok := s.Databases[dbID] + if !ok { + return PermNone + } + if s.IsGroupAdmin(userID, db.OwnerGroupID) { + return PermAdmin + } + reach := s.EffectiveGroups(userID) + best := PermNone + for _, g := range s.Grants { + if g.DatabaseID != dbID || !reach[g.GroupID] { + continue + } + if g.Perm.Rank() > best.Rank() { + best = g.Perm + } + } + return best +} + +// Assignment pairs a database with the caller's permission on it. +type Assignment struct { + DB *Database + Perm Perm +} + +// Assignments lists every database the user may see, strongest permission +// first resolved, sorted by name for a stable client view. +func (s *State) Assignments(userID string) []Assignment { + out := []Assignment{} + for id, db := range s.Databases { + if p := s.DatabasePerm(userID, id); p != PermNone { + out = append(out, Assignment{DB: db, Perm: p}) + } + } + sort.Slice(out, func(i, j int) bool { + if out[i].DB.Name != out[j].DB.Name { + return out[i].DB.Name < out[j].DB.Name + } + return out[i].DB.ID < out[j].DB.ID + }) + return out +} + +// Watchers lists the user IDs that must be told when a database changes. +func (s *State) Watchers(dbID string) []string { + out := []string{} + for id := range s.Users { + if s.DatabasePerm(id, dbID) != PermNone { + out = append(out, id) + } + } + sort.Strings(out) + return out +} + +func contains(ss []string, s string) bool { + for _, v := range ss { + if v == s { + return true + } + } + return false +} diff --git a/internal/store/acl_test.go b/internal/store/acl_test.go new file mode 100644 index 0000000..afc8cee --- /dev/null +++ b/internal/store/acl_test.go @@ -0,0 +1,90 @@ +package store + +import "testing" + +// tree: company > engineering > backend, company > sales +func aclFixture() *State { + s := newState() + s.Groups["company"] = &Group{ID: "company", Name: "Company"} + s.Groups["eng"] = &Group{ID: "eng", Name: "Engineering", ParentID: "company"} + s.Groups["backend"] = &Group{ID: "backend", Name: "Backend", ParentID: "eng"} + s.Groups["sales"] = &Group{ID: "sales", Name: "Sales", ParentID: "company"} + s.Users["alice"] = &User{ID: "alice", Username: "alice"} + s.Users["bob"] = &User{ID: "bob", Username: "bob"} + s.Users["root"] = &User{ID: "root", Username: "root", ServerAdmin: true} + s.Memberships = []Membership{ + {UserID: "alice", GroupID: "backend", Role: RoleMember}, + {UserID: "bob", GroupID: "sales", Role: RoleAdmin}, + } + s.Databases["shared"] = &Database{ID: "shared", Name: "Shared", OwnerGroupID: "company"} + s.Databases["salesdb"] = &Database{ID: "salesdb", Name: "Sales", OwnerGroupID: "sales"} + s.Grants = []Grant{ + {GroupID: "company", DatabaseID: "shared", Perm: PermRead}, + {GroupID: "eng", DatabaseID: "shared", Perm: PermWrite}, + } + return s +} + +func TestGrantOnAncestorReachesDescendantMember(t *testing.T) { + s := aclFixture() + if got := s.DatabasePerm("alice", "shared"); got != PermWrite { + t.Fatalf("alice on shared = %q, want write (read from company, write from eng)", got) + } +} + +func TestGrantDoesNotLeakAcrossSiblings(t *testing.T) { + s := aclFixture() + if got := s.DatabasePerm("alice", "salesdb"); got != PermNone { + t.Fatalf("alice on salesdb = %q, want none", got) + } + if got := s.DatabasePerm("bob", "shared"); got != PermRead { + t.Fatalf("bob on shared = %q, want read", got) + } +} + +func TestOwnerGroupAdminGetsAdmin(t *testing.T) { + s := aclFixture() + if got := s.DatabasePerm("bob", "salesdb"); got != PermAdmin { + t.Fatalf("bob on own group db = %q, want admin", got) + } + if !s.IsGroupAdmin("root", "backend") { + t.Fatal("server admin should administer any group") + } + if s.IsGroupAdmin("bob", "eng") { + t.Fatal("sales admin must not administer engineering") + } +} + +func TestAdminOfParentAdministersChild(t *testing.T) { + s := aclFixture() + s.Memberships = append(s.Memberships, Membership{UserID: "alice", GroupID: "company", Role: RoleAdmin}) + if !s.IsGroupAdmin("alice", "backend") { + t.Fatal("company admin should administer backend") + } + if got := s.DatabasePerm("alice", "salesdb"); got != PermAdmin { + t.Fatalf("company admin on salesdb = %q, want admin", got) + } +} + +func TestAssignmentsAndWatchers(t *testing.T) { + s := aclFixture() + got := s.Assignments("alice") + if len(got) != 1 || got[0].DB.ID != "shared" || got[0].Perm != PermWrite { + t.Fatalf("alice assignments = %+v", got) + } + w := s.Watchers("shared") + if len(w) != 3 { + t.Fatalf("watchers of shared = %v, want alice, bob and root", w) + } +} + +func TestTreeWalksSurviveCycle(t *testing.T) { + s := aclFixture() + s.Groups["company"].ParentID = "backend" // corrupt + if len(s.Ancestors("backend")) != 3 { + t.Fatalf("cycle walk = %v", s.Ancestors("backend")) + } + if len(s.Descendants("company")) == 0 { + t.Fatal("descendants must terminate on a cycle") + } +} diff --git a/internal/store/model.go b/internal/store/model.go new file mode 100644 index 0000000..6258730 --- /dev/null +++ b/internal/store/model.go @@ -0,0 +1,223 @@ +// Package store holds the server's authoritative metadata: users, nested +// groups, databases and the grants that connect them. +// +// Glossary: +// +// User an account, authenticated by password+TOTP or by OIDC. +// Group a node in a single tree of groups. Grants hang off groups. +// Membership a user's place in one group, as member or admin. +// Database one KDBX file tracked by the server. Owned by one group. +// Grant a (group, database, permission) triple. +// Invite a single- or multi-use registration token. +// Session a bearer token held by a client device. +package store + +import "time" + +// Perm is a permission on a database, ordered read < write < admin. +type Perm string + +const ( + PermNone Perm = "" + PermRead Perm = "read" + PermWrite Perm = "write" + PermAdmin Perm = "admin" +) + +var permRank = map[Perm]int{PermNone: 0, PermRead: 1, PermWrite: 2, PermAdmin: 3} + +// Rank orders permissions so the strongest of several grants can be picked. +func (p Perm) Rank() int { return permRank[p] } + +// AtLeast reports whether p is as strong as want. +func (p Perm) AtLeast(want Perm) bool { return p.Rank() >= want.Rank() } + +// Valid reports whether p is a permission that may be stored. +func (p Perm) Valid() bool { return p == PermRead || p == PermWrite || p == PermAdmin } + +// Role is a user's role inside a group. +type Role string + +const ( + RoleMember Role = "member" + RoleAdmin Role = "admin" +) + +// Valid reports whether r is a role that may be stored. +func (r Role) Valid() bool { return r == RoleMember || r == RoleAdmin } + +// Strategy decides what happens when a client pushes a database version that +// is no longer current. +type Strategy string + +const ( + // StrategyServerWins rejects the stale push; the client takes the server + // copy and keeps its own as a conflict file. This is the default. + StrategyServerWins Strategy = "server-wins" + // StrategyClientWins lets the client overwrite the server copy. + StrategyClientWins Strategy = "client-wins" + // StrategyManual rejects the stale push and leaves both copies in place + // for a human to reconcile. + StrategyManual Strategy = "manual" +) + +// Valid reports whether s is a strategy that may be stored. +func (s Strategy) Valid() bool { + return s == StrategyServerWins || s == StrategyClientWins || s == StrategyManual +} + +type User struct { + ID string `json:"id"` + Username string `json:"username"` + Email string `json:"email,omitempty"` + PassHash string `json:"pass_hash,omitempty"` + OIDCIssuer string `json:"oidc_issuer,omitempty"` + OIDCSubject string `json:"oidc_subject,omitempty"` + TOTPSecret string `json:"totp_secret,omitempty"` + TOTPConfirmed bool `json:"totp_confirmed"` + ServerAdmin bool `json:"server_admin"` + Disabled bool `json:"disabled"` + CreatedAt time.Time `json:"created_at"` +} + +type Group struct { + ID string `json:"id"` + Name string `json:"name"` + ParentID string `json:"parent_id,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type Membership struct { + UserID string `json:"user_id"` + GroupID string `json:"group_id"` + Role Role `json:"role"` +} + +type Database struct { + ID string `json:"id"` + Name string `json:"name"` + OwnerGroupID string `json:"owner_group_id"` + MergeStrategy Strategy `json:"merge_strategy"` + Version int64 `json:"version"` + SHA256 string `json:"sha256"` + Size int64 `json:"size"` + UpdatedAt time.Time `json:"updated_at"` + UpdatedBy string `json:"updated_by,omitempty"` +} + +type Grant struct { + GroupID string `json:"group_id"` + DatabaseID string `json:"database_id"` + Perm Perm `json:"perm"` +} + +type Invite struct { + Token string `json:"token"` + GroupID string `json:"group_id,omitempty"` + Role Role `json:"role"` + CreatedBy string `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` + MaxUses int `json:"max_uses"` + Uses int `json:"uses"` +} + +// Usable reports whether the invite may still be redeemed at time now. +func (i *Invite) Usable(now time.Time) bool { + return now.Before(i.ExpiresAt) && (i.MaxUses <= 0 || i.Uses < i.MaxUses) +} + +type Session struct { + Token string `json:"token"` + UserID string `json:"user_id"` + Label string `json:"label,omitempty"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` +} + +// DeviceAuth backs the browser-assisted login used for OIDC on desktop and +// Android: the client shows a short user code, the browser approves it, the +// client polls until a session token appears. +type DeviceAuth struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + Label string `json:"label,omitempty"` + SessionToken string `json:"session_token,omitempty"` + OIDCState string `json:"oidc_state,omitempty"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt time.Time `json:"expires_at"` +} + +// State is the whole metadata set, serialized as one JSON document. +type State struct { + Users map[string]*User `json:"users"` + Groups map[string]*Group `json:"groups"` + Databases map[string]*Database `json:"databases"` + Invites map[string]*Invite `json:"invites"` + Sessions map[string]*Session `json:"sessions"` + Devices map[string]*DeviceAuth `json:"devices"` + Memberships []Membership `json:"memberships"` + Grants []Grant `json:"grants"` +} + +func newState() *State { + return &State{ + Users: map[string]*User{}, + Groups: map[string]*Group{}, + Databases: map[string]*Database{}, + Invites: map[string]*Invite{}, + Sessions: map[string]*Session{}, + Devices: map[string]*DeviceAuth{}, + } +} + +func (s *State) init() { + if s.Users == nil { + s.Users = map[string]*User{} + } + if s.Groups == nil { + s.Groups = map[string]*Group{} + } + if s.Databases == nil { + s.Databases = map[string]*Database{} + } + if s.Invites == nil { + s.Invites = map[string]*Invite{} + } + if s.Sessions == nil { + s.Sessions = map[string]*Session{} + } + if s.Devices == nil { + s.Devices = map[string]*DeviceAuth{} + } +} + +// UserByName finds a user by exact username. +func (s *State) UserByName(name string) *User { + for _, u := range s.Users { + if u.Username == name { + return u + } + } + return nil +} + +// UserByOIDC finds a user previously linked to an OIDC issuer and subject. +func (s *State) UserByOIDC(issuer, subject string) *User { + for _, u := range s.Users { + if u.OIDCIssuer == issuer && u.OIDCSubject == subject { + return u + } + } + return nil +} + +// DeviceByUserCode finds a pending device login by the code shown to the user. +func (s *State) DeviceByUserCode(code string) *DeviceAuth { + for _, d := range s.Devices { + if d.UserCode == code { + return d + } + } + return nil +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..26ea1cb --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,136 @@ +package store + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" +) + +// Store is the single writer of the metadata document. Every mutation runs on +// a private copy of the state and is swapped in only once it has reached the +// disk, so a reader never observes a half-applied update and a crash never +// leaves a partial file. +type Store struct { + path string + mu sync.RWMutex + s *State +} + +// Open loads the state document at path, creating an empty one if absent. +func Open(path string) (*Store, error) { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return nil, err + } + st := &Store{path: path, s: newState()} + b, err := os.ReadFile(path) + switch { + case os.IsNotExist(err): + return st, st.persist(st.s) + case err != nil: + return nil, err + } + s := newState() + if err := json.Unmarshal(b, s); err != nil { + return nil, fmt.Errorf("store: parse %s: %w", path, err) + } + s.init() + st.s = s + return st, nil +} + +// Read runs fn against a consistent view of the state. fn must not retain or +// mutate anything it is given. +func (st *Store) Read(fn func(*State)) { + st.mu.RLock() + defer st.mu.RUnlock() + fn(st.s) +} + +// Update applies fn to a copy of the state and commits it. If fn or the write +// fails, the stored state is left untouched. +func (st *Store) Update(fn func(*State) error) error { + st.mu.Lock() + defer st.mu.Unlock() + next, err := clone(st.s) + if err != nil { + return err + } + if err := fn(next); err != nil { + return err + } + if err := st.persist(next); err != nil { + return err + } + st.s = next + return nil +} + +// GC drops expired sessions, device logins and invites. +func (st *Store) GC(now time.Time) error { + return st.Update(func(s *State) error { + for k, v := range s.Sessions { + if now.After(v.ExpiresAt) { + delete(s.Sessions, k) + } + } + for k, v := range s.Devices { + if now.After(v.ExpiresAt) { + delete(s.Devices, k) + } + } + for k, v := range s.Invites { + if !v.Usable(now) { + delete(s.Invites, k) + } + } + return nil + }) +} + +func (st *Store) persist(s *State) error { + b, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + return writeFileAtomic(st.path, b, 0o600) +} + +func clone(s *State) (*State, error) { + b, err := json.Marshal(s) + if err != nil { + return nil, err + } + out := newState() + if err := json.Unmarshal(b, out); err != nil { + return nil, err + } + out.init() + return out, nil +} + +// writeFileAtomic replaces path with data, surviving a crash mid-write. +func writeFileAtomic(path string, data []byte, mode os.FileMode) error { + tmp := path + ".tmp" + f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode) + if err != nil { + return err + } + if _, err := f.Write(data); err != nil { + f.Close() + os.Remove(tmp) + return err + } + if err := f.Sync(); err != nil { + f.Close() + os.Remove(tmp) + return err + } + if err := f.Close(); err != nil { + os.Remove(tmp) + return err + } + return os.Rename(tmp, path) +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..2b13a74 --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,76 @@ +package store + +import ( + "errors" + "os" + "path/filepath" + "testing" + "time" +) + +func TestUpdatePersistsAndSurvivesReopen(t *testing.T) { + p := filepath.Join(t.TempDir(), "state.json") + st, err := Open(p) + if err != nil { + t.Fatal(err) + } + if err := st.Update(func(s *State) error { + s.Users["u1"] = &User{ID: "u1", Username: "alice"} + return nil + }); err != nil { + t.Fatal(err) + } + again, err := Open(p) + if err != nil { + t.Fatal(err) + } + var name string + again.Read(func(s *State) { name = s.Users["u1"].Username }) + if name != "alice" { + t.Fatalf("reopened username = %q", name) + } + if fi, _ := os.Stat(p); fi.Mode().Perm() != 0o600 { + t.Fatalf("state file mode = %v", fi.Mode().Perm()) + } +} + +func TestFailedUpdateLeavesStateUntouched(t *testing.T) { + st, err := Open(filepath.Join(t.TempDir(), "state.json")) + if err != nil { + t.Fatal(err) + } + boom := errors.New("boom") + if err := st.Update(func(s *State) error { + s.Users["u1"] = &User{ID: "u1"} + return boom + }); !errors.Is(err, boom) { + t.Fatalf("err = %v", err) + } + var n int + st.Read(func(s *State) { n = len(s.Users) }) + if n != 0 { + t.Fatalf("users after failed update = %d", n) + } +} + +func TestGCDropsExpired(t *testing.T) { + st, _ := Open(filepath.Join(t.TempDir(), "state.json")) + now := time.Now() + _ = st.Update(func(s *State) error { + s.Sessions["old"] = &Session{Token: "old", ExpiresAt: now.Add(-time.Hour)} + s.Sessions["new"] = &Session{Token: "new", ExpiresAt: now.Add(time.Hour)} + s.Invites["spent"] = &Invite{Token: "spent", ExpiresAt: now.Add(time.Hour), MaxUses: 1, Uses: 1} + return nil + }) + if err := st.GC(now); err != nil { + t.Fatal(err) + } + st.Read(func(s *State) { + if len(s.Sessions) != 1 || s.Sessions["new"] == nil { + t.Fatalf("sessions after GC = %v", s.Sessions) + } + if len(s.Invites) != 0 { + t.Fatalf("invites after GC = %v", s.Invites) + } + }) +} diff --git a/internal/totp/totp.go b/internal/totp/totp.go new file mode 100644 index 0000000..071f8aa --- /dev/null +++ b/internal/totp/totp.go @@ -0,0 +1,90 @@ +// Package totp implements RFC 6238 time-based one-time passwords, the +// built-in second factor for password logins. +package totp + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha1" + "encoding/base32" + "encoding/binary" + "fmt" + "net/url" + "strings" + "time" +) + +const ( + digits = 6 + step = 30 * time.Second + // skew is how many steps either side of now are accepted, covering + // clock drift and a user typing slowly. + skew = 1 +) + +// NewSecret returns a fresh base32 secret with no padding, as authenticator +// apps expect. +func NewSecret() string { + b := make([]byte, 20) + if _, err := rand.Read(b); err != nil { + panic("totp: " + err.Error()) + } + return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b) +} + +// URI is the otpauth:// URL an authenticator app can scan. +func URI(issuer, account, secret string) string { + label := url.PathEscape(issuer + ":" + account) + q := url.Values{"secret": {secret}, "issuer": {issuer}, "algorithm": {"SHA1"}, + "digits": {fmt.Sprint(digits)}, "period": {"30"}} + return "otpauth://totp/" + label + "?" + q.Encode() +} + +// Code is the password for secret at time t. +func Code(secret string, t time.Time) (string, error) { + key, err := decode(secret) + if err != nil { + return "", err + } + return code(key, uint64(t.Unix())/uint64(step.Seconds())), nil +} + +// Check reports whether given is a valid password for secret at time t, +// allowing one step of drift either way. +func Check(secret, given string, t time.Time) bool { + key, err := decode(secret) + if err != nil { + return false + } + given = strings.TrimSpace(given) + if len(given) != digits { + return false + } + ctr := uint64(t.Unix()) / uint64(step.Seconds()) + for d := -skew; d <= skew; d++ { + if hmac.Equal([]byte(code(key, ctr+uint64(d))), []byte(given)) { + return true + } + } + return false +} + +func decode(secret string) ([]byte, error) { + s := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(secret), " ", "")) + return base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(s) +} + +func code(key []byte, counter uint64) string { + var buf [8]byte + binary.BigEndian.PutUint64(buf[:], counter) + mac := hmac.New(sha1.New, key) + mac.Write(buf[:]) + sum := mac.Sum(nil) + off := sum[len(sum)-1] & 0x0f + trunc := binary.BigEndian.Uint32(sum[off:off+4]) & 0x7fffffff + mod := uint32(1) + for i := 0; i < digits; i++ { + mod *= 10 + } + return fmt.Sprintf("%0*d", digits, trunc%mod) +} diff --git a/internal/totp/totp_test.go b/internal/totp/totp_test.go new file mode 100644 index 0000000..e394ba7 --- /dev/null +++ b/internal/totp/totp_test.go @@ -0,0 +1,46 @@ +package totp + +import ( + "testing" + "time" +) + +// RFC 6238 appendix B, SHA1 test vectors for the ASCII key "12345678901234567890". +func TestRFC6238Vectors(t *testing.T) { + const secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" // base32 of the RFC key + for _, tc := range []struct { + unix int64 + want string + }{ + {59, "287082"}, + {1111111109, "081804"}, + {1111111111, "050471"}, + {1234567890, "005924"}, + {2000000000, "279037"}, + } { + got, err := Code(secret, time.Unix(tc.unix, 0)) + if err != nil { + t.Fatal(err) + } + if got != tc.want { + t.Errorf("Code at %d = %s, want %s", tc.unix, got, tc.want) + } + } +} + +func TestCheckAcceptsDriftAndRejectsJunk(t *testing.T) { + s := NewSecret() + now := time.Now() + c, _ := Code(s, now) + if !Check(s, c, now.Add(29*time.Second)) { + t.Fatal("code within drift window rejected") + } + if Check(s, c, now.Add(5*time.Minute)) { + t.Fatal("stale code accepted") + } + for _, bad := range []string{"", "12345", "abcdef", "1234567"} { + if Check(s, bad, now) { + t.Fatalf("junk code %q accepted", bad) + } + } +} diff --git a/versions.mk b/versions.mk new file mode 100644 index 0000000..c2897ee --- /dev/null +++ b/versions.mk @@ -0,0 +1,18 @@ +# Single source of truth for all platform versions (semver). +# Bump with: make bump PLATFORM= PART= +VERSION_SERVER := 0.1.0 +VERSION_LINUX := 0.1.0 +VERSION_MACOS := 0.1.0 +VERSION_WINDOWS := 0.1.0 +VERSION_BSD := 0.1.0 +VERSION_ANDROID := 0.1.0 +# Monotonic Android versionCode, never decreases. +ANDROID_VERSION_CODE := 1 + +# Reverse-DNS application identifiers. +APPID_ANDROID := cc.tiredsysadmin.android.keeporg +APPID_MACOS := cc.tiredsysadmin.macos.keeporg +APPID_LINUX := cc.tiredsysadmin.linux.keeporg +APPID_WINDOWS := cc.tiredsysadmin.windows.keeporg +APPID_BSD := cc.tiredsysadmin.bsd.keeporg +APPID_SERVER := cc.tiredsysadmin.linux.keeporgd