Keep server data in an encrypted database, and harden every part
Storage. The server keeps its data in SQLite (a file in the data directory, the default) or PostgreSQL (database, KEEPORG_DATABASE_URL or -database). Every row, the KDBX versions included, is sealed with AES-256-GCM under a key derived from the master key and named by an HMAC. The master key is required to start and a different one is refused. A data directory from 0.10 or earlier is imported on first start. Several servers may share one PostgreSQL database. Go goes to 1.26 for the pure-Go drivers (modernc.org/sqlite, jackc/pgx). Hardening, after an audit of the server, both clients and the deployment: - Single sign-on approves a device only after an explicit Approve in the browser that started it, bound by a cookie and PKCE; identities are never linked by username, by verified email only when allowed, never to admins. - Sign-in attempts are limited per address and per account, passwords are checked outside the store lock with uniform timing, one-time codes work once, and starting two-factor needs the password. - Merge vaults bound KDF parameters, payload size and XML shape, check pushes before storing them, and run at most two merges at once. A version is written in the same transaction as its metadata. - Tokens in URLs only on the event stream, which ends on revocation; CSP, frame-ancestors, no-store and HSTS; sign-in CSRF; 404 for databases without access; new trusted_proxies. - Clients open only the server's approval page, keep case-variant and retired file names apart, never overwrite a save made during a pull, require https except locally, send the stream token in a header, bound their reads and escape notifications and terminal output. - The .deb creates the master key, systemd is sandboxed further, nginx sets modern TLS and HSTS, and CI deploys from main only. Server 0.12.0, desktop 0.6.0, Android 0.4.0 (versionCode 4). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f8f634fbf7
commit
4ffc434b1b
+122
-58
@@ -1,93 +1,118 @@
|
||||
// Package blob stores the KDBX payloads. Each database keeps a numbered
|
||||
// version history, which makes a rejected push recoverable and a bad save
|
||||
// reversible.
|
||||
//
|
||||
// The payloads live in the server's database next to the metadata, sealed
|
||||
// under a key derived from the master key and named by a keyed digest of
|
||||
// the database's id. KDBX is already encrypted by KeePass; sealing it again
|
||||
// means the database holds no readable KeePass headers either, and that one
|
||||
// backup of the database is the whole server.
|
||||
package blob
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cc.tiredsysadmin.keeporg/internal/secret"
|
||||
"cc.tiredsysadmin.keeporg/internal/sqldb"
|
||||
)
|
||||
|
||||
// KeepVersions is how many historical versions of each database are retained.
|
||||
const KeepVersions = 20
|
||||
|
||||
type Store struct{ dir string }
|
||||
// timeout bounds one blob operation. A payload is at most tens of megabytes.
|
||||
const timeout = time.Minute
|
||||
|
||||
// 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
|
||||
type Store struct {
|
||||
db *sqldb.DB
|
||||
key *secret.Keyring
|
||||
}
|
||||
|
||||
// Open returns the blob store in db, sealing with key.
|
||||
func Open(db *sqldb.DB, key *secret.Keyring) *Store { return &Store{db: db, key: key} }
|
||||
|
||||
// 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)
|
||||
func (s *Store) ref(dbID string) string { return s.key.MAC("blob\x00" + dbID) }
|
||||
|
||||
// aad binds a sealed payload to its database and version, so a row copied
|
||||
// over another one fails to open instead of serving the wrong file.
|
||||
func aad(ref string, version int64) []byte {
|
||||
return []byte(ref + "/" + strconv.FormatInt(version, 10))
|
||||
}
|
||||
|
||||
// Get reads one version of a database.
|
||||
// Put writes one version of a database. Writing a version again replaces it,
|
||||
// so a retried request is harmless.
|
||||
func (s *Store) Put(dbID string, version int64, data []byte) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
return s.put(ctx, s.db, dbID, version, data)
|
||||
}
|
||||
|
||||
// PutTx is Put inside a transaction, for a version written together with
|
||||
// the metadata that names it: a version a concurrent writer committed can
|
||||
// then never be overwritten by a write whose metadata turns out stale.
|
||||
func (s *Store) PutTx(ctx context.Context, tx *sql.Tx, dbID string, version int64, data []byte) error {
|
||||
return s.put(ctx, tx, dbID, version, data)
|
||||
}
|
||||
|
||||
func (s *Store) put(ctx context.Context, q sqldb.Querier, dbID string, version int64, data []byte) error {
|
||||
ref := s.ref(dbID)
|
||||
_, err := q.ExecContext(ctx, s.db.Q(`INSERT INTO keeporg_blobs (ref, version, body)
|
||||
VALUES (?, ?, ?) ON CONFLICT (ref, version) DO UPDATE SET body = excluded.body`),
|
||||
ref, version, s.key.Seal(aad(ref, version), data))
|
||||
return err
|
||||
}
|
||||
|
||||
// Get reads one version of a database. A version that is not stored is
|
||||
// fs.ErrNotExist.
|
||||
func (s *Store) Get(dbID string, version int64) ([]byte, error) {
|
||||
return os.ReadFile(s.versionPath(dbID, version))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
ref := s.ref(dbID)
|
||||
var body []byte
|
||||
err := s.db.QueryRowContext(ctx, s.db.Q(`SELECT body FROM keeporg_blobs
|
||||
WHERE ref = ? AND version = ?`), ref, version).Scan(&body)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("blob %s v%d: %w", dbID, version, fs.ErrNotExist)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.key.Open(aad(ref, version), body)
|
||||
}
|
||||
|
||||
// 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))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
rows, err := s.db.QueryContext(ctx, s.db.Q(`SELECT version FROM keeporg_blobs
|
||||
WHERE ref = ? ORDER BY version`), s.ref(dbID))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
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 {
|
||||
for rows.Next() {
|
||||
var v int64
|
||||
if rows.Scan(&v) == nil {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -97,19 +122,58 @@ func (s *Store) Prune(dbID string) error {
|
||||
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
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
_, err := s.db.ExecContext(ctx, s.db.Q(`DELETE FROM keeporg_blobs WHERE ref = ? AND version < ?`),
|
||||
s.ref(dbID), vs[len(vs)-KeepVersions])
|
||||
return err
|
||||
}
|
||||
|
||||
// Forget deletes every version of a database.
|
||||
func (s *Store) Forget(dbID string) error {
|
||||
return os.RemoveAll(filepath.Join(s.dir, dbID))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
_, err := s.db.ExecContext(ctx, s.db.Q(`DELETE FROM keeporg_blobs WHERE ref = ?`), s.ref(dbID))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) versionPath(dbID string, version int64) string {
|
||||
return filepath.Join(s.dir, dbID, fmt.Sprintf("%020d.kdbx", version))
|
||||
// ImportDir copies the blobs/<database id>/<version>.kdbx tree that servers
|
||||
// before 0.11 kept on disk into the store, and says how many it copied.
|
||||
func (s *Store) ImportDir(dir string) (int, error) {
|
||||
dbs, err := os.ReadDir(dir)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n := 0
|
||||
for _, d := range dbs {
|
||||
if !d.IsDir() {
|
||||
continue
|
||||
}
|
||||
files, err := os.ReadDir(filepath.Join(dir, d.Name()))
|
||||
if err != nil {
|
||||
return n, err
|
||||
}
|
||||
for _, f := range files {
|
||||
name := f.Name()
|
||||
if !strings.HasSuffix(name, ".kdbx") {
|
||||
continue
|
||||
}
|
||||
v, err := strconv.ParseInt(strings.TrimSuffix(name, ".kdbx"), 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(dir, d.Name(), name))
|
||||
if err != nil {
|
||||
return n, err
|
||||
}
|
||||
if err := s.Put(d.Name(), v, data); err != nil {
|
||||
return n, err
|
||||
}
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
@@ -1,12 +1,32 @@
|
||||
package blob
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
func TestPutGetIsIdempotentAndVersioned(t *testing.T) {
|
||||
s, err := Open(t.TempDir())
|
||||
"cc.tiredsysadmin.keeporg/internal/secret"
|
||||
"cc.tiredsysadmin.keeporg/internal/sqldb"
|
||||
"cc.tiredsysadmin.keeporg/internal/sqldb/sqldbtest"
|
||||
)
|
||||
|
||||
func open(t *testing.T) (*Store, *sqldb.DB) {
|
||||
t.Helper()
|
||||
db, err := sqldb.Open(context.Background(), sqldbtest.URL(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
k, _ := secret.NewKeyring(bytes.Repeat([]byte{3}, 32))
|
||||
return Open(db, k), db
|
||||
}
|
||||
|
||||
func TestPutGetIsIdempotentAndVersioned(t *testing.T) {
|
||||
s, _ := open(t)
|
||||
if err := s.Put("db", 1, []byte("v1")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -23,15 +43,39 @@ func TestPutGetIsIdempotentAndVersioned(t *testing.T) {
|
||||
if vs := s.Versions("db"); len(vs) != 2 || vs[0] != 1 || vs[1] != 2 {
|
||||
t.Fatalf("versions = %v", vs)
|
||||
}
|
||||
if _, err := s.Get("db", 9); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Fatalf("missing version: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPayloadsAreSealedAndBoundToTheirVersion(t *testing.T) {
|
||||
s, db := open(t)
|
||||
_ = s.Put("db", 1, []byte("KDBX-HEADER-one"))
|
||||
_ = s.Put("db", 2, []byte("KDBX-HEADER-two"))
|
||||
var body []byte
|
||||
if err := db.QueryRow(`SELECT body FROM keeporg_blobs WHERE version = 1`).Scan(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes.Contains(body, []byte("KDBX")) {
|
||||
t.Fatal("a payload is stored in the clear")
|
||||
}
|
||||
// Moving version 1's row over version 2 must not serve it as version 2.
|
||||
if _, err := db.Exec(db.Q(`UPDATE keeporg_blobs SET body = ? WHERE version = 2`), body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Get("db", 2); err == nil {
|
||||
t.Fatal("a payload opened under another version's name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneKeepsNewest(t *testing.T) {
|
||||
s, _ := Open(t.TempDir())
|
||||
s, _ := open(t)
|
||||
for i := int64(1); i <= KeepVersions+5; i++ {
|
||||
if err := s.Put("db", i, []byte{byte(i)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
_ = s.Put("other", 1, []byte("x"))
|
||||
if err := s.Prune("db"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -39,4 +83,22 @@ func TestPruneKeepsNewest(t *testing.T) {
|
||||
if len(vs) != KeepVersions || vs[0] != 6 {
|
||||
t.Fatalf("after prune = %v", vs)
|
||||
}
|
||||
if err := s.Forget("db"); err != nil || len(s.Versions("db")) != 0 || len(s.Versions("other")) != 1 {
|
||||
t.Fatalf("forget: %v, left %v / %v", err, s.Versions("db"), s.Versions("other"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportDirReadsTheOldLayout(t *testing.T) {
|
||||
s, _ := open(t)
|
||||
dir := t.TempDir()
|
||||
os.MkdirAll(filepath.Join(dir, "abc"), 0o700)
|
||||
os.WriteFile(filepath.Join(dir, "abc", "00000000000000000003.kdbx"), []byte("three"), 0o600)
|
||||
os.WriteFile(filepath.Join(dir, "abc", "00000000000000000004.kdbx.tmp"), []byte("junk"), 0o600)
|
||||
n, err := s.ImportDir(dir)
|
||||
if err != nil || n != 1 {
|
||||
t.Fatalf("import = %d, %v", n, err)
|
||||
}
|
||||
if got, err := s.Get("abc", 3); err != nil || string(got) != "three" {
|
||||
t.Fatalf("imported = %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,13 +153,15 @@ func (a *API) ChangePassword(ctx context.Context, current, next, totp string) er
|
||||
}
|
||||
|
||||
// EnrollTOTP starts second-factor enrolment and returns the shared secret
|
||||
// and its otpauth URI.
|
||||
func (a *API) EnrollTOTP(ctx context.Context) (secret, uri string, err error) {
|
||||
// and its otpauth URI. It needs the account password, and the current code
|
||||
// when a second factor is already on and is being replaced.
|
||||
func (a *API) EnrollTOTP(ctx context.Context, password, code string) (secret, uri string, err error) {
|
||||
var out struct {
|
||||
Secret string `json:"secret"`
|
||||
URI string `json:"uri"`
|
||||
}
|
||||
err = a.do(ctx, http.MethodPost, "/api/v1/auth/totp/enroll", nil, &out)
|
||||
err = a.do(ctx, http.MethodPost, "/api/v1/auth/totp/enroll",
|
||||
map[string]string{"password": password, "totp": code}, &out)
|
||||
return out.Secret, out.URI, err
|
||||
}
|
||||
|
||||
|
||||
+14
-6
@@ -16,6 +16,9 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// maxPayload bounds one database download.
|
||||
const maxPayload = 256 << 20
|
||||
|
||||
// API talks to one KeepOrg server as one user.
|
||||
type API struct {
|
||||
BaseURL string
|
||||
@@ -185,10 +188,15 @@ func (a *API) Pull(ctx context.Context, dbID string) ([]byte, int64, error) {
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, 0, decodeError(resp)
|
||||
}
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
// The server's own limit is tens of megabytes; a server that sends more
|
||||
// is not one to trust with memory.
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxPayload+1))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if len(data) > maxPayload {
|
||||
return nil, 0, fmt.Errorf("the server sent more than %d MiB for one database", maxPayload>>20)
|
||||
}
|
||||
v, _ := strconv.ParseInt(strings.Trim(resp.Header.Get("ETag"), `"`), 10, 64)
|
||||
return data, v, nil
|
||||
}
|
||||
@@ -216,7 +224,7 @@ func (a *API) Push(ctx context.Context, dbID string, baseVersion int64, data []b
|
||||
return nil, decodeError(resp)
|
||||
}
|
||||
var out PushResult
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
@@ -236,10 +244,10 @@ type Event struct {
|
||||
// The caller reconnects; losing events is safe because every notification
|
||||
// only ever triggers a full reconcile.
|
||||
func (a *API) Events(ctx context.Context, onEvent func(Event)) error {
|
||||
// The token travels as a query parameter because an event stream has no
|
||||
// place to put a header on reconnect in every client runtime.
|
||||
u := a.BaseURL + "/api/v1/events?access_token=" + url.QueryEscape(a.Token)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
// The token goes in the header like everywhere else. In the URL it
|
||||
// would land in proxy logs, and in every error message Go makes about
|
||||
// a failed request, which this client logs.
|
||||
req, err := a.request(ctx, http.MethodGet, "/api/v1/events", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -43,6 +44,10 @@ type Config struct {
|
||||
LogFile string `json:"log_file"`
|
||||
// Notifications turns the tray's desktop notifications on or off.
|
||||
Notifications bool `json:"notifications"`
|
||||
// AllowInsecureHTTP permits an http:// server_url that is not this
|
||||
// machine. Without TLS the password, the session token and every vault
|
||||
// cross the network readable and alterable by anyone on the path.
|
||||
AllowInsecureHTTP bool `json:"allow_insecure_http"`
|
||||
}
|
||||
|
||||
// ErrNoConfig means the client has not been logged in yet.
|
||||
@@ -134,6 +139,10 @@ func (c *Config) Validate() error {
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return fmt.Errorf("server_url %q must be an http:// or https:// URL", c.ServerURL)
|
||||
}
|
||||
if u.Scheme == "http" && !isLoopback(u.Hostname()) && !c.AllowInsecureHTTP {
|
||||
return fmt.Errorf("server_url %q is not encrypted; use https://, "+
|
||||
"or set allow_insecure_http if you really mean it", c.ServerURL)
|
||||
}
|
||||
}
|
||||
if c.CAFile != "" {
|
||||
if _, err := os.Stat(c.CAFile); err != nil {
|
||||
@@ -143,6 +152,31 @@ func (c *Config) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func isLoopback(host string) bool {
|
||||
if host == "localhost" {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
||||
// CheckVerificationURL makes sure a page the server asks the client to open
|
||||
// in a browser is on that server. A compromised server must not be able to
|
||||
// make the desktop open whatever it likes — a file: URL, a network share
|
||||
// that runs a program, another application's URL scheme.
|
||||
func CheckVerificationURL(serverURL, raw string) error {
|
||||
srv, err := url.Parse(serverURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Scheme != srv.Scheme || !strings.EqualFold(u.Host, srv.Host) ||
|
||||
u.User != nil || u.Path != strings.TrimRight(srv.Path, "/")+"/device" {
|
||||
return fmt.Errorf("the server asked to open %q, which is not its own approval page", raw)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// API returns a client for the configured server, signed in with the stored
|
||||
// token when there is one.
|
||||
func (c *Config) API() (*API, error) {
|
||||
|
||||
@@ -119,3 +119,45 @@ func TestLockDirAdmitsOneAgent(t *testing.T) {
|
||||
}
|
||||
again()
|
||||
}
|
||||
|
||||
func TestCheckVerificationURLStaysOnTheServer(t *testing.T) {
|
||||
ok := []string{"https://k.example.com/device?code=ABCD-EFGH"}
|
||||
bad := []string{
|
||||
"file:///Applications/Calculator.app",
|
||||
`\\attacker\share\x.exe`,
|
||||
"https://evil.example/device?code=X",
|
||||
"http://k.example.com/device?code=X",
|
||||
"https://user@k.example.com/device",
|
||||
"https://k.example.com/somewhere-else",
|
||||
"keeporg://device-approved",
|
||||
}
|
||||
for _, u := range ok {
|
||||
if err := CheckVerificationURL("https://k.example.com", u); err != nil {
|
||||
t.Errorf("%s refused: %v", u, err)
|
||||
}
|
||||
}
|
||||
for _, u := range bad {
|
||||
if err := CheckVerificationURL("https://k.example.com", u); err == nil {
|
||||
t.Errorf("%s accepted", u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlainHTTPIsRefusedExceptLocally(t *testing.T) {
|
||||
c := DefaultConfig()
|
||||
for url, wantErr := range map[string]bool{
|
||||
"http://keeporg.example.com": true,
|
||||
"http://127.0.0.1:8765": false,
|
||||
"http://localhost:8765": false,
|
||||
"https://keeporg.example.com": false,
|
||||
} {
|
||||
c.ServerURL = url
|
||||
if err := c.Validate(); (err != nil) != wantErr {
|
||||
t.Errorf("%s: %v", url, err)
|
||||
}
|
||||
}
|
||||
c.ServerURL, c.AllowInsecureHTTP = "http://keeporg.example.com", true
|
||||
if err := c.Validate(); err != nil {
|
||||
t.Errorf("allow_insecure_http ignored: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
+86
-16
@@ -30,6 +30,11 @@ type fileState struct {
|
||||
Version int64 `json:"version"`
|
||||
SHA256 string `json:"sha256"`
|
||||
ConflictMarker string `json:"conflict_marker,omitempty"`
|
||||
// Retired marks a database this device no longer has access to. Its
|
||||
// file stays where it is, and its name stays taken: a database created
|
||||
// later under the same name must not adopt — and upload — a file that
|
||||
// belonged to another.
|
||||
Retired bool `json:"retired,omitempty"`
|
||||
}
|
||||
|
||||
// Status is a human-readable account of one mirrored database.
|
||||
@@ -192,12 +197,12 @@ func (m *Mirror) SyncAll(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
// A database that is no longer assigned stops being tracked. Its file is
|
||||
// left alone: deleting a user's local copy of a password database is not
|
||||
// this program's decision.
|
||||
for id := range m.st {
|
||||
// A database that is no longer assigned stops being synchronized. Its
|
||||
// file is left alone — deleting a user's local copy of a password
|
||||
// database is not this program's decision — and its name stays retired.
|
||||
for id, st := range m.st {
|
||||
if !seen[id] {
|
||||
delete(m.st, id)
|
||||
st.Retired = true
|
||||
delete(m.status, id)
|
||||
}
|
||||
}
|
||||
@@ -263,7 +268,7 @@ func (m *Mirror) reconcile(ctx context.Context, db Database) error {
|
||||
return nil
|
||||
|
||||
case actPull:
|
||||
return m.pull(ctx, db, st, path)
|
||||
return m.pull(ctx, db, st, path, localSHA)
|
||||
|
||||
case actPush, actPushForMerge:
|
||||
return m.push(ctx, db, st, path, local, false)
|
||||
@@ -276,7 +281,7 @@ func (m *Mirror) reconcile(ctx context.Context, db Database) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.pull(ctx, db, st, path); err != nil {
|
||||
if err := m.pull(ctx, db, st, path, localSHA); err != nil {
|
||||
return err
|
||||
}
|
||||
why := "the server copy was newer, your save was kept as " + filepath.Base(copyPath)
|
||||
@@ -306,7 +311,15 @@ func (m *Mirror) reconcile(ctx context.Context, db Database) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Mirror) pull(ctx context.Context, db Database, st *fileState, path string) error {
|
||||
// errChangedDuringSync means the local file was saved while a pass was
|
||||
// deciding about it. Nothing is overwritten; the next pass sees the save.
|
||||
var errChangedDuringSync = errors.New("the file was saved during synchronization; trying again")
|
||||
|
||||
// pull replaces the local file with the server copy. expectSHA is the digest
|
||||
// of the local file the decision was made on ("" for none): if the file is
|
||||
// different now, a KeePass application saved it in the meantime, and
|
||||
// replacing it would lose that save without a trace.
|
||||
func (m *Mirror) pull(ctx context.Context, db Database, st *fileState, path, expectSHA string) error {
|
||||
data, version, err := m.api.Pull(ctx, db.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -314,6 +327,13 @@ func (m *Mirror) pull(ctx context.Context, db Database, st *fileState, path stri
|
||||
if version == 0 || len(data) == 0 {
|
||||
return nil // nothing stored yet
|
||||
}
|
||||
now, exists, err := readFileIfExists(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if (exists && digest(now) != expectSHA) || (!exists && expectSHA != "") {
|
||||
return errChangedDuringSync
|
||||
}
|
||||
if err := writeFileAtomic(path, data); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -332,7 +352,7 @@ func (m *Mirror) push(ctx context.Context, db Database, st *fileState, path stri
|
||||
// The server folded our save into someone else's. What it holds
|
||||
// now is neither copy, so take it.
|
||||
m.log.Printf("%s: server merged version %d (%s)", db.Name, res.Version, res.MergeSummary)
|
||||
if err := m.pull(ctx, db, st, path); err != nil {
|
||||
if err := m.pull(ctx, db, st, path, digest(data)); err != nil {
|
||||
return err
|
||||
}
|
||||
m.setStatus(db, "merged", res.MergeSummary)
|
||||
@@ -341,7 +361,7 @@ func (m *Mirror) push(ctx context.Context, db Database, st *fileState, path stri
|
||||
if res.SHA256 != digest(data) {
|
||||
// Whatever the server stored differs from what we sent, so the
|
||||
// local file is no longer what the vault holds.
|
||||
return m.pull(ctx, db, st, path)
|
||||
return m.pull(ctx, db, st, path, digest(data))
|
||||
}
|
||||
if !res.Unchanged {
|
||||
m.log.Printf("%s: pushed version %d", db.Name, res.Version)
|
||||
@@ -371,7 +391,7 @@ func (m *Mirror) push(ctx context.Context, db Database, st *fileState, path stri
|
||||
if cerr != nil {
|
||||
return cerr
|
||||
}
|
||||
if perr := m.pull(ctx, db, st, path); perr != nil {
|
||||
if perr := m.pull(ctx, db, st, path, digest(data)); perr != nil {
|
||||
return perr
|
||||
}
|
||||
why := "the server copy was newer, your save was kept as " + filepath.Base(copyPath)
|
||||
@@ -386,7 +406,7 @@ func (m *Mirror) push(ctx context.Context, db Database, st *fileState, path stri
|
||||
if cerr != nil {
|
||||
return cerr
|
||||
}
|
||||
if perr := m.pull(ctx, db, st, path); perr != nil {
|
||||
if perr := m.pull(ctx, db, st, path, digest(data)); perr != nil {
|
||||
return perr
|
||||
}
|
||||
why := "you have read-only access, your save was kept as " + filepath.Base(copyPath)
|
||||
@@ -406,6 +426,8 @@ func (m *Mirror) trackedState(db Database) *fileState {
|
||||
m.st[db.ID] = st
|
||||
return st
|
||||
}
|
||||
// Access came back: the file and what was last agreed are still valid.
|
||||
st.Retired = false
|
||||
if st.File == "" {
|
||||
st.File = m.fileNameFor(db)
|
||||
}
|
||||
@@ -413,6 +435,13 @@ func (m *Mirror) trackedState(db Database) *fileState {
|
||||
want := m.fileNameFor(db)
|
||||
old := filepath.Join(m.dir, st.File)
|
||||
if _, err := os.Stat(old); err == nil && want != st.File {
|
||||
// Never onto a file that is already there: os.Rename would
|
||||
// replace it, and it may be someone else's vault.
|
||||
if _, err := os.Lstat(filepath.Join(m.dir, want)); err == nil {
|
||||
m.log.Printf("%s: not renaming %s to %s, which already exists", db.Name, st.File, want)
|
||||
st.Name = db.Name
|
||||
return st
|
||||
}
|
||||
if err := os.Rename(old, filepath.Join(m.dir, want)); err != nil {
|
||||
m.log.Printf("%s: rename %s to %s: %v", db.Name, st.File, want, err)
|
||||
return st
|
||||
@@ -425,7 +454,9 @@ func (m *Mirror) trackedState(db Database) *fileState {
|
||||
}
|
||||
|
||||
// fileNameFor turns a database name into a filename, keeping it unique
|
||||
// within the mirror directory.
|
||||
// within the mirror directory. Names compare without regard to case, since
|
||||
// macOS and Windows file systems do: "work" and "Work" are one file there,
|
||||
// and two databases sharing it would upload each other.
|
||||
func (m *Mirror) fileNameFor(db Database) string {
|
||||
base := sanitize(db.Name)
|
||||
if base == "" {
|
||||
@@ -433,7 +464,7 @@ func (m *Mirror) fileNameFor(db Database) string {
|
||||
}
|
||||
name := base + ".kdbx"
|
||||
for id, st := range m.st {
|
||||
if id != db.ID && st.File == name {
|
||||
if id != db.ID && strings.EqualFold(st.File, name) {
|
||||
return fmt.Sprintf("%s-%s.kdbx", base, shortID(db.ID))
|
||||
}
|
||||
}
|
||||
@@ -521,7 +552,8 @@ func writeFileAtomic(path string, data []byte) error {
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// sanitize keeps a database name usable as a filename on every platform.
|
||||
// sanitize keeps a database name usable as a filename on every platform,
|
||||
// and out of the names this client makes for itself.
|
||||
func sanitize(name string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range strings.TrimSpace(name) {
|
||||
@@ -533,7 +565,45 @@ func sanitize(name string) string {
|
||||
b.WriteRune('-')
|
||||
}
|
||||
}
|
||||
return strings.Trim(strings.TrimSuffix(b.String(), ".kdbx"), " .-")
|
||||
out := b.String()
|
||||
for strings.Contains(out, "..") {
|
||||
out = strings.ReplaceAll(out, "..", ".")
|
||||
}
|
||||
out = strings.Trim(strings.TrimSuffix(out, ".kdbx"), " .-")
|
||||
// A database called "Work.server-v7" or "Work.conflict-…" would collide
|
||||
// with the side copies kept for "Work".
|
||||
for _, reserved := range []string{".server-v", ".conflict-", ".keeporg"} {
|
||||
out = undotFold(out, reserved)
|
||||
}
|
||||
// Windows maps these to devices, whatever the extension.
|
||||
stem, _, _ := strings.Cut(out, ".")
|
||||
if windowsDevice[strings.ToUpper(stem)] {
|
||||
out += "-db"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var windowsDevice = map[string]bool{
|
||||
"CON": true, "PRN": true, "AUX": true, "NUL": true,
|
||||
"COM1": true, "COM2": true, "COM3": true, "COM4": true, "COM5": true,
|
||||
"COM6": true, "COM7": true, "COM8": true, "COM9": true,
|
||||
"LPT1": true, "LPT2": true, "LPT3": true, "LPT4": true, "LPT5": true,
|
||||
"LPT6": true, "LPT7": true, "LPT8": true, "LPT9": true,
|
||||
}
|
||||
|
||||
// undotFold turns the leading dot of every case-insensitive occurrence of
|
||||
// marker into a dash, keeping the name's own spelling.
|
||||
func undotFold(s, marker string) string {
|
||||
lower, low := []byte(strings.ToLower(s)), strings.ToLower(marker)
|
||||
out := []byte(s)
|
||||
for i := 0; ; {
|
||||
j := strings.Index(string(lower[i:]), low)
|
||||
if j < 0 {
|
||||
return string(out)
|
||||
}
|
||||
out[i+j] = '-'
|
||||
i += j + len(marker)
|
||||
}
|
||||
}
|
||||
|
||||
func shortID(id string) string {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSanitizeKeepsClearOfTheClientsOwnNames(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"Team Vault": "Team Vault",
|
||||
"../../etc/passwd": "etc-passwd",
|
||||
"Work.server-v7": "Work-server-v7",
|
||||
"Work.CONFLICT-1": "Work-CONFLICT-1",
|
||||
"nul": "nul-db",
|
||||
"COM3.backup": "COM3.backup-db",
|
||||
".keeporg-state": "keeporg-state",
|
||||
} {
|
||||
if got := sanitize(in); got != want {
|
||||
t.Errorf("sanitize(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileNamesDifferingOnlyInCaseDoNotShareAFile(t *testing.T) {
|
||||
m := &Mirror{st: map[string]*fileState{"a": {Name: "Work", File: "Work.kdbx"}}}
|
||||
if got := m.fileNameFor(Database{ID: "bbbbbbbb", Name: "work"}); got == "work.kdbx" {
|
||||
t.Fatalf("a case variant got the same file on a case-insensitive disk: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The attack: a database the user lost access to leaves its file behind; an
|
||||
// administrator elsewhere creates an empty database of the same name and
|
||||
// grants it to the user. The leftover must not be adopted and uploaded.
|
||||
func TestARetiredFileIsNotAdoptedByANewDatabase(t *testing.T) {
|
||||
s := newStack(t)
|
||||
s.sync()
|
||||
v, _ := s.remote()
|
||||
if v == 0 {
|
||||
s.write("Team Vault.kdbx", "the original vault")
|
||||
s.sync()
|
||||
}
|
||||
ctx := context.Background()
|
||||
if err := s.admin.DeleteDatabase(ctx, s.dbID, password, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.sync()
|
||||
impostor, err := s.admin.CreateDatabase(ctx, "Team Vault", "everyone", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.sync()
|
||||
if _, v, err := s.admin.Pull(ctx, impostor.ID); err != nil || v != 0 {
|
||||
t.Fatalf("the leftover file was uploaded into the new database: version %d, %v", v, err)
|
||||
}
|
||||
if got := s.read("Team Vault.kdbx"); got != "the original vault" {
|
||||
t.Fatalf("the leftover file changed: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPullDoesNotOverwriteASaveMadeDuringSync(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m := &Mirror{dir: dir, log: log.New(io.Discard, "", 0)}
|
||||
path := filepath.Join(dir, "v.kdbx")
|
||||
os.WriteFile(path, []byte("saved just now"), 0o600)
|
||||
// The decision was made on the earlier content.
|
||||
now, _, _ := readFileIfExists(path)
|
||||
if digest(now) == digest([]byte("earlier")) {
|
||||
t.Fatal("test setup")
|
||||
}
|
||||
s := newStack(t)
|
||||
m.api = s.admin
|
||||
if _, err := s.admin.Push(context.Background(), s.dbID, 0, []byte("server copy"), false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.st, m.status = map[string]*fileState{}, map[string]*Status{}
|
||||
st := &fileState{File: "v.kdbx"}
|
||||
err := m.pull(context.Background(), Database{ID: s.dbID, Name: "v"}, st, path, digest([]byte("earlier")))
|
||||
if err != errChangedDuringSync {
|
||||
t.Fatalf("pull over a fresh save: %v", err)
|
||||
}
|
||||
if b, _ := os.ReadFile(path); string(b) != "saved just now" {
|
||||
t.Fatalf("the save was overwritten: %q", b)
|
||||
}
|
||||
_ = time.Second
|
||||
}
|
||||
@@ -3,10 +3,14 @@ package client
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// maxEventBytes bounds one event's data.
|
||||
const maxEventBytes = 1 << 20
|
||||
|
||||
// sseDecoder reads the subset of the Server-Sent Events format the server
|
||||
// emits: "event:" and "data:" lines, one JSON payload per event.
|
||||
type sseDecoder struct {
|
||||
@@ -47,6 +51,11 @@ func (d *sseDecoder) next() (*Event, error) {
|
||||
name = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
|
||||
case strings.HasPrefix(line, "data:"):
|
||||
data += strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||
// Events are a few hundred bytes; a stream that keeps adding
|
||||
// to one is not a KeepOrg server.
|
||||
if len(data) > maxEventBytes {
|
||||
return nil, errors.New("event stream: an event exceeds 1 MiB")
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := d.sc.Err(); err != nil {
|
||||
|
||||
@@ -44,6 +44,9 @@ func deriveKeys(masterSeed []byte, kdf variantDict, password string) (keys, erro
|
||||
|
||||
// transformKey applies the file's KDF to the composite key.
|
||||
func transformKey(kdf variantDict, composite []byte) ([]byte, error) {
|
||||
if err := checkKDF(kdf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uuid := kdf.bytes("$UUID")
|
||||
switch {
|
||||
case bytes.Equal(uuid, kdfAES):
|
||||
|
||||
+58
-18
@@ -51,18 +51,38 @@ type KDFParams struct {
|
||||
// every save by every member of the vault.
|
||||
var DefaultKDF = KDFParams{MemoryKiB: 32 * 1024, Iterations: 2, Lanes: 2}
|
||||
|
||||
// Open decrypts and parses a KDBX 4 file.
|
||||
func Open(data []byte, password string) (*Database, error) {
|
||||
// Inspect checks a KDBX 4 file's outer header without the password: the
|
||||
// format, and that its key derivation stays within the limits Open accepts.
|
||||
// It costs nothing, so a server can refuse a file it could not afford to
|
||||
// open before storing it.
|
||||
func Inspect(data []byte) error {
|
||||
_, p, err := readHeader(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p+64 > len(data) {
|
||||
return errors.New("kdbx: file ends before its integrity hashes")
|
||||
}
|
||||
sum := sha256.Sum256(data[:p])
|
||||
if subtle.ConstantTimeCompare(sum[:], data[p:p+32]) != 1 {
|
||||
return errors.New("kdbx: header does not match its checksum")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readHeader parses the outer header and returns it with the offset where it
|
||||
// ends.
|
||||
func readHeader(data []byte) (*Database, int, error) {
|
||||
if len(data) < 12 {
|
||||
return nil, errors.New("kdbx: file is too short")
|
||||
return nil, 0, errors.New("kdbx: file is too short")
|
||||
}
|
||||
if binary.LittleEndian.Uint32(data[0:]) != signature1 ||
|
||||
binary.LittleEndian.Uint32(data[4:]) != signature2 {
|
||||
return nil, errors.New("kdbx: not a KeePass 2 database")
|
||||
return nil, 0, errors.New("kdbx: not a KeePass 2 database")
|
||||
}
|
||||
version := binary.LittleEndian.Uint32(data[8:])
|
||||
if major := version >> 16; major != 4 {
|
||||
return nil, unsupported("KDBX %d.%d; this server stores KDBX 4 vaults",
|
||||
return nil, 0, unsupported("KDBX %d.%d; this server stores KDBX 4 vaults",
|
||||
major, version&0xFFFF)
|
||||
}
|
||||
|
||||
@@ -70,13 +90,13 @@ func Open(data []byte, password string) (*Database, error) {
|
||||
p := 12
|
||||
for {
|
||||
if p+5 > len(data) {
|
||||
return nil, errors.New("kdbx: truncated header")
|
||||
return nil, 0, errors.New("kdbx: truncated header")
|
||||
}
|
||||
id := data[p]
|
||||
size := int(binary.LittleEndian.Uint32(data[p+1:]))
|
||||
p += 5
|
||||
if size < 0 || p+size > len(data) {
|
||||
return nil, errors.New("kdbx: truncated header field")
|
||||
return nil, 0, errors.New("kdbx: truncated header field")
|
||||
}
|
||||
field := data[p : p+size]
|
||||
p += size
|
||||
@@ -88,7 +108,7 @@ func Open(data []byte, password string) (*Database, error) {
|
||||
db.Cipher = append([]byte(nil), field...)
|
||||
case hdrCompression:
|
||||
if len(field) != 4 {
|
||||
return nil, errors.New("kdbx: malformed compression flags")
|
||||
return nil, 0, errors.New("kdbx: malformed compression flags")
|
||||
}
|
||||
db.Compression = binary.LittleEndian.Uint32(field)
|
||||
case hdrMasterSeed:
|
||||
@@ -97,31 +117,44 @@ func Open(data []byte, password string) (*Database, error) {
|
||||
case hdrKdfParameters:
|
||||
vd, err := parseVariantDict(field)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, 0, err
|
||||
}
|
||||
db.KDF = vd
|
||||
case hdrPublicCustomData:
|
||||
vd, err := parseVariantDict(field)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, 0, err
|
||||
}
|
||||
db.PublicData = vd
|
||||
case hdrComment:
|
||||
case hdrTransformSeed, hdrTransformRounds, hdrInnerStreamKey3,
|
||||
hdrStreamStartBytes, hdrInnerStreamID3:
|
||||
return nil, unsupported("KDBX 3 header field %d in a KDBX 4 file", id)
|
||||
return nil, 0, unsupported("KDBX 3 header field %d in a KDBX 4 file", id)
|
||||
}
|
||||
}
|
||||
headerDone:
|
||||
if len(headerField(data[12:p], hdrMasterSeed)) != 32 {
|
||||
return nil, 0, errors.New("kdbx: master seed has the wrong length")
|
||||
}
|
||||
if len(db.Cipher) != 16 {
|
||||
return nil, 0, errors.New("kdbx: file names no payload cipher")
|
||||
}
|
||||
if err := checkKDF(db.KDF); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return db, p, nil
|
||||
|
||||
}
|
||||
|
||||
// Open decrypts and parses a KDBX 4 file.
|
||||
func Open(data []byte, password string) (*Database, error) {
|
||||
db, p, err := readHeader(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
headerBytes := data[:p]
|
||||
masterSeed := headerField(data[12:p], hdrMasterSeed)
|
||||
iv := headerField(data[12:p], hdrEncryptionIV)
|
||||
if len(masterSeed) != 32 {
|
||||
return nil, errors.New("kdbx: master seed has the wrong length")
|
||||
}
|
||||
if len(db.Cipher) != 16 {
|
||||
return nil, errors.New("kdbx: file names no payload cipher")
|
||||
}
|
||||
|
||||
if p+64 > len(data) {
|
||||
return nil, errors.New("kdbx: file ends before its integrity hashes")
|
||||
@@ -420,7 +453,14 @@ func gunzip(b []byte) ([]byte, error) {
|
||||
}
|
||||
defer r.Close()
|
||||
// A KDBX payload is a password database, not an archive: cap it.
|
||||
return io.ReadAll(io.LimitReader(r, 512<<20))
|
||||
out, err := io.ReadAll(io.LimitReader(r, int64(MaxXMLBytes)+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(out) > MaxXMLBytes {
|
||||
return nil, fmt.Errorf("kdbx: the payload decompresses to more than %d MiB", MaxXMLBytes>>20)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func randomBytes(n int) []byte {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package kdbx
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Limits on what a file may demand of whoever opens it. The server opens
|
||||
// files that its users upload — a merge decrypts both copies — so a header
|
||||
// asking for a billion AES rounds, two gibibytes of Argon2 memory or a
|
||||
// payload that inflates without end would otherwise be a way to stop it.
|
||||
// The bounds sit well above what KeePassXC, KeePass and KeePassDX choose for
|
||||
// a one-second unlock on a desktop, and far below what hurts a server.
|
||||
var (
|
||||
// MaxAESRounds bounds AES-KDF.
|
||||
MaxAESRounds uint64 = 30_000_000
|
||||
// MaxArgon2MemoryKiB bounds Argon2's memory, per derivation.
|
||||
MaxArgon2MemoryKiB uint64 = 256 << 10
|
||||
// MaxArgon2Iterations bounds Argon2's passes.
|
||||
MaxArgon2Iterations uint64 = 64
|
||||
// MaxArgon2Work bounds memory × passes, in MiB-passes, so a file cannot
|
||||
// take the maximum of both at once.
|
||||
MaxArgon2Work uint64 = 4096
|
||||
// MaxArgon2Lanes bounds Argon2's parallelism.
|
||||
MaxArgon2Lanes uint64 = 16
|
||||
// MaxXMLBytes bounds the decompressed payload.
|
||||
MaxXMLBytes = 128 << 20
|
||||
// MaxXMLDepth and MaxXMLNodes bound the payload's shape. KeePass nests
|
||||
// groups a few levels deep and stores thousands of entries, not millions.
|
||||
MaxXMLDepth = 256
|
||||
MaxXMLNodes = 4_000_000
|
||||
)
|
||||
|
||||
// checkKDF refuses key derivation parameters beyond the limits.
|
||||
func checkKDF(kdf variantDict) error {
|
||||
uuid := kdf.bytes("$UUID")
|
||||
switch {
|
||||
case bytes.Equal(uuid, kdfAES):
|
||||
if r, ok := kdf.uint64("R"); ok && r > MaxAESRounds {
|
||||
return fmt.Errorf("kdbx: AES-KDF asks for %d rounds, refusing above %d", r, MaxAESRounds)
|
||||
}
|
||||
case bytes.Equal(uuid, kdfArgon2d), bytes.Equal(uuid, kdfArgon2id):
|
||||
mem, _ := kdf.uint64("M")
|
||||
iter, _ := kdf.uint64("I")
|
||||
par, _ := kdf.uint64("P")
|
||||
memKiB := mem / 1024
|
||||
switch {
|
||||
case memKiB > MaxArgon2MemoryKiB:
|
||||
return fmt.Errorf("kdbx: Argon2 asks for %d MiB, refusing above %d", memKiB>>10, MaxArgon2MemoryKiB>>10)
|
||||
case iter > MaxArgon2Iterations:
|
||||
return fmt.Errorf("kdbx: Argon2 asks for %d passes, refusing above %d", iter, MaxArgon2Iterations)
|
||||
case par > MaxArgon2Lanes:
|
||||
return fmt.Errorf("kdbx: Argon2 asks for %d lanes, refusing above %d", par, MaxArgon2Lanes)
|
||||
case (memKiB>>10)*iter > MaxArgon2Work:
|
||||
return fmt.Errorf("kdbx: Argon2 asks for %d MiB × %d passes, refusing above %d MiB-passes",
|
||||
memKiB>>10, iter, MaxArgon2Work)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package kdbx
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A file whose header asks for more work than the limits allow is refused
|
||||
// from its header alone — by Inspect, and by Open before any derivation.
|
||||
func TestOverLimitKDFIsRefusedBeforeAnyWork(t *testing.T) {
|
||||
saved := MaxArgon2Iterations
|
||||
MaxArgon2Iterations = 10_000 // only to be able to write the file
|
||||
data, err := New("Heavy", KDFParams{MemoryKiB: 8, Iterations: 5_000, Lanes: 1}).Marshal("pw-123456")
|
||||
MaxArgon2Iterations = saved
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Inspect(data); err == nil || !strings.Contains(err.Error(), "passes") {
|
||||
t.Fatalf("Inspect = %v", err)
|
||||
}
|
||||
start := time.Now()
|
||||
if _, err := Open(data, "pw-123456"); err == nil {
|
||||
t.Fatal("Open accepted an over-limit file")
|
||||
}
|
||||
if time.Since(start) > 100*time.Millisecond {
|
||||
t.Fatal("Open did the work before refusing")
|
||||
}
|
||||
// Within the limits, Inspect agrees with Open.
|
||||
ok, err := New("Light", KDFParams{MemoryKiB: 64, Iterations: 1, Lanes: 1}).Marshal("pw-123456")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Inspect(ok); err != nil {
|
||||
t.Fatalf("Inspect refused a normal file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeepOrHugeXMLIsRefused(t *testing.T) {
|
||||
deep := strings.Repeat("<a>", MaxXMLDepth+1) + strings.Repeat("</a>", MaxXMLDepth+1)
|
||||
if _, err := parseXML([]byte(deep)); err == nil || !strings.Contains(err.Error(), "deeper") {
|
||||
t.Fatalf("deep xml: %v", err)
|
||||
}
|
||||
saved := MaxXMLNodes
|
||||
MaxXMLNodes = 100
|
||||
defer func() { MaxXMLNodes = saved }()
|
||||
wide := "<r>" + strings.Repeat("<a/>", 200) + "</r>"
|
||||
if _, err := parseXML([]byte(wide)); err == nil || !strings.Contains(err.Error(), "elements") {
|
||||
t.Fatalf("wide xml: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ func parseXML(data []byte) (*Node, error) {
|
||||
var stack []*Node
|
||||
var root *Node
|
||||
var text strings.Builder
|
||||
nodes := 0
|
||||
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
@@ -47,6 +48,15 @@ func parseXML(data []byte) (*Node, error) {
|
||||
}
|
||||
switch t := tok.(type) {
|
||||
case xml.StartElement:
|
||||
// Bounded here so that nothing after parsing — walking,
|
||||
// cloning, merging, writing, all recursive — sees a tree
|
||||
// deeper or bigger than this.
|
||||
if nodes++; nodes > MaxXMLNodes {
|
||||
return nil, fmt.Errorf("kdbx: xml: more than %d elements", MaxXMLNodes)
|
||||
}
|
||||
if len(stack) >= MaxXMLDepth {
|
||||
return nil, fmt.Errorf("kdbx: xml: nested deeper than %d", MaxXMLDepth)
|
||||
}
|
||||
n := &Node{Name: t.Name.Local}
|
||||
for _, a := range t.Attr {
|
||||
name := a.Name.Local
|
||||
|
||||
@@ -3,7 +3,10 @@ package secret
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -21,6 +24,7 @@ import (
|
||||
// both holds the vaults.
|
||||
type Keyring struct {
|
||||
aead cipher.AEAD
|
||||
key []byte
|
||||
}
|
||||
|
||||
// Environment variables that supply the master key. Both are read before
|
||||
@@ -131,7 +135,54 @@ func NewKeyring(key []byte) (*Keyring, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Keyring{aead: aead}, nil
|
||||
return &Keyring{aead: aead, key: append([]byte(nil), key...)}, nil
|
||||
}
|
||||
|
||||
// Derive returns a keyring for one purpose, keyed by HMAC-SHA256 of the
|
||||
// master key and the purpose's name. Each use of the master key gets its own
|
||||
// key this way: a ciphertext made for one purpose does not open under
|
||||
// another, and no subkey reveals the master key.
|
||||
func (k *Keyring) Derive(purpose string) *Keyring {
|
||||
m := hmac.New(sha256.New, k.key)
|
||||
m.Write([]byte("keeporg/" + purpose))
|
||||
sub, err := NewKeyring(m.Sum(nil))
|
||||
if err != nil {
|
||||
panic("secret: derive: " + err.Error()) // a SHA-256 sum is always 32 bytes
|
||||
}
|
||||
return sub
|
||||
}
|
||||
|
||||
// Seal encrypts data with AES-256-GCM under a fresh nonce, authenticating
|
||||
// aad with it. The output is the nonce followed by the ciphertext.
|
||||
func (k *Keyring) Seal(aad, data []byte) []byte {
|
||||
nonce := randBytes(k.aead.NonceSize())
|
||||
return k.aead.Seal(nonce, nonce, data, aad)
|
||||
}
|
||||
|
||||
// ErrSealed means sealed data did not open: a different key, different
|
||||
// associated data, or tampering.
|
||||
var ErrSealed = errors.New("secret: sealed data does not open with this key")
|
||||
|
||||
// Open decrypts what Seal produced with the same aad.
|
||||
func (k *Keyring) Open(aad, sealed []byte) ([]byte, error) {
|
||||
n := k.aead.NonceSize()
|
||||
if len(sealed) < n {
|
||||
return nil, ErrSealed
|
||||
}
|
||||
plain, err := k.aead.Open(nil, sealed[:n], sealed[n:], aad)
|
||||
if err != nil {
|
||||
return nil, ErrSealed
|
||||
}
|
||||
return plain, nil
|
||||
}
|
||||
|
||||
// MAC is a keyed, deterministic digest of value, in hex. It names a row by a
|
||||
// value without storing the value: equal inputs give equal names, and the
|
||||
// name says nothing about the input to anyone without the key.
|
||||
func (k *Keyring) MAC(value string) string {
|
||||
m := hmac.New(sha256.New, k.key)
|
||||
m.Write([]byte(value))
|
||||
return hex.EncodeToString(m.Sum(nil))
|
||||
}
|
||||
|
||||
// Wrap encrypts a vault secret. The vault's own id is authenticated with it,
|
||||
|
||||
@@ -228,3 +228,24 @@ func TestSecretFileContentIsValidated(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivedKeysAreSeparate(t *testing.T) {
|
||||
k, err := NewKeyring(randBytes(32))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a, b := k.Derive("records"), k.Derive("blobs")
|
||||
sealed := a.Seal([]byte("row-1"), []byte("hello"))
|
||||
if got, err := a.Open([]byte("row-1"), sealed); err != nil || string(got) != "hello" {
|
||||
t.Fatalf("open = %q, %v", got, err)
|
||||
}
|
||||
if _, err := b.Open([]byte("row-1"), sealed); err == nil {
|
||||
t.Fatal("a ciphertext opened under another purpose's key")
|
||||
}
|
||||
if _, err := a.Open([]byte("row-2"), sealed); err == nil {
|
||||
t.Fatal("a ciphertext opened under another row's name")
|
||||
}
|
||||
if a.MAC("x") != k.Derive("records").MAC("x") || a.MAC("x") == b.MAC("x") {
|
||||
t.Fatal("MAC is not deterministic per purpose")
|
||||
}
|
||||
}
|
||||
|
||||
+25
-41
@@ -27,8 +27,8 @@ var (
|
||||
// is no password to change.
|
||||
errNoPassword = errors.New("this account signs in through single sign-on and has no password here")
|
||||
// errReauth asks for the password and, once enrolled, the one-time code,
|
||||
// before a change that would weaken how the account signs in. A live
|
||||
// session is what an unlocked laptop leaves lying around.
|
||||
// before a change to how the account signs in. A live session is what
|
||||
// an unlocked laptop leaves lying around.
|
||||
errReauth = errors.New("confirm with your password and one-time code")
|
||||
)
|
||||
|
||||
@@ -104,24 +104,12 @@ func revokeOtherSessions(st *store.State, userID, keep string) int {
|
||||
return n
|
||||
}
|
||||
|
||||
// reauth checks the caller's password, and their one-time code when one is
|
||||
// enrolled, for a change that weakens or replaces how the account signs in.
|
||||
func reauth(st *store.State, u *store.User, password, code string) error {
|
||||
if u.PassHash == "" {
|
||||
return errNoPassword
|
||||
}
|
||||
if password == "" {
|
||||
return errReauth
|
||||
}
|
||||
_, err := checkPassword(st, u.Username, password, code)
|
||||
return err
|
||||
}
|
||||
|
||||
// beginTOTP starts enrolling a second factor and returns its secret. Until
|
||||
// confirmTOTP sees a code from it, the account signs in as before. Replacing
|
||||
// a factor that is already active needs the password and the current code,
|
||||
// because a new secret disarms the old one.
|
||||
func beginTOTP(st *store.State, userID, password, code string) (string, error) {
|
||||
// confirmTOTP sees a code from it, the account signs in as before. The
|
||||
// caller has re-proved the password (and any current code): turning a
|
||||
// factor on from a stolen session would lock the owner out, and replacing
|
||||
// one disarms the old.
|
||||
func beginTOTP(st *store.State, userID string) (string, error) {
|
||||
u := st.Users[userID]
|
||||
if u == nil {
|
||||
return "", errNoSuchSession
|
||||
@@ -129,16 +117,12 @@ func beginTOTP(st *store.State, userID, password, code string) (string, error) {
|
||||
if u.PassHash == "" {
|
||||
return "", errNoPassword
|
||||
}
|
||||
if u.TOTPConfirmed {
|
||||
if err := reauth(st, u, password, code); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
u.TOTPSecret, u.TOTPConfirmed = totp.NewSecret(), false
|
||||
u.TOTPSecret, u.TOTPConfirmed, u.TOTPLastStep = totp.NewSecret(), false, 0
|
||||
return u.TOTPSecret, nil
|
||||
}
|
||||
|
||||
// confirmTOTP turns the second factor on, once a code from it checks out.
|
||||
// That code is spent: it cannot also sign in.
|
||||
func confirmTOTP(st *store.State, userID, code string) error {
|
||||
u := st.Users[userID]
|
||||
if u == nil || u.TOTPSecret == "" {
|
||||
@@ -147,10 +131,11 @@ func confirmTOTP(st *store.State, userID, code string) error {
|
||||
if u.TOTPConfirmed {
|
||||
return errors.New("the second factor is already on")
|
||||
}
|
||||
if !totp.Check(u.TOTPSecret, code, time.Now()) {
|
||||
step, ok := totp.Match(u.TOTPSecret, code, time.Now())
|
||||
if !ok {
|
||||
return errTOTPInvalid
|
||||
}
|
||||
u.TOTPConfirmed = true
|
||||
u.TOTPConfirmed, u.TOTPLastStep = true, step
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -161,9 +146,9 @@ func cancelTOTP(st *store.State, userID string) {
|
||||
}
|
||||
}
|
||||
|
||||
// disableTOTP turns the second factor off, behind the password and a
|
||||
// current code.
|
||||
func disableTOTP(st *store.State, userID, password, code string) error {
|
||||
// disableTOTP turns the second factor off. The caller has re-proved the
|
||||
// password and a current code.
|
||||
func disableTOTP(st *store.State, userID string) error {
|
||||
u := st.Users[userID]
|
||||
if u == nil {
|
||||
return errNoSuchSession
|
||||
@@ -171,10 +156,7 @@ func disableTOTP(st *store.State, userID, password, code string) error {
|
||||
if !u.TOTPConfirmed {
|
||||
return errors.New("no second factor is on")
|
||||
}
|
||||
if err := reauth(st, u, password, code); err != nil {
|
||||
return err
|
||||
}
|
||||
u.TOTPSecret, u.TOTPConfirmed = "", false
|
||||
u.TOTPSecret, u.TOTPConfirmed, u.TOTPLastStep = "", false, 0
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -182,6 +164,9 @@ func disableTOTP(st *store.State, userID, password, code string) error {
|
||||
// failures are 403, not 401: a client that drops its token on 401 must not
|
||||
// sign itself out over a mistyped password.
|
||||
func failAccount(w http.ResponseWriter, err error) {
|
||||
if writeThrottled(w, err) {
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, errNoSuchSession):
|
||||
fail(w, http.StatusNotFound, "not_found", err.Error())
|
||||
@@ -190,7 +175,7 @@ func failAccount(w http.ResponseWriter, err error) {
|
||||
case errors.Is(err, errTOTPInvalid):
|
||||
fail(w, http.StatusForbidden, "totp_invalid", "the one-time code was rejected")
|
||||
case errors.Is(err, errBadCredentials):
|
||||
fail(w, http.StatusForbidden, "bad_credentials", "the password is wrong")
|
||||
fail(w, http.StatusForbidden, "bad_credentials", "the password or one-time code is wrong")
|
||||
case errors.Is(err, errNoPassword):
|
||||
fail(w, http.StatusConflict, "no_password", err.Error())
|
||||
default:
|
||||
@@ -228,8 +213,7 @@ func (s *Server) handleRevokeOtherSessions(w http.ResponseWriter, r *http.Reques
|
||||
writeJSON(w, http.StatusOK, map[string]int{"revoked": n})
|
||||
}
|
||||
|
||||
// reauthRequest is the body of a change that may need re-authentication.
|
||||
// It is optional where nothing is enrolled yet.
|
||||
// reauthRequest is the body of a change that needs re-authentication.
|
||||
type reauthRequest struct {
|
||||
Password string `json:"password"`
|
||||
TOTP string `json:"totp"`
|
||||
@@ -241,9 +225,9 @@ func (s *Server) handleTOTPEnroll(w http.ResponseWriter, r *http.Request, u *sto
|
||||
return
|
||||
}
|
||||
var sec string
|
||||
if err := s.st.Update(func(st *store.State) error {
|
||||
if err := s.reconfirm(r, u, req.Password, req.TOTP, func(st *store.State, u *store.User) error {
|
||||
var err error
|
||||
sec, err = beginTOTP(st, u.ID, req.Password, req.TOTP)
|
||||
sec, err = beginTOTP(st, u.ID)
|
||||
return err
|
||||
}); err != nil {
|
||||
failAccount(w, err)
|
||||
@@ -280,8 +264,8 @@ func (s *Server) handleTOTPDisable(w http.ResponseWriter, r *http.Request, u *st
|
||||
if !readJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if err := s.st.Update(func(st *store.State) error {
|
||||
return disableTOTP(st, u.ID, req.Password, req.TOTP)
|
||||
if err := s.reconfirm(r, u, req.Password, req.TOTP, func(st *store.State, u *store.User) error {
|
||||
return disableTOTP(st, u.ID)
|
||||
}); err != nil {
|
||||
failAccount(w, err)
|
||||
return
|
||||
|
||||
@@ -13,6 +13,34 @@ import (
|
||||
"cc.tiredsysadmin.keeporg/internal/totp"
|
||||
)
|
||||
|
||||
// codeAt is the one-time code d steps from now. Each code is good once, so
|
||||
// a test that needs several uses inside one 30-second step takes them at
|
||||
// -1, 0 and +1, all within the accepted skew.
|
||||
func codeAt(t *testing.T, sec string, d int) string {
|
||||
t.Helper()
|
||||
c, err := totp.Code(sec, time.Now().Add(time.Duration(d)*30*time.Second))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// enrollTOTP turns the second factor on for the token's account, spending
|
||||
// the code one step back, and returns the secret.
|
||||
func (h *harness) enrollTOTP(t *testing.T, tok, password string) string {
|
||||
t.Helper()
|
||||
status, body := h.call("POST", "/api/v1/auth/totp/enroll", tok, map[string]string{"password": password})
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("enroll: %d %v", status, body)
|
||||
}
|
||||
sec := body["secret"].(string)
|
||||
if status, body := h.call("POST", "/api/v1/auth/totp/confirm", tok,
|
||||
map[string]string{"code": codeAt(t, sec, -1)}); status != http.StatusOK {
|
||||
t.Fatalf("confirm: %d %v", status, body)
|
||||
}
|
||||
return sec
|
||||
}
|
||||
|
||||
func sessionList(t *testing.T, h *harness, token string) []map[string]any {
|
||||
t.Helper()
|
||||
status, body := h.call("GET", "/api/v1/auth/sessions", token, nil)
|
||||
@@ -75,16 +103,13 @@ func TestReplacingOrRemovingTheSecondFactorNeedsThePassword(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
tok, _, _ := h.login("admin", adminPassword, "")
|
||||
|
||||
// Turning it on needs nothing beyond the session: it only adds a check.
|
||||
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)
|
||||
code, _ := totp.Code(sec, time.Now())
|
||||
if status, _ := h.call("POST", "/api/v1/auth/totp/confirm", tok, map[string]string{"code": code}); status != http.StatusOK {
|
||||
t.Fatalf("confirm: %d", status)
|
||||
// Even turning it on needs the password: on a stolen session it would
|
||||
// lock the owner out.
|
||||
if status, body := h.call("POST", "/api/v1/auth/totp/enroll", tok, nil); status != http.StatusForbidden {
|
||||
t.Fatalf("enroll without the password: %d %v", status, body)
|
||||
}
|
||||
sec := h.enrollTOTP(t, tok, adminPassword)
|
||||
code := codeAt(t, sec, 0)
|
||||
|
||||
// A session alone can no longer swap the secret or turn it off.
|
||||
if status, body := h.call("POST", "/api/v1/auth/totp/enroll", tok, nil); status != http.StatusForbidden || body["code"] != "reauth_required" {
|
||||
@@ -98,6 +123,11 @@ func TestReplacingOrRemovingTheSecondFactorNeedsThePassword(t *testing.T) {
|
||||
map[string]string{"password": "wrong-password", "totp": code}); status != http.StatusForbidden || body["code"] != "bad_credentials" {
|
||||
t.Fatalf("disable with a wrong password: %d %v", status, body)
|
||||
}
|
||||
// A wrong code reads the same as a wrong password.
|
||||
if status, body := h.call("DELETE", "/api/v1/auth/totp", tok,
|
||||
map[string]string{"password": adminPassword, "totp": "000000"}); status != http.StatusForbidden || body["code"] != "bad_credentials" {
|
||||
t.Fatalf("disable with a wrong code: %d %v", status, body)
|
||||
}
|
||||
var on bool
|
||||
h.srv.Store().Read(func(st *store.State) { on = st.Users["admin"].TOTPConfirmed })
|
||||
if !on {
|
||||
@@ -171,6 +201,11 @@ func TestAccountPageSetsUpTheSecondFactor(t *testing.T) {
|
||||
csrf := b.signIn()
|
||||
|
||||
_, page := b.post("/web/account", url.Values{"csrf": {csrf}, "action": {"totp-begin"}})
|
||||
if !strings.Contains(page, "Confirm with your password") {
|
||||
t.Fatalf("set up without the password:\n%s", page)
|
||||
}
|
||||
_, page = b.post("/web/account", url.Values{"csrf": {csrf}, "action": {"totp-begin"},
|
||||
"password": {adminPassword}})
|
||||
if !strings.Contains(page, `href="otpauth://totp/`) {
|
||||
t.Fatalf("no authenticator link:\n%s", page)
|
||||
}
|
||||
@@ -184,8 +219,7 @@ func TestAccountPageSetsUpTheSecondFactor(t *testing.T) {
|
||||
if !strings.Contains(page, "rejected") {
|
||||
t.Fatalf("a wrong code was not refused:\n%s", page)
|
||||
}
|
||||
code, _ := totp.Code(sec, time.Now())
|
||||
_, page = b.post("/web/account", url.Values{"csrf": {csrf}, "action": {"totp-confirm"}, "code": {code}})
|
||||
_, page = b.post("/web/account", url.Values{"csrf": {csrf}, "action": {"totp-confirm"}, "code": {codeAt(t, sec, -1)}})
|
||||
if !strings.Contains(page, "Two-factor authentication is on") {
|
||||
t.Fatalf("not turned on:\n%s", page)
|
||||
}
|
||||
@@ -199,7 +233,7 @@ func TestAccountPageSetsUpTheSecondFactor(t *testing.T) {
|
||||
t.Fatalf("turned off without a code:\n%s", page)
|
||||
}
|
||||
_, page = b.post("/web/account", url.Values{"csrf": {csrf}, "action": {"totp-disable"},
|
||||
"password": {adminPassword}, "totp": {code}})
|
||||
"password": {adminPassword}, "totp": {codeAt(t, sec, 0)}})
|
||||
if !strings.Contains(page, "Two-factor authentication is off") {
|
||||
t.Fatalf("not turned off:\n%s", page)
|
||||
}
|
||||
@@ -257,6 +291,17 @@ func TestBrowserLabel(t *testing.T) {
|
||||
|
||||
// makeUser adds a password account and returns a session token for it.
|
||||
func (h *harness) makeUser(name, password string) string {
|
||||
h.t.Helper()
|
||||
h.makeUserNoLogin(name, password)
|
||||
tok, status, body := h.login(name, password, "")
|
||||
if status != http.StatusOK {
|
||||
h.t.Fatalf("login %s: %d %v", name, status, body)
|
||||
}
|
||||
return tok
|
||||
}
|
||||
|
||||
// makeUserNoLogin adds a password account.
|
||||
func (h *harness) makeUserNoLogin(name, password string) {
|
||||
h.t.Helper()
|
||||
hash, err := secret.Hash(password)
|
||||
if err != nil {
|
||||
@@ -268,9 +313,4 @@ func (h *harness) makeUser(name, password string) string {
|
||||
}); err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
tok, status, body := h.login(name, password, "")
|
||||
if status != http.StatusOK {
|
||||
h.t.Fatalf("login %s: %d %v", name, status, body)
|
||||
}
|
||||
return tok
|
||||
}
|
||||
|
||||
+82
-58
@@ -23,14 +23,19 @@ var errForbidden = errors.New("forbidden")
|
||||
// cases stay distinct so a client knows to prompt for the factor it is
|
||||
// missing rather than reporting a flat refusal.
|
||||
func denyAuth(w http.ResponseWriter, err error) {
|
||||
if writeThrottled(w, err) {
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, errNoDatabase):
|
||||
fail(w, http.StatusNotFound, "not_found", err.Error())
|
||||
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")
|
||||
fail(w, http.StatusUnauthorized, "totp_required", "your one-time code is required for this")
|
||||
case errors.Is(err, errReauth):
|
||||
fail(w, http.StatusUnauthorized, "bad_credentials", "your own account password is required for this")
|
||||
case errors.Is(err, errBadCredentials):
|
||||
fail(w, http.StatusUnauthorized, "bad_credentials",
|
||||
"your own account password is required for this")
|
||||
"your own account password or one-time code is wrong")
|
||||
default:
|
||||
deny(w, err)
|
||||
}
|
||||
@@ -41,6 +46,8 @@ func deny(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case err == nil:
|
||||
return
|
||||
case errors.Is(err, errNoDatabase):
|
||||
fail(w, http.StatusNotFound, "not_found", err.Error())
|
||||
case errors.Is(err, errForbidden):
|
||||
fail(w, http.StatusForbidden, "forbidden", "you do not administer that object")
|
||||
default:
|
||||
@@ -215,16 +222,27 @@ func removeMember(st *store.State, u *store.User, groupID string, refs ...string
|
||||
|
||||
// ---- grants ----
|
||||
|
||||
// adminsDatabase checks that u administers a database. One without any
|
||||
// access cannot tell a database that is not theirs from one that does not
|
||||
// exist; one who can see it is told it is not theirs to change.
|
||||
func adminsDatabase(st *store.State, u *store.User, dbID string) error {
|
||||
perm := st.DatabasePerm(u.ID, dbID)
|
||||
switch {
|
||||
case st.Databases[dbID] == nil || perm == store.PermNone:
|
||||
return errNoDatabase
|
||||
case perm != store.PermAdmin:
|
||||
return errForbidden
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setGrant(st *store.State, u *store.User, groupID, dbID string, perm store.Perm) error {
|
||||
if st.Databases[dbID] == nil {
|
||||
return errors.New("no such database")
|
||||
if err := adminsDatabase(st, u, dbID); err != nil {
|
||||
return err
|
||||
}
|
||||
if st.Groups[groupID] == nil {
|
||||
return errors.New("no such group")
|
||||
}
|
||||
if st.DatabasePerm(u.ID, dbID) != store.PermAdmin {
|
||||
return errForbidden
|
||||
}
|
||||
if !perm.Valid() {
|
||||
return errors.New("perm must be read, write or admin")
|
||||
}
|
||||
@@ -239,11 +257,8 @@ func setGrant(st *store.State, u *store.User, groupID, dbID string, perm store.P
|
||||
}
|
||||
|
||||
func removeGrant(st *store.State, u *store.User, groupID, dbID string) error {
|
||||
if st.Databases[dbID] == nil {
|
||||
return errors.New("no such database")
|
||||
}
|
||||
if st.DatabasePerm(u.ID, dbID) != store.PermAdmin {
|
||||
return errForbidden
|
||||
if err := adminsDatabase(st, u, dbID); err != nil {
|
||||
return err
|
||||
}
|
||||
st.Grants = filterGrants(st.Grants, func(g store.Grant) bool {
|
||||
return g.GroupID != groupID || g.DatabaseID != dbID
|
||||
@@ -445,13 +460,10 @@ func (s *Server) handlePatchDatabase(w http.ResponseWriter, r *http.Request, u *
|
||||
}
|
||||
var out dbView
|
||||
err := s.st.Update(func(st *store.State) error {
|
||||
if err := adminsDatabase(st, u, id); err != nil {
|
||||
return err
|
||||
}
|
||||
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")
|
||||
@@ -483,49 +495,48 @@ func (s *Server) handlePatchDatabase(w http.ResponseWriter, r *http.Request, u *
|
||||
// did not type the name of the thing it is about to destroy.
|
||||
var errConfirmName = errors.New("type the name exactly to confirm")
|
||||
|
||||
// confirmDeletion re-proves who the caller is before something irreversible.
|
||||
// Holding a session is enough to read and write a vault; it is not enough to
|
||||
// destroy every version of one, because a session is what an unlocked laptop
|
||||
// leaves lying around.
|
||||
//
|
||||
// An account that signs in only through an identity provider has no password
|
||||
// to type. Refusing those accounts would mean a deployment where nobody can
|
||||
// delete anything, so they confirm by typing the exact name instead. That is
|
||||
// a weaker gate and it is deliberately the fallback, not the rule.
|
||||
func confirmDeletion(st *store.State, u *store.User, name, password, totpCode, typed string) error {
|
||||
target := st.Users[u.ID]
|
||||
if target == nil {
|
||||
return errBadCredentials
|
||||
}
|
||||
if target.PassHash == "" {
|
||||
if strings.TrimSpace(typed) != name {
|
||||
return errConfirmName
|
||||
}
|
||||
return nil
|
||||
}
|
||||
_, err := checkPassword(st, target.Username, password, totpCode)
|
||||
return err
|
||||
}
|
||||
// errNoDatabase is a database that does not exist or that the caller does
|
||||
// not administer; the two are not told apart, so neither answer says that a
|
||||
// database exists.
|
||||
var errNoDatabase = errors.New("no such database")
|
||||
|
||||
// deleteDatabase removes a database, its grants and every stored version of
|
||||
// the file. Clients keep whatever they already have on disk: the server can
|
||||
// stop tracking a vault, but it has no business reaching into somebody's
|
||||
// home directory.
|
||||
func (s *Server) deleteDatabase(u *store.User, id, password, totpCode, typed string) (string, error) {
|
||||
//
|
||||
// It re-proves who the caller is first. Holding a session is enough to read
|
||||
// and write a vault; it is not enough to destroy every version of one,
|
||||
// because a session is what an unlocked laptop leaves lying around. The
|
||||
// password is checked like a sign-in — outside the store's lock and against
|
||||
// the attempt limits — and only for a caller who administers the database,
|
||||
// so it is not a password oracle for anyone else.
|
||||
//
|
||||
// An account that signs in only through an identity provider has no password
|
||||
// to type. Refusing those accounts would mean a deployment where nobody can
|
||||
// delete anything, so they confirm by typing the exact name instead. That is
|
||||
// a weaker gate and it is deliberately the fallback, not the rule.
|
||||
func (s *Server) deleteDatabase(r *http.Request, u *store.User, id, password, totpCode, typed string) (string, error) {
|
||||
var (
|
||||
name string
|
||||
watchers []string
|
||||
name string
|
||||
hasPassword bool
|
||||
)
|
||||
err := s.st.Update(func(st *store.State) error {
|
||||
s.st.Read(func(st *store.State) {
|
||||
if db := st.Databases[id]; db != nil && st.DatabasePerm(u.ID, id) == store.PermAdmin {
|
||||
name = db.Name
|
||||
}
|
||||
if cur := st.Users[u.ID]; cur != nil {
|
||||
hasPassword = cur.PassHash != ""
|
||||
}
|
||||
})
|
||||
if name == "" {
|
||||
return "", errNoDatabase
|
||||
}
|
||||
var watchers []string
|
||||
remove := func(st *store.State, _ *store.User) 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 cerr := confirmDeletion(st, u, db.Name, password, totpCode, typed); cerr != nil {
|
||||
return cerr
|
||||
if db == nil || st.DatabasePerm(u.ID, id) != store.PermAdmin {
|
||||
return errNoDatabase
|
||||
}
|
||||
name = db.Name
|
||||
// Taken before the delete: the people to tell are the ones who are
|
||||
@@ -534,7 +545,16 @@ func (s *Server) deleteDatabase(u *store.User, id, password, totpCode, typed str
|
||||
delete(st.Databases, id)
|
||||
st.Grants = filterGrants(st.Grants, func(g store.Grant) bool { return g.DatabaseID != id })
|
||||
return nil
|
||||
})
|
||||
}
|
||||
var err error
|
||||
if !hasPassword {
|
||||
if strings.TrimSpace(typed) != name {
|
||||
return "", errConfirmName
|
||||
}
|
||||
err = s.st.Update(func(st *store.State) error { return remove(st, u) })
|
||||
} else {
|
||||
err = s.reconfirm(r, u, password, totpCode, remove)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -555,7 +575,7 @@ func (s *Server) handleDeleteDatabase(w http.ResponseWriter, r *http.Request, u
|
||||
if r.ContentLength > 0 && !readJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if _, err := s.deleteDatabase(u, r.PathValue("id"), req.Password, req.TOTP, req.Confirm); err != nil {
|
||||
if _, err := s.deleteDatabase(r, u, r.PathValue("id"), req.Password, req.TOTP, req.Confirm); err != nil {
|
||||
denyAuth(w, err)
|
||||
return
|
||||
}
|
||||
@@ -777,7 +797,11 @@ func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request, u *stor
|
||||
out := []userView{}
|
||||
s.st.Read(func(st *store.State) {
|
||||
for _, target := range visibleUsers(st, u) {
|
||||
out = append(out, view(target))
|
||||
v := view(target)
|
||||
if !u.ServerAdmin && target.ID != u.ID {
|
||||
v.Email, v.TOTPEnabled, v.ServerAdmin = "", false, false
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"users": out})
|
||||
|
||||
+69
-62
@@ -10,7 +10,6 @@ import (
|
||||
|
||||
"cc.tiredsysadmin.keeporg/internal/secret"
|
||||
"cc.tiredsysadmin.keeporg/internal/store"
|
||||
"cc.tiredsysadmin.keeporg/internal/totp"
|
||||
)
|
||||
|
||||
// MinPasswordLen is the shortest password the server will store.
|
||||
@@ -19,33 +18,31 @@ const MinPasswordLen = 10
|
||||
// deviceTTL bounds how long a pending device login stays open.
|
||||
const deviceTTL = 10 * time.Minute
|
||||
|
||||
// maxPendingDevices bounds the device logins anyone can open without an
|
||||
// account. Each is a row in the state, which every write copies.
|
||||
const maxPendingDevices = 500
|
||||
|
||||
// errBusy means too many device logins are pending.
|
||||
var errBusy = errors.New("too many sign-ins are pending; try again in a few minutes")
|
||||
|
||||
// pruneDevices drops expired device logins and reports whether there is
|
||||
// room for another.
|
||||
func pruneDevices(st *store.State) bool {
|
||||
now := time.Now()
|
||||
for k, d := range st.Devices {
|
||||
if now.After(d.ExpiresAt) {
|
||||
delete(st.Devices, k)
|
||||
}
|
||||
}
|
||||
return len(st.Devices) < maxPendingDevices
|
||||
}
|
||||
|
||||
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"`
|
||||
@@ -78,15 +75,14 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
err := s.signIn(r, req.Username, req.Password, req.TOTP, func(st *store.State, u *store.User) error {
|
||||
sess := s.newSession(st, u.ID, clampLabel(req.Label))
|
||||
resp = loginResponse{Token: sess.Token, ExpiresAt: sess.ExpiresAt, User: view(u)}
|
||||
return nil
|
||||
})
|
||||
if writeThrottled(w, err) {
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, errTOTPRequired):
|
||||
fail(w, http.StatusUnauthorized, "totp_required", "second factor required")
|
||||
@@ -151,19 +147,31 @@ func (s *Server) handleDeviceStart(w http.ResponseWriter, r *http.Request) {
|
||||
"return_url must use the "+returnScheme+": scheme")
|
||||
return
|
||||
}
|
||||
if wait := s.limits.take("start:" + s.clientIP(r)); wait > 0 {
|
||||
writeThrottled(w, &errThrottled{RetryAfter: wait})
|
||||
return
|
||||
}
|
||||
d := &store.DeviceAuth{
|
||||
DeviceCode: secret.Token(),
|
||||
UserCode: secret.UserCode(),
|
||||
Label: req.Label,
|
||||
Label: clampLabel(req.Label),
|
||||
ReturnURL: req.ReturnURL,
|
||||
CreatedAt: time.Now(),
|
||||
ExpiresAt: time.Now().Add(deviceTTL),
|
||||
}
|
||||
if err := s.st.Update(func(st *store.State) error {
|
||||
if !pruneDevices(st) {
|
||||
return errBusy
|
||||
}
|
||||
st.Devices[d.DeviceCode] = d
|
||||
return nil
|
||||
}); err != nil {
|
||||
fail(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
if errors.Is(err, errBusy) {
|
||||
fail(w, http.StatusServiceUnavailable, "busy", err.Error())
|
||||
return
|
||||
}
|
||||
s.log.Printf("device start: %v", err)
|
||||
fail(w, http.StatusInternalServerError, "internal", "could not start the sign-in")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
@@ -237,20 +245,20 @@ func (s *Server) handleDeviceApprove(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
code := strings.ToUpper(strings.TrimSpace(r.FormValue("code")))
|
||||
var returnURL string
|
||||
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")
|
||||
}
|
||||
returnURL = d.ReturnURL
|
||||
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
|
||||
})
|
||||
err := s.signIn(r, r.FormValue("username"), r.FormValue("password"), r.FormValue("totp"),
|
||||
func(st *store.State, u *store.User) error {
|
||||
d := st.DeviceByUserCode(code)
|
||||
if d == nil || time.Now().After(d.ExpiresAt) || d.SessionToken != "" {
|
||||
return errors.New("unknown or expired code")
|
||||
}
|
||||
returnURL = d.ReturnURL
|
||||
d.SessionToken = s.newSession(st, u.ID, d.Label).Token
|
||||
return nil
|
||||
})
|
||||
var throttled *errThrottled
|
||||
switch {
|
||||
case errors.As(err, &throttled):
|
||||
s.renderDevice(w, code, throttled.Error())
|
||||
case errors.Is(err, errTOTPRequired):
|
||||
s.renderDevice(w, code, "This account requires a one-time code.")
|
||||
case errors.Is(err, errTOTPInvalid):
|
||||
@@ -280,28 +288,27 @@ func validReturnURL(raw string) bool {
|
||||
// session, the second factor is required when one is enrolled, and every
|
||||
// other session of that user is revoked, because a password change is what
|
||||
// someone does when they think a session is no longer theirs.
|
||||
func changePassword(st *store.State, username, current, totpCode, next, keepToken string) error {
|
||||
u, err := checkPassword(st, username, current, totpCode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
func (s *Server) changePassword(r *http.Request, u *store.User, current, code, next, keepToken string) error {
|
||||
if len(next) < MinPasswordLen {
|
||||
return fmt.Errorf("the new password must be at least %d characters", MinPasswordLen)
|
||||
}
|
||||
if next == current {
|
||||
return errors.New("the new password is the same as the old one")
|
||||
}
|
||||
// Hashed before the write, for the same reason verify checks outside it.
|
||||
hash, err := secret.Hash(next)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.PassHash = hash
|
||||
for token, sess := range st.Sessions {
|
||||
if sess.UserID == u.ID && token != keepToken {
|
||||
delete(st.Sessions, token)
|
||||
return s.reconfirm(r, u, current, code, func(st *store.State, u *store.User) error {
|
||||
u.PassHash = hash
|
||||
for token, sess := range st.Sessions {
|
||||
if sess.UserID == u.ID && token != keepToken {
|
||||
delete(st.Sessions, token)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request, u *store.User) {
|
||||
@@ -313,17 +320,17 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request, u
|
||||
if !readJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
err := s.st.Update(func(st *store.State) error {
|
||||
return changePassword(st, u.Username, req.CurrentPassword, req.TOTP,
|
||||
req.NewPassword, bearer(r))
|
||||
})
|
||||
err := s.changePassword(r, u, req.CurrentPassword, req.TOTP, req.NewPassword, bearer(r))
|
||||
if writeThrottled(w, err) {
|
||||
return
|
||||
}
|
||||
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", "the current password is wrong")
|
||||
case errors.Is(err, errBadCredentials), errors.Is(err, errReauth):
|
||||
fail(w, http.StatusUnauthorized, "bad_credentials", "the current password or one-time code is wrong")
|
||||
case errors.Is(err, errNoPassword):
|
||||
fail(w, http.StatusUnauthorized, "bad_credentials", err.Error())
|
||||
case err != nil:
|
||||
fail(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cc.tiredsysadmin.keeporg/internal/secret"
|
||||
"cc.tiredsysadmin.keeporg/internal/store"
|
||||
"cc.tiredsysadmin.keeporg/internal/totp"
|
||||
)
|
||||
|
||||
// Proving who someone is: every password and one-time code the server
|
||||
// checks goes through verify, wherever it was typed — the API, the sign-in
|
||||
// form, the device page, a password change, a second-factor change or a
|
||||
// vault deletion. Two things follow from that being one function.
|
||||
//
|
||||
// The password hash is checked outside the store's lock. It is deliberately
|
||||
// slow, and under the write lock it stalled every other request, so a few
|
||||
// guesses a second were a denial of service. verify reads what it needs,
|
||||
// checks it, and commit re-checks inside the write that nothing changed in
|
||||
// between.
|
||||
//
|
||||
// Failures are counted per client address and per account, and a client or
|
||||
// account over its budget is refused before any hash is computed. The
|
||||
// counts live in memory: a restart forgets them, which costs an attacker a
|
||||
// restart they cannot cause.
|
||||
|
||||
// errThrottled means too many failures; RetryAfter says how long to wait.
|
||||
type errThrottled struct{ RetryAfter time.Duration }
|
||||
|
||||
func (e *errThrottled) Error() string {
|
||||
return fmt.Sprintf("too many failed attempts; try again in %s",
|
||||
e.RetryAfter.Round(time.Minute))
|
||||
}
|
||||
|
||||
// credentials is a password, and code, that checked out against an account
|
||||
// as it stood when verify read it.
|
||||
type credentials struct {
|
||||
userID string
|
||||
hash string
|
||||
step int64 // the one-time code's time step, when one was needed
|
||||
totp bool
|
||||
}
|
||||
|
||||
// dummyHash is verified against when there is no real one, so that an
|
||||
// unknown, disabled or password-less account takes as long to refuse as a
|
||||
// wrong password: the timing would otherwise list the usernames.
|
||||
var dummyHash = func() string {
|
||||
h, err := secret.Hash("keeporg timing equaliser, never a real password")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return h
|
||||
}()
|
||||
|
||||
// verify checks a username, password and one-time code.
|
||||
func (s *Server) verify(r *http.Request, username, password, code string) (*credentials, error) {
|
||||
ip := "ip:" + s.clientIP(r)
|
||||
acct := "user:" + strings.ToLower(strings.TrimSpace(username))
|
||||
if wait := s.limits.blocked(ip, acct); wait > 0 {
|
||||
return nil, &errThrottled{RetryAfter: wait}
|
||||
}
|
||||
var (
|
||||
u store.User
|
||||
found bool
|
||||
)
|
||||
s.st.Read(func(st *store.State) {
|
||||
if p := st.UserByName(username); p != nil {
|
||||
u, found = *p, true
|
||||
}
|
||||
})
|
||||
if !found || u.Disabled || u.PassHash == "" {
|
||||
secret.Verify(dummyHash, password)
|
||||
s.limits.fail(ip, acct)
|
||||
return nil, errBadCredentials
|
||||
}
|
||||
if !secret.Verify(u.PassHash, password) {
|
||||
s.limits.fail(ip, acct)
|
||||
return nil, errBadCredentials
|
||||
}
|
||||
c := &credentials{userID: u.ID, hash: u.PassHash}
|
||||
if u.TOTPConfirmed {
|
||||
if strings.TrimSpace(code) == "" {
|
||||
return nil, errTOTPRequired
|
||||
}
|
||||
step, ok := totp.Match(u.TOTPSecret, code, time.Now())
|
||||
if !ok || step <= u.TOTPLastStep {
|
||||
s.limits.fail(ip, acct)
|
||||
return nil, errTOTPInvalid
|
||||
}
|
||||
c.step, c.totp = step, true
|
||||
}
|
||||
s.limits.reset(acct)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// commit re-checks verified credentials inside a write: the account must
|
||||
// still exist, be enabled and have the same password, and the one-time code
|
||||
// is spent. It returns the account to act on.
|
||||
func (c *credentials) commit(st *store.State) (*store.User, error) {
|
||||
u := st.Users[c.userID]
|
||||
if u == nil || u.Disabled || u.PassHash != c.hash {
|
||||
return nil, errBadCredentials
|
||||
}
|
||||
if c.totp && u.TOTPConfirmed {
|
||||
if c.step <= u.TOTPLastStep {
|
||||
return nil, errTOTPInvalid
|
||||
}
|
||||
u.TOTPLastStep = c.step
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// signIn verifies credentials and, inside one write, re-checks them and runs
|
||||
// then on the account. It is the shape of every sign-in.
|
||||
func (s *Server) signIn(r *http.Request, username, password, code string,
|
||||
then func(st *store.State, u *store.User) error) error {
|
||||
c, err := s.verify(r, username, password, code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.st.Update(func(st *store.State) error {
|
||||
u, err := c.commit(st)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return then(st, u)
|
||||
})
|
||||
}
|
||||
|
||||
// reconfirm proves, for a change that weakens or replaces how an account
|
||||
// signs in, that the person holding the session also holds the password and
|
||||
// the current code. Unlike a sign-in it does not say which of the two was
|
||||
// wrong: a stolen session must not become a password oracle.
|
||||
func (s *Server) reconfirm(r *http.Request, u *store.User, password, code string,
|
||||
then func(st *store.State, u *store.User) error) error {
|
||||
// The account as stored now, not as the session saw it at the start of
|
||||
// the request.
|
||||
s.st.Read(func(st *store.State) {
|
||||
if cur := st.Users[u.ID]; cur != nil {
|
||||
cp := *cur
|
||||
u = &cp
|
||||
}
|
||||
})
|
||||
if u.PassHash == "" {
|
||||
return errNoPassword
|
||||
}
|
||||
if password == "" {
|
||||
return errReauth
|
||||
}
|
||||
if u.TOTPConfirmed && strings.TrimSpace(code) == "" {
|
||||
// Asked before the password is checked, so it says nothing about
|
||||
// whether the password was right.
|
||||
return errTOTPRequired
|
||||
}
|
||||
err := s.signIn(r, u.Username, password, code, then)
|
||||
if errors.Is(err, errTOTPInvalid) || errors.Is(err, errTOTPRequired) {
|
||||
return errBadCredentials
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// writeThrottled answers a refused attempt with 429 and Retry-After.
|
||||
func writeThrottled(w http.ResponseWriter, err error) bool {
|
||||
var t *errThrottled
|
||||
if !errors.As(err, &t) {
|
||||
return false
|
||||
}
|
||||
w.Header().Set("Retry-After", strconv.Itoa(int(t.RetryAfter.Seconds())+1))
|
||||
fail(w, http.StatusTooManyRequests, "throttled", t.Error())
|
||||
return true
|
||||
}
|
||||
|
||||
// ---- limits ----
|
||||
|
||||
// limitWindow is how long failures are remembered.
|
||||
const limitWindow = 15 * time.Minute
|
||||
|
||||
// Budgets per window. An address may fail more often than an account, since
|
||||
// several people may share one address; an account fails rarely when its
|
||||
// owner is the one typing.
|
||||
var limitBudget = map[string]int{
|
||||
"ip": 30,
|
||||
"user": 10,
|
||||
"start": 30, // unauthenticated device and single sign-on starts, per address
|
||||
}
|
||||
|
||||
type attempts struct {
|
||||
n int
|
||||
since time.Time
|
||||
}
|
||||
|
||||
// limiter counts failures per key ("ip:…", "user:…", "start:…").
|
||||
type limiter struct {
|
||||
mu sync.Mutex
|
||||
m map[string]*attempts
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func newLimiter() *limiter { return &limiter{m: map[string]*attempts{}, now: time.Now} }
|
||||
|
||||
func budget(key string) int {
|
||||
kind, _, _ := strings.Cut(key, ":")
|
||||
return limitBudget[kind]
|
||||
}
|
||||
|
||||
// blocked says how long until every key is under budget again, or 0.
|
||||
func (l *limiter) blocked(keys ...string) time.Duration {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := l.now()
|
||||
var wait time.Duration
|
||||
for _, k := range keys {
|
||||
a := l.m[k]
|
||||
if a == nil || now.Sub(a.since) >= limitWindow || a.n < budget(k) {
|
||||
continue
|
||||
}
|
||||
if left := limitWindow - now.Sub(a.since); left > wait {
|
||||
wait = left
|
||||
}
|
||||
}
|
||||
return wait
|
||||
}
|
||||
|
||||
// fail counts one failure against each key.
|
||||
func (l *limiter) fail(keys ...string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := l.now()
|
||||
if len(l.m) > 50_000 {
|
||||
for k, a := range l.m {
|
||||
if now.Sub(a.since) >= limitWindow {
|
||||
delete(l.m, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, k := range keys {
|
||||
a := l.m[k]
|
||||
if a == nil || now.Sub(a.since) >= limitWindow {
|
||||
a = &attempts{since: now}
|
||||
l.m[k] = a
|
||||
}
|
||||
a.n++
|
||||
}
|
||||
}
|
||||
|
||||
// take counts one use of key and reports whether it was within budget, for
|
||||
// endpoints limited by use rather than by failure.
|
||||
func (l *limiter) take(key string) time.Duration {
|
||||
if wait := l.blocked(key); wait > 0 {
|
||||
return wait
|
||||
}
|
||||
l.fail(key)
|
||||
return 0
|
||||
}
|
||||
|
||||
func (l *limiter) reset(key string) {
|
||||
l.mu.Lock()
|
||||
delete(l.m, key)
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
// ---- client addresses ----
|
||||
|
||||
// clientIP is the address a request came from. Behind a reverse proxy every
|
||||
// request comes from the proxy, so for a peer listed in trusted_proxies the
|
||||
// address is taken from X-Forwarded-For, right to left, skipping trusted
|
||||
// hops; nobody else gets to choose their own address with a header.
|
||||
func (s *Server) clientIP(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
if !s.trusted(host) {
|
||||
return host
|
||||
}
|
||||
hops := strings.Split(r.Header.Get("X-Forwarded-For"), ",")
|
||||
for i := len(hops) - 1; i >= 0; i-- {
|
||||
h := strings.TrimSpace(hops[i])
|
||||
if h == "" {
|
||||
continue
|
||||
}
|
||||
if !s.trusted(h) {
|
||||
return h
|
||||
}
|
||||
host = h
|
||||
}
|
||||
if real := strings.TrimSpace(r.Header.Get("X-Real-IP")); real != "" && net.ParseIP(real) != nil {
|
||||
return real
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
func (s *Server) trusted(addr string) bool {
|
||||
ip := net.ParseIP(addr)
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
for _, n := range s.proxies {
|
||||
if n.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseProxies reads trusted_proxies: addresses or CIDR ranges.
|
||||
func parseProxies(list []string) ([]*net.IPNet, error) {
|
||||
var out []*net.IPNet
|
||||
for _, p := range list {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(p, "/") {
|
||||
if ip := net.ParseIP(p); ip != nil && ip.To4() != nil {
|
||||
p += "/32"
|
||||
} else {
|
||||
p += "/128"
|
||||
}
|
||||
}
|
||||
_, n, err := net.ParseCIDR(p)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("trusted_proxies: %q: %w", p, err)
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// maxLabel bounds a session or device label a client chooses: it is shown
|
||||
// in lists and stored with every session, and nothing needs more.
|
||||
const maxLabel = 64
|
||||
|
||||
// clampLabel trims a client-chosen label to something displayable.
|
||||
func clampLabel(label string) string {
|
||||
label = strings.Map(func(r rune) rune {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, strings.TrimSpace(label))
|
||||
if r := []rune(label); len(r) > maxLabel {
|
||||
label = string(r[:maxLabel])
|
||||
}
|
||||
return label
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFailedSignInsAreThrottledPerAccount(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
for i := 0; i < limitBudget["user"]; i++ {
|
||||
if _, status, _ := h.login("admin", "wrong-password-x", ""); status != http.StatusUnauthorized {
|
||||
t.Fatalf("attempt %d: %d", i, status)
|
||||
}
|
||||
}
|
||||
// Over budget, even the right password is refused, before any hashing.
|
||||
_, status, body := h.login("admin", adminPassword, "")
|
||||
if status != http.StatusTooManyRequests || body["code"] != "throttled" {
|
||||
t.Fatalf("after the budget: %d %v", status, body)
|
||||
}
|
||||
// Another account is not affected.
|
||||
h.makeUserNoLogin("bob", "bob-password-1")
|
||||
if _, status, _ := h.login("bob", "bob-password-1", ""); status != http.StatusOK {
|
||||
t.Fatalf("another account was throttled too: %d", status)
|
||||
}
|
||||
// The window passes.
|
||||
h.srv.limits.now = func() time.Time { return time.Now().Add(limitWindow) }
|
||||
if _, status, _ := h.login("admin", adminPassword, ""); status != http.StatusOK {
|
||||
t.Fatalf("still throttled after the window: %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownUsersCountAgainstTheAddress(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
for i := 0; i < limitBudget["ip"]; i++ {
|
||||
h.login("nobody-"+strings.Repeat("x", i%5+1), "whatever-password", "")
|
||||
}
|
||||
if _, status, _ := h.login("admin", adminPassword, ""); status != http.StatusTooManyRequests {
|
||||
t.Fatalf("an address over its budget could still sign in: %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIPTrustsOnlyConfiguredProxies(t *testing.T) {
|
||||
proxies, err := parseProxies([]string{"10.0.0.0/8", "192.168.1.5"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := &Server{proxies: proxies}
|
||||
req := func(remote, xff string) *http.Request {
|
||||
r := httptest.NewRequest("GET", "/", nil)
|
||||
r.RemoteAddr = remote
|
||||
if xff != "" {
|
||||
r.Header.Set("X-Forwarded-For", xff)
|
||||
}
|
||||
return r
|
||||
}
|
||||
for _, c := range []struct{ remote, xff, want string }{
|
||||
{"203.0.113.9:1234", "1.2.3.4", "203.0.113.9"}, // untrusted peer: header ignored
|
||||
{"10.1.2.3:1234", "198.51.100.7", "198.51.100.7"}, // proxy: its client
|
||||
{"10.1.2.3:1234", "6.6.6.6, 198.51.100.7", "198.51.100.7"}, // a spoofed left entry is skipped
|
||||
{"10.1.2.3:1234", "198.51.100.7, 192.168.1.5", "198.51.100.7"},
|
||||
{"10.1.2.3:1234", "", "10.1.2.3"},
|
||||
} {
|
||||
if got := s.clientIP(req(c.remote, c.xff)); got != c.want {
|
||||
t.Errorf("clientIP(%s, %q) = %s, want %s", c.remote, c.xff, got, c.want)
|
||||
}
|
||||
}
|
||||
if _, err := parseProxies([]string{"not-an-address"}); err == nil {
|
||||
t.Error("a malformed proxy was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClampLabel(t *testing.T) {
|
||||
if got := clampLabel(" phone\x1b[31m\n"); got != "phone[31m" {
|
||||
t.Errorf("control characters kept: %q", got)
|
||||
}
|
||||
if got := clampLabel(strings.Repeat("ä", 200)); len([]rune(got)) != maxLabel {
|
||||
t.Errorf("label not clamped: %d runes", len([]rune(got)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"cc.tiredsysadmin.keeporg/internal/kdbx"
|
||||
"cc.tiredsysadmin.keeporg/internal/sqldb/sqldbtest"
|
||||
)
|
||||
|
||||
// A merge vault must never store a file the server could not afford to open
|
||||
// later: its header is checked before anything is written.
|
||||
func TestMergeVaultRefusesAnOverLimitKDF(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
tok, _, _ := h.login("admin", adminPassword, "")
|
||||
id := h.makeVault(tok, "Merged")
|
||||
|
||||
saved := kdbx.MaxArgon2Iterations
|
||||
kdbx.MaxArgon2Iterations = 10_000
|
||||
heavy, err := kdbx.New("Merged", kdbx.KDFParams{MemoryKiB: 8, Iterations: 5_000, Lanes: 1}).Marshal(vaultPassword)
|
||||
kdbx.MaxArgon2Iterations = saved
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status, body, _ := h.push(tok, id, `"1"`, heavy)
|
||||
if status != http.StatusBadRequest || body["code"] != "invalid_kdbx" {
|
||||
t.Fatalf("over-limit push: %d %v", status, body)
|
||||
}
|
||||
if v := h.srv.blobs.Versions(id); len(v) != 1 {
|
||||
t.Fatalf("versions after a refused push: %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// Two server processes on one database: a push accepted by a process whose
|
||||
// view is stale must not overwrite the version another process committed.
|
||||
func TestStalePushOnAnotherNodeDoesNotOverwrite(t *testing.T) {
|
||||
url := sqldbtest.URL(t)
|
||||
key := filepath.Join(t.TempDir(), "master.key")
|
||||
h := newHarnessWith(t, func(c *Config) { c.Database, c.MasterKeyFile = url, key })
|
||||
tok, _, _ := h.login("admin", adminPassword, "")
|
||||
_, created := h.call("POST", "/api/v1/databases", tok, map[string]string{
|
||||
"name": "Shared", "owner_group_id": "everyone", "merge_strategy": "server-wins"})
|
||||
id := created["id"].(string)
|
||||
if status, body, _ := h.push(tok, id, `"0"`, []byte("version one")); status != http.StatusOK {
|
||||
t.Fatalf("v1: %d %v", status, body)
|
||||
}
|
||||
|
||||
other, err := New(Config{DataDir: t.TempDir(), Database: url, MasterKeyFile: key,
|
||||
ExternalURL: "http://other"}, log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer other.Close()
|
||||
ts := httptest.NewServer(other.Handler())
|
||||
defer ts.Close()
|
||||
put := func(base, data string) int {
|
||||
req, _ := http.NewRequest("PUT", ts.URL+"/api/v1/databases/"+id+"/blob", bytes.NewReader([]byte(data)))
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
req.Header.Set("If-Match", base)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
return resp.StatusCode
|
||||
}
|
||||
if status := put(`"1"`, "version two, by the other node"); status != http.StatusOK {
|
||||
t.Fatalf("v2 on the other node: %d", status)
|
||||
}
|
||||
// The first node has not reloaded yet and still thinks v1 is current.
|
||||
status, body, _ := h.push(tok, id, `"1"`, []byte("a stale edit"))
|
||||
if status != http.StatusConflict {
|
||||
t.Fatalf("stale push: %d %v", status, body)
|
||||
}
|
||||
got, err := h.srv.blobs.Get(id, 2)
|
||||
if err != nil || string(got) != "version two, by the other node" {
|
||||
t.Fatalf("version 2 is now %q (%v)", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryTokenOnlyOpensTheEventStream(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
tok, _, _ := h.login("admin", adminPassword, "")
|
||||
resp, err := http.Get(h.http.URL + "/api/v1/databases?access_token=" + tok)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("a token in the URL opened /databases: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminEndpointsDoNotRevealDatabases(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
admin, _, _ := h.login("admin", adminPassword, "")
|
||||
id := h.makeVault(admin, "Private")
|
||||
bob := h.makeUser("bob", "bob-password-1")
|
||||
for _, target := range []string{id, "does-not-exist"} {
|
||||
status, _ := h.call("PATCH", "/api/v1/databases/"+target, bob, map[string]string{"name": "x"})
|
||||
if status != http.StatusNotFound {
|
||||
t.Errorf("PATCH %s as an outsider: %d", target, status)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,24 +2,21 @@ package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cc.tiredsysadmin.keeporg/internal/secret"
|
||||
"cc.tiredsysadmin.keeporg/internal/store"
|
||||
"cc.tiredsysadmin.keeporg/internal/totp"
|
||||
)
|
||||
|
||||
// blobsExist reports whether the server still keeps any version of a
|
||||
// database on disk.
|
||||
// database.
|
||||
func (h *harness) blobsExist(dbID string) bool {
|
||||
h.t.Helper()
|
||||
_, err := os.Stat(filepath.Join(h.dataDir, "blobs", dbID))
|
||||
return err == nil
|
||||
return len(h.srv.blobs.Versions(dbID)) > 0
|
||||
}
|
||||
|
||||
func TestWebDeletesAVaultOnlyWithTheAdminsPassword(t *testing.T) {
|
||||
@@ -48,7 +45,7 @@ func TestWebDeletesAVaultOnlyWithTheAdminsPassword(t *testing.T) {
|
||||
|
||||
_, page = b.post("/web/vaults/"+id+"/delete", url.Values{
|
||||
"csrf": {csrf}, "password": {"not-the-password"}})
|
||||
if !strings.Contains(page, "not your account password") {
|
||||
if !strings.Contains(page, "password or one-time code is wrong") {
|
||||
t.Fatalf("a wrong password was not reported:\n%s", page)
|
||||
}
|
||||
h.srv.Store().Read(func(st *store.State) {
|
||||
@@ -155,28 +152,15 @@ func TestDeleteAsksForTheSecondFactorToo(t *testing.T) {
|
||||
admin, _, _ := h.login("admin", adminPassword, "")
|
||||
id := h.makeVault(admin, "Guarded")
|
||||
|
||||
status, body := h.call("POST", "/api/v1/auth/totp/enroll", admin, nil)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("enroll: %d %v", status, body)
|
||||
}
|
||||
sec := body["secret"].(string)
|
||||
code, err := totp.Code(sec, time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status, body = h.call("POST", "/api/v1/auth/totp/confirm", admin,
|
||||
map[string]string{"code": code}); status != http.StatusOK {
|
||||
t.Fatalf("confirm: %d %v", status, body)
|
||||
}
|
||||
sec := h.enrollTOTP(t, admin, adminPassword)
|
||||
|
||||
if status, body = h.call("DELETE", "/api/v1/databases/"+id, admin,
|
||||
if status, body := h.call("DELETE", "/api/v1/databases/"+id, admin,
|
||||
map[string]string{"password": adminPassword}); status != http.StatusUnauthorized ||
|
||||
body["code"] != "totp_required" {
|
||||
t.Fatalf("delete without a code: %d %v", status, body)
|
||||
}
|
||||
code, _ = totp.Code(sec, time.Now())
|
||||
if status, body = h.call("DELETE", "/api/v1/databases/"+id, admin,
|
||||
map[string]string{"password": adminPassword, "totp": code}); status != http.StatusOK {
|
||||
if status, body := h.call("DELETE", "/api/v1/databases/"+id, admin,
|
||||
map[string]string{"password": adminPassword, "totp": codeAt(t, sec, 0)}); status != http.StatusOK {
|
||||
t.Fatalf("delete with the code: %d %v", status, body)
|
||||
}
|
||||
}
|
||||
@@ -197,13 +181,13 @@ func TestSingleSignOnAccountConfirmsByName(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := h.srv.deleteDatabase(sso, id, "", "", ""); err != errConfirmName {
|
||||
if _, err := h.srv.deleteDatabase(httptest.NewRequest("POST", "/", nil), sso, id, "", "", ""); err != errConfirmName {
|
||||
t.Fatalf("delete with nothing typed = %v", err)
|
||||
}
|
||||
if _, err := h.srv.deleteDatabase(sso, id, "", "", "federated"); err != errConfirmName {
|
||||
if _, err := h.srv.deleteDatabase(httptest.NewRequest("POST", "/", nil), sso, id, "", "", "federated"); err != errConfirmName {
|
||||
t.Fatalf("delete with the wrong case = %v", err)
|
||||
}
|
||||
if _, err := h.srv.deleteDatabase(sso, id, "", "", "Federated"); err != nil {
|
||||
if _, err := h.srv.deleteDatabase(httptest.NewRequest("POST", "/", nil), sso, id, "", "", "Federated"); err != nil {
|
||||
t.Fatalf("delete with the name typed = %v", err)
|
||||
}
|
||||
h.srv.Store().Read(func(st *store.State) {
|
||||
@@ -222,7 +206,7 @@ func TestDeleteNotifiesTheWatchers(t *testing.T) {
|
||||
|
||||
sub, events := h.srv.hub.Subscribe("admin")
|
||||
defer h.srv.hub.Unsubscribe(sub)
|
||||
if _, err := h.srv.deleteDatabase(&store.User{ID: "admin", Username: "admin"},
|
||||
if _, err := h.srv.deleteDatabase(httptest.NewRequest("POST", "/", nil), &store.User{ID: "admin", Username: "admin"},
|
||||
id, adminPassword, "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -9,15 +9,14 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cc.tiredsysadmin.keeporg/internal/secret"
|
||||
"cc.tiredsysadmin.keeporg/internal/sqldb/sqldbtest"
|
||||
"cc.tiredsysadmin.keeporg/internal/store"
|
||||
"cc.tiredsysadmin.keeporg/internal/totp"
|
||||
)
|
||||
|
||||
type harness struct {
|
||||
@@ -43,6 +42,7 @@ func newHarnessWith(t *testing.T, adjust func(*Config)) *harness {
|
||||
ts := httptest.NewUnstartedServer(nil)
|
||||
cfg := Config{
|
||||
DataDir: dir,
|
||||
Database: sqldbtest.URL(t),
|
||||
ExternalURL: "http://" + ts.Listener.Addr().String(),
|
||||
MasterKeyFile: filepath.Join(t.TempDir(), "master.key"),
|
||||
VaultKDF: testKDF,
|
||||
@@ -54,6 +54,7 @@ func newHarnessWith(t *testing.T, adjust func(*Config)) *harness {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(srv.Close)
|
||||
hash, err := secret.Hash(adminPassword)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -73,14 +74,31 @@ func newHarnessWith(t *testing.T, adjust func(*Config)) *harness {
|
||||
return &harness{t: t, srv: srv, http: ts, dataDir: dir}
|
||||
}
|
||||
|
||||
// stateBytes is the server's whole metadata document as stored on disk.
|
||||
// stateBytes is everything the server has stored, as the database holds it:
|
||||
// every row of every table, concatenated. A test that something is not kept
|
||||
// in the clear searches this.
|
||||
func (h *harness) stateBytes() []byte {
|
||||
h.t.Helper()
|
||||
b, err := os.ReadFile(filepath.Join(h.dataDir, "state.json"))
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
var out []byte
|
||||
for _, q := range []string{
|
||||
`SELECT name, value FROM keeporg_meta`,
|
||||
`SELECT id, body FROM keeporg_records`,
|
||||
`SELECT ref, body FROM keeporg_blobs`,
|
||||
} {
|
||||
rows, err := h.srv.db.Query(q)
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var a, b []byte
|
||||
if err := rows.Scan(&a, &b); err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
out = append(append(out, a...), b...)
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
return b
|
||||
return out
|
||||
}
|
||||
|
||||
// call sends a JSON request and decodes the JSON response.
|
||||
@@ -357,30 +375,28 @@ func TestEventStreamDeliversPush(t *testing.T) {
|
||||
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 {
|
||||
status, body := h.call("POST", "/api/v1/auth/totp/enroll", tok, map[string]string{"password": adminPassword})
|
||||
if status != http.StatusOK || !strings.HasPrefix(body["uri"].(string), "otpauth://totp/") {
|
||||
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 {
|
||||
map[string]string{"code": codeAt(t, sec, -1)}); 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())
|
||||
code := codeAt(t, sec, 0)
|
||||
if _, status, body = h.login("admin", adminPassword, code); status != http.StatusOK {
|
||||
t.Fatalf("login with code: %d %v", status, body)
|
||||
}
|
||||
// The same code a second time is refused: each code is good once.
|
||||
if _, status, body = h.login("admin", adminPassword, code); status != http.StatusUnauthorized ||
|
||||
body["code"] != "totp_invalid" {
|
||||
t.Fatalf("replayed code: %d %v", status, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceLoginApprovedInBrowser(t *testing.T) {
|
||||
@@ -441,7 +457,8 @@ func TestNonAdminCannotAdminister(t *testing.T) {
|
||||
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 {
|
||||
map[string]string{"merge_strategy": "client-wins"}); status != http.StatusNotFound {
|
||||
// 404, not 403: without access the database does not exist for them.
|
||||
t.Fatalf("outsider changed the strategy: %d", status)
|
||||
}
|
||||
// Without any grant the database is invisible, not merely unwritable.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -18,7 +20,21 @@ import (
|
||||
//
|
||||
// Any failure answers with a conflict instead, so the client falls back to
|
||||
// keeping its copy aside rather than losing an edit.
|
||||
func (s *Server) mergePush(w http.ResponseWriter, u *store.User, id string, basedOn int64, incoming []byte) {
|
||||
func (s *Server) mergePush(w http.ResponseWriter, r *http.Request, u *store.User, id string, basedOn int64, incoming []byte) {
|
||||
// A merge runs the vault's key derivation three times and holds two
|
||||
// decrypted copies; only a few run at once, and a push that cannot get
|
||||
// a turn soon is told to retry rather than piling up.
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
select {
|
||||
case s.merges <- struct{}{}:
|
||||
defer func() { <-s.merges }()
|
||||
case <-ctx.Done():
|
||||
w.Header().Set("Retry-After", "5")
|
||||
fail(w, http.StatusServiceUnavailable, "busy", "the server is busy merging; try again shortly")
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
wrapped string
|
||||
current int64
|
||||
@@ -36,7 +52,7 @@ func (s *Server) mergePush(w http.ResponseWriter, u *store.User, id string, base
|
||||
w.Header().Set("ETag", etag(current))
|
||||
w.Header().Set("X-KeepOrg-Strategy", string(store.StrategyMerge))
|
||||
writeJSON(w, http.StatusConflict, map[string]any{
|
||||
"code": "merge_failed", "error": err.Error(),
|
||||
"code": "merge_failed", "error": "the server could not merge this save; the server log says why",
|
||||
"current_version": current, "merge_strategy": store.StrategyMerge,
|
||||
})
|
||||
return
|
||||
@@ -48,7 +64,8 @@ func (s *Server) mergePush(w http.ResponseWriter, u *store.User, id string, base
|
||||
watchers []string
|
||||
stale bool
|
||||
)
|
||||
if err := s.st.Update(func(st *store.State) error {
|
||||
if err := s.st.UpdateWith(func(st *store.State) error {
|
||||
stale = false
|
||||
d := st.Databases[id]
|
||||
if d == nil {
|
||||
return errors.New("no such database")
|
||||
@@ -59,15 +76,18 @@ func (s *Server) mergePush(w http.ResponseWriter, u *store.User, id string, base
|
||||
return nil
|
||||
}
|
||||
next = d.Version + 1
|
||||
if err := s.blobs.Put(id, next, merged); err != nil {
|
||||
return err
|
||||
}
|
||||
d.Version, d.SHA256, d.Size = next, sum, int64(len(merged))
|
||||
d.UpdatedAt, d.UpdatedBy = time.Now(), u.Username
|
||||
watchers = st.Watchers(id)
|
||||
return nil
|
||||
}, func(ctx context.Context, tx *sql.Tx) error {
|
||||
if stale {
|
||||
return nil
|
||||
}
|
||||
return s.blobs.PutTx(ctx, tx, id, next, merged)
|
||||
}); err != nil {
|
||||
fail(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
s.log.Printf("merge %s: %v", id, err)
|
||||
fail(w, http.StatusInternalServerError, "internal", "the merge could not be stored; try again")
|
||||
return
|
||||
}
|
||||
if stale {
|
||||
|
||||
+213
-48
@@ -1,6 +1,8 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -28,6 +30,12 @@ type OIDCConfig struct {
|
||||
AutoCreate bool `json:"auto_create"`
|
||||
// DefaultGroupID is the group auto-created users join.
|
||||
DefaultGroupID string `json:"default_group_id"`
|
||||
// LinkVerifiedEmail lets an identity claim an existing, unlinked local
|
||||
// account whose email it shares, when the provider says the email is
|
||||
// verified. Off by default: whoever controls an identity provider's
|
||||
// email field then controls those accounts. Accounts can always be
|
||||
// linked explicitly from their Account page.
|
||||
LinkVerifiedEmail bool `json:"link_verified_email"`
|
||||
}
|
||||
|
||||
func (c OIDCConfig) Enabled() bool {
|
||||
@@ -85,14 +93,24 @@ func (p *oidcProvider) metadata() (*oidcMetadata, error) {
|
||||
}
|
||||
|
||||
type oidcIdentity struct {
|
||||
Subject string `json:"sub"`
|
||||
Email string `json:"email"`
|
||||
Username string `json:"preferred_username"`
|
||||
Name string `json:"name"`
|
||||
Subject string `json:"sub"`
|
||||
Email string `json:"email"`
|
||||
EmailVerified flexBool `json:"email_verified"`
|
||||
Username string `json:"preferred_username"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// flexBool reads a JSON boolean that some providers send as a string.
|
||||
type flexBool bool
|
||||
|
||||
func (b *flexBool) UnmarshalJSON(data []byte) error {
|
||||
v := strings.Trim(string(data), `"`)
|
||||
*b = flexBool(v == "true")
|
||||
return nil
|
||||
}
|
||||
|
||||
// exchange turns an authorization code into the caller's identity.
|
||||
func (p *oidcProvider) exchange(code string) (*oidcIdentity, error) {
|
||||
func (p *oidcProvider) exchange(code, verifier string) (*oidcIdentity, error) {
|
||||
meta, err := p.metadata()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -101,6 +119,8 @@ func (p *oidcProvider) exchange(code string) (*oidcIdentity, error) {
|
||||
"grant_type": {"authorization_code"},
|
||||
"code": {code},
|
||||
"redirect_uri": {p.redirectURI},
|
||||
// PKCE: only the party that started the flow can spend the code.
|
||||
"code_verifier": {verifier},
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, meta.TokenEndpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
@@ -153,59 +173,114 @@ func (p *oidcProvider) userinfo(endpoint, accessToken string) (*oidcIdentity, er
|
||||
return &id, nil
|
||||
}
|
||||
|
||||
// handleOIDCLogin starts the authorization code flow for a pending device.
|
||||
// oidcCookie binds a single sign-on round trip to the browser that started
|
||||
// it. The state in the URL alone is not enough: a callback link forwarded to
|
||||
// someone else, or a flow started by an attacker's page in a victim's
|
||||
// browser, would otherwise finish in the wrong hands. Lax, because the
|
||||
// provider's redirect back is a cross-site navigation.
|
||||
const oidcCookie = "keeporg_oidc"
|
||||
|
||||
func (s *Server) setOIDCCookie(w http.ResponseWriter, value string, ttl time.Duration) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: oidcCookie, Value: value, Path: "/oidc", HttpOnly: true,
|
||||
Secure: strings.HasPrefix(s.cfg.ExternalURL, "https://"),
|
||||
SameSite: http.SameSiteLaxMode, MaxAge: int(ttl.Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
func oidcCookieMatches(r *http.Request, want string) bool {
|
||||
c, err := r.Cookie(oidcCookie)
|
||||
return err == nil && want != "" && secret.Equal(c.Value, want)
|
||||
}
|
||||
|
||||
// pkceChallenge is the S256 challenge for a verifier.
|
||||
func pkceChallenge(verifier string) string {
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
return base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// handleOIDCLogin starts the authorization code flow. It has three uses:
|
||||
// with ?code= it identifies the person approving a waiting client; with
|
||||
// ?link=1 it links an identity to the account signed in here; without
|
||||
// either it signs a browser in to the web interface.
|
||||
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
|
||||
}
|
||||
if wait := s.limits.take("start:" + s.clientIP(r)); wait > 0 {
|
||||
s.renderDone(w, "Too many sign-in attempts", (&errThrottled{RetryAfter: wait}).Error())
|
||||
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
|
||||
}
|
||||
// With a user code this approves a waiting client; without one it signs
|
||||
// a browser in to the web interface.
|
||||
userCode := strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("code")))
|
||||
state := secret.Token()
|
||||
q := r.URL.Query()
|
||||
userCode := strings.ToUpper(strings.TrimSpace(q.Get("code")))
|
||||
var linkUser string
|
||||
if q.Get("link") == "1" {
|
||||
// Linking changes how an account signs in, so it needs that
|
||||
// account's session and its CSRF token: a page elsewhere must not
|
||||
// be able to link the attacker's identity to the victim's account.
|
||||
sess := s.webUser(r)
|
||||
if sess == nil || !secret.Equal(sess.csrf, q.Get("csrf")) {
|
||||
s.renderDone(w, "Single sign-on not linked", "Start again from your Account page.")
|
||||
return
|
||||
}
|
||||
linkUser = sess.user.ID
|
||||
}
|
||||
state, verifier := secret.Token(), secret.Token()
|
||||
if err := s.st.Update(func(st *store.State) error {
|
||||
if userCode == "" {
|
||||
if !pruneDevices(st) {
|
||||
return errBusy
|
||||
}
|
||||
st.Devices[state] = &store.DeviceAuth{
|
||||
DeviceCode: state, Web: true, Label: "web", OIDCState: state,
|
||||
DeviceCode: state, Web: linkUser == "", LinkUserID: linkUser, Label: "web",
|
||||
OIDCState: state, PKCEVerifier: verifier,
|
||||
CreatedAt: time.Now(), ExpiresAt: time.Now().Add(deviceTTL),
|
||||
}
|
||||
return nil
|
||||
}
|
||||
d := st.DeviceByUserCode(userCode)
|
||||
if d == nil || time.Now().After(d.ExpiresAt) {
|
||||
if d == nil || time.Now().After(d.ExpiresAt) || d.SessionToken != "" {
|
||||
return errors.New("unknown or expired device code")
|
||||
}
|
||||
d.OIDCState = state
|
||||
d.OIDCState, d.PKCEVerifier = state, verifier
|
||||
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},
|
||||
s.setOIDCCookie(w, state, deviceTTL)
|
||||
v := url.Values{
|
||||
"response_type": {"code"},
|
||||
"client_id": {s.oidc.cfg.ClientID},
|
||||
"redirect_uri": {s.oidc.redirectURI},
|
||||
"scope": {s.oidc.cfg.Scopes},
|
||||
"state": {state},
|
||||
"code_challenge": {pkceChallenge(verifier)},
|
||||
"code_challenge_method": {"S256"},
|
||||
}
|
||||
http.Redirect(w, r, meta.AuthorizationEndpoint+"?"+q.Encode(), http.StatusFound)
|
||||
http.Redirect(w, r, meta.AuthorizationEndpoint+"?"+v.Encode(), http.StatusFound)
|
||||
}
|
||||
|
||||
// handleOIDCCallback finishes the flow and approves the waiting device.
|
||||
// handleOIDCCallback finishes the flow. A browser sign-in ends signed in; a
|
||||
// link ends linked; a waiting client is not approved here but shown to the
|
||||
// person for an explicit decision, because arriving at this URL proves only
|
||||
// that a browser with a provider session was sent here, not that its owner
|
||||
// meant to let a device in.
|
||||
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)
|
||||
if q.Get("error") != "" {
|
||||
s.renderDone(w, "Sign-in failed", "The identity provider refused the sign-in.")
|
||||
return
|
||||
}
|
||||
state, code := q.Get("state"), q.Get("code")
|
||||
@@ -213,26 +288,34 @@ func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
if !oidcCookieMatches(r, state) {
|
||||
s.renderDone(w, "Sign-in failed",
|
||||
"This sign-in was not started in this browser. Start again from the same browser.")
|
||||
return
|
||||
}
|
||||
s.setOIDCCookie(w, "", -time.Second)
|
||||
var verifier string
|
||||
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
|
||||
verifier = d.PKCEVerifier
|
||||
}
|
||||
}
|
||||
})
|
||||
if !found {
|
||||
if verifier == "" {
|
||||
s.renderDone(w, "Sign-in failed", "This sign-in request has expired. Start again from the client.")
|
||||
return
|
||||
}
|
||||
id, err := s.oidc.exchange(code)
|
||||
id, err := s.oidc.exchange(code, verifier)
|
||||
if err != nil {
|
||||
s.log.Printf("oidc exchange: %v", err)
|
||||
s.renderDone(w, "Sign-in failed", "The identity provider rejected the sign-in.")
|
||||
return
|
||||
}
|
||||
var webToken, returnURL string
|
||||
var (
|
||||
webToken, linked string
|
||||
confirm *webDeviceConfirm
|
||||
)
|
||||
if err := s.st.Update(func(st *store.State) error {
|
||||
var dev *store.DeviceAuth
|
||||
for _, d := range st.Devices {
|
||||
@@ -243,32 +326,110 @@ func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
|
||||
if dev == nil || time.Now().After(dev.ExpiresAt) {
|
||||
return errors.New("this sign-in request has expired")
|
||||
}
|
||||
dev.OIDCState, dev.PKCEVerifier = "", ""
|
||||
if dev.LinkUserID != "" {
|
||||
delete(st.Devices, dev.DeviceCode)
|
||||
return s.linkIdentity(st, dev.LinkUserID, id, &linked)
|
||||
}
|
||||
u, err := s.linkOIDCUser(st, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dev.OIDCState = ""
|
||||
if dev.Web {
|
||||
token := s.newBrowserSession(st, u.ID, r).Token
|
||||
// Nothing is waiting to poll for this one; the browser in front
|
||||
// of us is the client.
|
||||
webToken = token
|
||||
webToken = s.newBrowserSession(st, u.ID, r).Token
|
||||
delete(st.Devices, dev.DeviceCode)
|
||||
return nil
|
||||
}
|
||||
dev.SessionToken = s.newSession(st, u.ID, dev.Label).Token
|
||||
returnURL = dev.ReturnURL
|
||||
dev.PendingUserID, dev.ApprovalNonce = u.ID, secret.Token()
|
||||
confirm = &webDeviceConfirm{Nonce: dev.ApprovalNonce, User: u.Username, Label: dev.Label,
|
||||
Code: dev.UserCode, Started: ago(dev.CreatedAt)}
|
||||
return nil
|
||||
}); err != nil {
|
||||
s.renderDone(w, "Sign-in failed", err.Error())
|
||||
s.renderDone(w, "Sign-in failed", humanError(err))
|
||||
return
|
||||
}
|
||||
if webToken != "" {
|
||||
switch {
|
||||
case webToken != "":
|
||||
s.setWebCookie(w, webToken, s.cfg.SessionTTL)
|
||||
http.Redirect(w, r, "/web/vaults", http.StatusFound)
|
||||
case linked != "":
|
||||
http.Redirect(w, r, "/web/account", http.StatusFound)
|
||||
default:
|
||||
s.setOIDCCookie(w, confirm.Nonce, deviceTTL)
|
||||
render(w, deviceConfirmTmpl, map[string]any{"Title": "Approve a device", "D": confirm})
|
||||
}
|
||||
}
|
||||
|
||||
// webDeviceConfirm is the question put to someone who signed in through
|
||||
// their provider to approve a device.
|
||||
type webDeviceConfirm struct {
|
||||
Nonce, User, Label, Code, Started string
|
||||
}
|
||||
|
||||
// handleOIDCApprove is the person's answer. It needs the nonce from the
|
||||
// confirmation page and the same nonce in this browser's cookie, so it can
|
||||
// only come from the page the callback showed in this browser.
|
||||
func (s *Server) handleOIDCApprove(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
fail(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
s.renderApproved(w, returnURL)
|
||||
nonce := r.FormValue("nonce")
|
||||
if !oidcCookieMatches(r, nonce) {
|
||||
s.renderDone(w, "Not approved", "This approval was not started in this browser.")
|
||||
return
|
||||
}
|
||||
s.setOIDCCookie(w, "", -time.Second)
|
||||
approve := r.FormValue("decision") == "approve"
|
||||
var returnURL string
|
||||
err := s.st.Update(func(st *store.State) error {
|
||||
for code, d := range st.Devices {
|
||||
if d.ApprovalNonce == "" || !secret.Equal(d.ApprovalNonce, nonce) {
|
||||
continue
|
||||
}
|
||||
if time.Now().After(d.ExpiresAt) || d.SessionToken != "" {
|
||||
return errors.New("this sign-in request has expired")
|
||||
}
|
||||
if !approve {
|
||||
delete(st.Devices, code)
|
||||
return nil
|
||||
}
|
||||
u := st.Users[d.PendingUserID]
|
||||
if u == nil || u.Disabled {
|
||||
return errors.New("this account cannot sign in")
|
||||
}
|
||||
d.ApprovalNonce = ""
|
||||
d.SessionToken = s.newSession(st, u.ID, d.Label).Token
|
||||
returnURL = d.ReturnURL
|
||||
return nil
|
||||
}
|
||||
return errors.New("this sign-in request has expired")
|
||||
})
|
||||
switch {
|
||||
case err != nil:
|
||||
s.renderDone(w, "Not approved", humanError(err))
|
||||
case !approve:
|
||||
s.renderDone(w, "Device refused", "The device was not signed in. If you did not start this, someone tried to.")
|
||||
default:
|
||||
s.renderApproved(w, returnURL)
|
||||
}
|
||||
}
|
||||
|
||||
// linkIdentity links an identity to an account its owner is signed in to.
|
||||
func (s *Server) linkIdentity(st *store.State, userID string, id *oidcIdentity, linked *string) error {
|
||||
u := st.Users[userID]
|
||||
if u == nil || u.Disabled {
|
||||
return errors.New("this account cannot be linked")
|
||||
}
|
||||
issuer := strings.TrimRight(s.oidc.cfg.Issuer, "/")
|
||||
if other := st.UserByOIDC(issuer, id.Subject); other != nil && other.ID != u.ID {
|
||||
return errors.New("this identity is already linked to another account")
|
||||
}
|
||||
u.OIDCIssuer, u.OIDCSubject = issuer, id.Subject
|
||||
*linked = u.Username
|
||||
return nil
|
||||
}
|
||||
|
||||
// linkOIDCUser finds the account behind an identity, provisioning one when
|
||||
@@ -281,20 +442,24 @@ func (s *Server) linkOIDCUser(st *store.State, id *oidcIdentity) (*store.User, e
|
||||
}
|
||||
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
|
||||
// Never by username: anyone who can choose their preferred_username at
|
||||
// the provider could otherwise claim "admin". By email only when the
|
||||
// deployment allows it and the provider vouches for the address, and
|
||||
// never for a server administrator, whose account is linked from its
|
||||
// own Account page or not at all.
|
||||
if s.oidc.cfg.LinkVerifiedEmail && bool(id.EmailVerified) && id.Email != "" {
|
||||
for _, u := range st.Users {
|
||||
if u.OIDCSubject != "" || u.ServerAdmin || u.Disabled {
|
||||
continue
|
||||
}
|
||||
if 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")
|
||||
return nil, errors.New("no account is linked to this identity; sign in with your password and link single sign-on from your Account page")
|
||||
}
|
||||
name := id.Username
|
||||
if name == "" {
|
||||
|
||||
+149
-25
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -17,9 +18,12 @@ import (
|
||||
type fakeIdP struct {
|
||||
*httptest.Server
|
||||
subject, email, username string
|
||||
emailVerified bool
|
||||
// tokenAuth records the client credentials the server presented.
|
||||
tokenAuth [2]string
|
||||
authCalls int
|
||||
// challenge is the PKCE challenge of the last authorization request.
|
||||
challenge string
|
||||
}
|
||||
|
||||
func newFakeIdP(t *testing.T, subject, email, username string) *fakeIdP {
|
||||
@@ -39,6 +43,11 @@ func newFakeIdP(t *testing.T, subject, email, username string) *fakeIdP {
|
||||
mux.HandleFunc("GET /authorize", func(w http.ResponseWriter, r *http.Request) {
|
||||
idp.authCalls++
|
||||
q := r.URL.Query()
|
||||
if q.Get("code_challenge_method") != "S256" || q.Get("code_challenge") == "" {
|
||||
http.Error(w, "PKCE required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
idp.challenge = q.Get("code_challenge")
|
||||
back, err := url.Parse(q.Get("redirect_uri"))
|
||||
if err != nil {
|
||||
http.Error(w, "bad redirect_uri", http.StatusBadRequest)
|
||||
@@ -58,6 +67,10 @@ func newFakeIdP(t *testing.T, subject, email, username string) *fakeIdP {
|
||||
http.Error(w, "bad code", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if pkceChallenge(r.FormValue("code_verifier")) != idp.challenge {
|
||||
http.Error(w, "PKCE verifier does not match", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "an-access-token", "token_type": "Bearer", "expires_in": 300,
|
||||
})
|
||||
@@ -67,8 +80,9 @@ func newFakeIdP(t *testing.T, subject, email, username string) *fakeIdP {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"sub": idp.subject, "email": idp.email, "preferred_username": idp.username,
|
||||
"email_verified": idp.emailVerified,
|
||||
})
|
||||
})
|
||||
return idp
|
||||
@@ -136,54 +150,164 @@ func TestOIDCSignsInToTheWebInterface(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCApprovesAWaitingClient(t *testing.T) {
|
||||
idp := newFakeIdP(t, "subject-2", "dev@example.com", "devuser")
|
||||
h := newHarnessWith(t, idp.withOIDC(true, "everyone"))
|
||||
var noncePattern = regexp.MustCompile(`name="nonce" value="([^"]+)"`)
|
||||
|
||||
// startDeviceApproval starts a device login and takes a browser through
|
||||
// single sign-on for it, up to the confirmation page.
|
||||
func startDeviceApproval(t *testing.T, h *harness, b *browser) (start map[string]any, nonce string) {
|
||||
t.Helper()
|
||||
status, start := h.call("POST", "/api/v1/auth/device/start", "",
|
||||
map[string]string{"label": "phone", "return_url": "keeporg://device-approved"})
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("device start: %d %v", status, start)
|
||||
}
|
||||
userCode := start["user_code"].(string)
|
||||
|
||||
b := newBrowser(t, h)
|
||||
status, page := b.get("/oidc/login?code=" + url.QueryEscape(userCode))
|
||||
if status != http.StatusOK || !strings.Contains(page, "Device approved") {
|
||||
t.Fatalf("device approval through single sign-on: %d\n%s", status, page)
|
||||
status, page := b.get("/oidc/login?code=" + url.QueryEscape(start["user_code"].(string)))
|
||||
if status != http.StatusOK || !strings.Contains(page, "Let a device in?") ||
|
||||
!strings.Contains(page, start["user_code"].(string)) {
|
||||
t.Fatalf("no confirmation page after single sign-on: %d\n%s", status, page)
|
||||
}
|
||||
// A phone is handed back to its app from the provider's redirect too.
|
||||
if !strings.Contains(page, `href="keeporg://device-approved"`) {
|
||||
t.Fatalf("no link back to the app after single sign-on:\n%s", page)
|
||||
m := noncePattern.FindStringSubmatch(page)
|
||||
if m == nil {
|
||||
t.Fatalf("no nonce on the confirmation page:\n%s", page)
|
||||
}
|
||||
return start, m[1]
|
||||
}
|
||||
|
||||
func TestOIDCApprovesAWaitingClient(t *testing.T) {
|
||||
idp := newFakeIdP(t, "subject-2", "dev@example.com", "devuser")
|
||||
h := newHarnessWith(t, idp.withOIDC(true, "everyone"))
|
||||
b := newBrowser(t, h)
|
||||
start, nonce := startDeviceApproval(t, h, b)
|
||||
poll := func() (int, map[string]any) {
|
||||
return h.call("POST", "/api/v1/auth/device/poll", "",
|
||||
map[string]string{"device_code": start["device_code"].(string)})
|
||||
}
|
||||
|
||||
// Signing in at the provider is not yet an approval: one click on a
|
||||
// link must never let somebody else's device in.
|
||||
if status, _ := poll(); status != http.StatusAccepted {
|
||||
t.Fatalf("the device was approved before the person said yes: %d", status)
|
||||
}
|
||||
_, page := b.post("/oidc/approve", url.Values{"nonce": {nonce}, "decision": {"approve"}})
|
||||
if !strings.Contains(page, "Device approved") || !strings.Contains(page, `href="keeporg://device-approved"`) {
|
||||
t.Fatalf("approval page:\n%s", page)
|
||||
}
|
||||
// A device approval must not also hand the browser a session cookie.
|
||||
if b.cookie() != "" {
|
||||
t.Fatal("approving a device set a web session cookie")
|
||||
}
|
||||
|
||||
status, body := h.call("POST", "/api/v1/auth/device/poll", "",
|
||||
map[string]string{"device_code": start["device_code"].(string)})
|
||||
status, body := poll()
|
||||
if status != http.StatusOK || body["token"] == "" {
|
||||
t.Fatalf("poll after single sign-on: %d %v", status, body)
|
||||
t.Fatalf("poll after approval: %d %v", status, body)
|
||||
}
|
||||
if u := body["user"].(map[string]any); u["username"] != "devuser" {
|
||||
t.Fatalf("the session belongs to %v", u["username"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCLinksAnExistingAccountByUsername(t *testing.T) {
|
||||
func TestOIDCDeviceApprovalCanBeRefused(t *testing.T) {
|
||||
idp := newFakeIdP(t, "subject-6", "", "devuser")
|
||||
h := newHarnessWith(t, idp.withOIDC(true, ""))
|
||||
b := newBrowser(t, h)
|
||||
start, nonce := startDeviceApproval(t, h, b)
|
||||
_, page := b.post("/oidc/approve", url.Values{"nonce": {nonce}, "decision": {"deny"}})
|
||||
if !strings.Contains(page, "not signed in") {
|
||||
t.Fatalf("refusal page:\n%s", page)
|
||||
}
|
||||
if status, _ := h.call("POST", "/api/v1/auth/device/poll", "",
|
||||
map[string]string{"device_code": start["device_code"].(string)}); status != http.StatusGone {
|
||||
t.Fatalf("a refused device can still poll: %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
// The attack: someone starts a device login and gets the victim's browser to
|
||||
// the provider with that device's code. The victim's browser ends on the
|
||||
// confirmation page; the approval itself cannot be posted from anywhere
|
||||
// else, because it needs this browser's cookie.
|
||||
func TestOIDCApprovalCannotBePostedFromAnotherBrowser(t *testing.T) {
|
||||
idp := newFakeIdP(t, "subject-7", "", "devuser")
|
||||
h := newHarnessWith(t, idp.withOIDC(true, ""))
|
||||
victim := newBrowser(t, h)
|
||||
start, nonce := startDeviceApproval(t, h, victim)
|
||||
|
||||
attacker := newBrowser(t, h)
|
||||
_, page := attacker.post("/oidc/approve", url.Values{"nonce": {nonce}, "decision": {"approve"}})
|
||||
if !strings.Contains(page, "not started in this browser") {
|
||||
t.Fatalf("an approval was accepted from another browser:\n%s", page)
|
||||
}
|
||||
if status, _ := h.call("POST", "/api/v1/auth/device/poll", "",
|
||||
map[string]string{"device_code": start["device_code"].(string)}); status != http.StatusAccepted {
|
||||
t.Fatalf("the device got in: %d", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCNeverLinksByUsername(t *testing.T) {
|
||||
idp := newFakeIdP(t, "subject-3", "", "admin")
|
||||
h := newHarnessWith(t, idp.withOIDC(true, ""))
|
||||
b := newBrowser(t, h)
|
||||
b.get("/oidc/login")
|
||||
h.srv.Store().Read(func(st *store.State) {
|
||||
if st.Users["admin"].OIDCSubject != "" {
|
||||
t.Fatal("an identity calling itself admin claimed the admin account")
|
||||
}
|
||||
if u := st.UserByOIDC(idp.URL, "subject-3"); u == nil || u.Username == "admin" || u.ServerAdmin {
|
||||
t.Fatalf("the identity got %+v", u)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestOIDCLinksAVerifiedEmailOnlyWhenAllowed(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
allow, verified bool
|
||||
wantLinkedToBob bool
|
||||
}{
|
||||
{"off", false, true, false},
|
||||
{"unverified", true, false, false},
|
||||
{"verified", true, true, true},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
idp := newFakeIdP(t, "subject-8", "bob@example.com", "someone-else")
|
||||
idp.emailVerified = c.verified
|
||||
h := newHarnessWith(t, func(cfg *Config) {
|
||||
idp.withOIDC(false, "")(cfg)
|
||||
cfg.OIDC.LinkVerifiedEmail = c.allow
|
||||
})
|
||||
_ = h.srv.Store().Update(func(st *store.State) error {
|
||||
st.Users["bob"] = &store.User{ID: "bob", Username: "bob", Email: "bob@example.com", PassHash: "x"}
|
||||
return nil
|
||||
})
|
||||
newBrowser(t, h).get("/oidc/login")
|
||||
var linked bool
|
||||
h.srv.Store().Read(func(st *store.State) { linked = st.Users["bob"].OIDCSubject == "subject-8" })
|
||||
if linked != c.wantLinkedToBob {
|
||||
t.Fatalf("linked = %v, want %v", linked, c.wantLinkedToBob)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCIsLinkedFromTheAccountPage(t *testing.T) {
|
||||
idp := newFakeIdP(t, "subject-9", "", "anything")
|
||||
h := newHarnessWith(t, idp.withOIDC(false, ""))
|
||||
b := newBrowser(t, h)
|
||||
csrf := b.signIn()
|
||||
|
||||
if _, page := b.get("/oidc/login"); !strings.Contains(page, "Your vaults") {
|
||||
t.Fatalf("an existing account was not matched:\n%s", page)
|
||||
// Without the session's CSRF token, a link elsewhere cannot do it.
|
||||
b.get("/oidc/login?link=1")
|
||||
_, page := b.get("/web/account")
|
||||
if !strings.Contains(page, "Link single sign-on") {
|
||||
t.Fatalf("the account page does not offer linking:\n%s", page)
|
||||
}
|
||||
var u *store.User
|
||||
h.srv.Store().Read(func(st *store.State) { u = st.Users["admin"] })
|
||||
if u.OIDCSubject != "subject-3" {
|
||||
t.Fatalf("the existing account was not linked: %+v", u)
|
||||
_, page = b.get("/oidc/login?link=1&csrf=" + url.QueryEscape(csrf))
|
||||
if !strings.Contains(page, "single sign-on") || strings.Contains(page, "Link single sign-on") {
|
||||
t.Fatalf("not linked:\n%s", page)
|
||||
}
|
||||
h.srv.Store().Read(func(st *store.State) {
|
||||
if st.Users["admin"].OIDCSubject != "subject-9" {
|
||||
t.Fatalf("admin = %+v", st.Users["admin"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestOIDCRefusesAnUnknownIdentityWhenProvisioningIsOff(t *testing.T) {
|
||||
@@ -206,7 +330,7 @@ func TestOIDCCallbackRejectsAnUnknownState(t *testing.T) {
|
||||
b := newBrowser(t, h)
|
||||
|
||||
_, page := b.get("/oidc/callback?code=authorization-code&state=not-a-real-state")
|
||||
if !strings.Contains(page, "expired") {
|
||||
if !strings.Contains(page, "not started in this browser") {
|
||||
t.Fatalf("a forged callback was accepted:\n%s", page)
|
||||
}
|
||||
if b.cookie() != "" {
|
||||
|
||||
@@ -121,6 +121,7 @@ var signInTmpl = template.Must(template.Must(pages.Clone()).Parse(`
|
||||
<p class="sub">Sign in to create and manage your vaults.</p>
|
||||
{{if .Error}}<p class="err">{{.Error}}</p>{{end}}
|
||||
<form method="post" action="/web/signin">
|
||||
<input type="hidden" name="pre" value="{{.Pre}}">
|
||||
<label for="username">Username</label><input id="username" name="username" autocomplete="username" required autofocus>
|
||||
<label for="password">Password</label><input id="password" name="password" type="password" autocomplete="current-password" required>
|
||||
<label for="totp">One-time code <small>(if enabled)</small></label><input id="totp" name="totp" inputmode="numeric" autocomplete="one-time-code">
|
||||
@@ -236,6 +237,23 @@ to ask for.</p>
|
||||
{{end}}
|
||||
{{end}}`))
|
||||
|
||||
var deviceConfirmTmpl = template.Must(template.Must(pages.Clone()).Parse(`
|
||||
{{define "body"}}{{with .D}}<h1>Let a device in?</h1>
|
||||
<p class="sub">A KeepOrg client asked to sign in as <b>{{.User}}</b>.</p>
|
||||
<table><tbody>
|
||||
<tr><th>Device</th><td class="name">{{if .Label}}{{.Label}}{{else}}unnamed{{end}}</td></tr>
|
||||
<tr><th>Its code</th><td><code>{{.Code}}</code></td></tr>
|
||||
<tr><th>Started</th><td>{{.Started}}</td></tr>
|
||||
</tbody></table>
|
||||
<p class="note">Approve only if you started this sign-in yourself, just now,
|
||||
and your device shows the code above. Anyone who sent you a link to this page
|
||||
gets your access if you approve.</p>
|
||||
<form method="post" action="/oidc/approve">
|
||||
<input type="hidden" name="nonce" value="{{.Nonce}}">
|
||||
<button type="submit" name="decision" value="approve">Approve {{if .Label}}{{.Label}}{{else}}this device{{end}}</button>
|
||||
<button class="secondary" type="submit" name="decision" value="deny">This was not me</button>
|
||||
</form>{{end}}{{end}}`))
|
||||
|
||||
var doneTmpl = template.Must(template.Must(pages.Clone()).Parse(`
|
||||
{{define "body"}}<h1>{{.Title}}</h1><p class="sub">{{.Message}}</p>
|
||||
{{if .Return}}<p><a class="button" href="{{.Return}}">Return to KeepOrg</a></p>{{end}}{{end}}`))
|
||||
@@ -250,9 +268,10 @@ 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) renderSignIn(w http.ResponseWriter, errMsg string) {
|
||||
func (s *Server) renderSignIn(w http.ResponseWriter, r *http.Request, errMsg string) {
|
||||
render(w, signInTmpl, map[string]any{
|
||||
"Title": "Sign in", "Error": errMsg, "OIDC": s.oidc != nil,
|
||||
"Pre": s.preSessionToken(w, r),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"time"
|
||||
|
||||
"cc.tiredsysadmin.keeporg/internal/store"
|
||||
"cc.tiredsysadmin.keeporg/internal/totp"
|
||||
)
|
||||
|
||||
const newAdminPassword = "a-much-better-password"
|
||||
@@ -69,20 +68,14 @@ func TestChangePasswordSwapsThePasswordAndRevokesOtherSessions(t *testing.T) {
|
||||
func TestChangePasswordRequiresTheSecondFactorWhenEnrolled(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
tok, _, _ := h.login("admin", adminPassword, "")
|
||||
_, enroll := h.call("POST", "/api/v1/auth/totp/enroll", tok, nil)
|
||||
sec := enroll["secret"].(string)
|
||||
code, _ := totp.Code(sec, time.Now())
|
||||
if s, _ := h.call("POST", "/api/v1/auth/totp/confirm", tok,
|
||||
map[string]string{"code": code}); s != http.StatusOK {
|
||||
t.Fatal("could not enrol the second factor")
|
||||
}
|
||||
sec := h.enrollTOTP(t, tok, adminPassword)
|
||||
|
||||
status, body := h.call("POST", "/api/v1/auth/password", tok, map[string]string{
|
||||
"current_password": adminPassword, "new_password": newAdminPassword})
|
||||
if status != http.StatusUnauthorized || body["code"] != "totp_required" {
|
||||
t.Fatalf("change without a code: %d %v", status, body)
|
||||
}
|
||||
code, _ = totp.Code(sec, time.Now())
|
||||
code := codeAt(t, sec, 0)
|
||||
if s, b := h.call("POST", "/api/v1/auth/password", tok, map[string]string{
|
||||
"current_password": adminPassword, "new_password": newAdminPassword,
|
||||
"totp": code}); s != http.StatusOK {
|
||||
|
||||
+92
-19
@@ -8,7 +8,9 @@ package server
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -17,6 +19,7 @@ import (
|
||||
"cc.tiredsysadmin.keeporg/internal/blob"
|
||||
"cc.tiredsysadmin.keeporg/internal/kdbx"
|
||||
"cc.tiredsysadmin.keeporg/internal/secret"
|
||||
"cc.tiredsysadmin.keeporg/internal/sqldb"
|
||||
"cc.tiredsysadmin.keeporg/internal/store"
|
||||
)
|
||||
|
||||
@@ -31,9 +34,19 @@ type Config struct {
|
||||
TLSKey string `json:"tls_key,omitempty"`
|
||||
OIDC OIDCConfig `json:"oidc"`
|
||||
|
||||
// MasterKeyFile holds the key that wraps the vault keys the server keeps
|
||||
// in order to merge. It belongs outside DataDir: a copy of the data
|
||||
// directory alone must not be enough to open a vault.
|
||||
// TrustedProxies are the reverse proxies whose X-Forwarded-For is
|
||||
// believed, as addresses or CIDR ranges. Rate limits count per client
|
||||
// address, and behind a proxy every request otherwise comes from it.
|
||||
TrustedProxies []string `json:"trusted_proxies,omitempty"`
|
||||
|
||||
// Database is where the server keeps its data: postgres://… for
|
||||
// PostgreSQL, or sqlite:<path>. The default is a SQLite file in DataDir.
|
||||
Database string `json:"database"`
|
||||
|
||||
// MasterKeyFile holds the key that encrypts everything in the database
|
||||
// and wraps the vault keys the server keeps in order to merge. It belongs
|
||||
// outside DataDir and outside the database's backups: either alone must
|
||||
// not be enough to read anything.
|
||||
MasterKeyFile string `json:"master_key_file"`
|
||||
// VaultKDF is the work factor of vaults this server creates. The server
|
||||
// pays it three times per merge, and so does every client on every open.
|
||||
@@ -49,7 +62,12 @@ func (c *Config) Defaults() {
|
||||
c.DataDir = "/var/lib/keeporg"
|
||||
}
|
||||
if c.ExternalURL == "" {
|
||||
c.ExternalURL = "http://localhost" + c.Addr
|
||||
// ":8765" means every interface; "127.0.0.1:8765" names its host.
|
||||
host := c.Addr
|
||||
if strings.HasPrefix(host, ":") {
|
||||
host = "localhost" + host
|
||||
}
|
||||
c.ExternalURL = "http://" + host
|
||||
}
|
||||
c.ExternalURL = strings.TrimRight(c.ExternalURL, "/")
|
||||
if c.SessionTTL == 0 {
|
||||
@@ -58,6 +76,9 @@ func (c *Config) Defaults() {
|
||||
if c.MaxDBSize == 0 {
|
||||
c.MaxDBSize = 32 << 20
|
||||
}
|
||||
if c.Database == "" {
|
||||
c.Database = "sqlite:" + filepath.Join(c.DataDir, "keeporg.db")
|
||||
}
|
||||
if c.MasterKeyFile == "" {
|
||||
c.MasterKeyFile = "/etc/keeporg/master.key"
|
||||
}
|
||||
@@ -68,33 +89,42 @@ func (c *Config) Defaults() {
|
||||
|
||||
type Server struct {
|
||||
cfg Config
|
||||
db *sqldb.DB
|
||||
st *store.Store
|
||||
blobs *blob.Store
|
||||
hub *Hub
|
||||
oidc *oidcProvider
|
||||
keyring *secret.Keyring
|
||||
limits *limiter
|
||||
proxies []*net.IPNet
|
||||
merges chan struct{} // a slot per merge allowed to run at once
|
||||
mux *http.ServeMux
|
||||
log *log.Logger
|
||||
}
|
||||
|
||||
// New opens the server's data directory and wires up its routes.
|
||||
// New opens the server's database 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"))
|
||||
proxies, err := parseProxies(cfg.TrustedProxies)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blobs, err := blob.Open(filepath.Join(cfg.DataDir, "blobs"))
|
||||
// Everything in the database is sealed under the master key, so there
|
||||
// is no serving without it.
|
||||
kr, err := secret.LoadKeyring(cfg.MasterKeyFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("master key: %w", err)
|
||||
}
|
||||
st, db, err := OpenStore(cfg, kr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &Server{cfg: cfg, st: st, blobs: blobs, hub: NewHub(), mux: http.NewServeMux(), log: lg}
|
||||
// Without a master key the server still serves every strategy that does
|
||||
// not need to read a vault; only merging and vault creation are refused.
|
||||
if kr, kerr := secret.LoadKeyring(cfg.MasterKeyFile); kerr != nil {
|
||||
lg.Printf("no master key, so this server cannot create or merge vaults: %v", kerr)
|
||||
} else {
|
||||
s.keyring = kr
|
||||
s := &Server{cfg: cfg, db: db, st: st, blobs: blob.Open(db, kr.Derive("blobs")),
|
||||
keyring: kr, limits: newLimiter(), proxies: proxies, merges: make(chan struct{}, 2),
|
||||
hub: NewHub(), mux: http.NewServeMux(), log: lg}
|
||||
if err := s.importLegacy(); err != nil {
|
||||
s.Close()
|
||||
return nil, fmt.Errorf("import %s: %w", cfg.DataDir, err)
|
||||
}
|
||||
if cfg.OIDC.Enabled() {
|
||||
s.oidc = newOIDCProvider(cfg.OIDC, cfg.ExternalURL+"/oidc/callback")
|
||||
@@ -146,7 +176,30 @@ func (s *Server) Store() *store.Store { return s.st }
|
||||
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 }
|
||||
// Handler is the server's HTTP surface, with the security headers every
|
||||
// response carries.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
hsts := strings.HasPrefix(s.cfg.ExternalURL, "https://")
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("Referrer-Policy", "no-referrer")
|
||||
// The pages have no scripts, no images and no frames; their only
|
||||
// style is inline. Nothing may frame them, which is what stops a
|
||||
// sign-out or delete button being clicked through a disguise.
|
||||
h.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; "+
|
||||
"form-action 'self'; frame-ancestors 'none'; base-uri 'none'")
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("Cross-Origin-Opener-Policy", "same-origin")
|
||||
// Pages carry CSRF tokens, invite links and, once, a TOTP secret;
|
||||
// API answers carry bearer tokens. None of it belongs in a cache.
|
||||
h.Set("Cache-Control", "no-store")
|
||||
if hsts {
|
||||
h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
|
||||
}
|
||||
s.mux.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// StartHousekeeping runs periodic expiry until done is closed.
|
||||
func (s *Server) StartHousekeeping(done <-chan struct{}) {
|
||||
@@ -215,6 +268,7 @@ func (s *Server) routes() {
|
||||
m.HandleFunc("POST /invite/{token}", s.handleInviteRedeem)
|
||||
m.HandleFunc("GET /oidc/login", s.handleOIDCLogin)
|
||||
m.HandleFunc("GET /oidc/callback", s.handleOIDCCallback)
|
||||
m.HandleFunc("POST /oidc/approve", s.handleOIDCApprove)
|
||||
|
||||
// Synchronization.
|
||||
m.HandleFunc("GET /api/v1/databases", s.authed(s.handleListDatabases))
|
||||
@@ -338,15 +392,34 @@ func (s *Server) markSeen(token string, last time.Time) {
|
||||
})
|
||||
}
|
||||
|
||||
// bearer extracts a token from the Authorization header, or from the
|
||||
// access_token query parameter, which EventSource clients need because they
|
||||
// cannot set headers.
|
||||
// bearer extracts a token from the Authorization header. The event stream
|
||||
// alone also takes ?access_token=, which clients before 0.12 sent; a token
|
||||
// in a URL ends up in proxy logs, so nothing else accepts it, and current
|
||||
// clients send the header there too.
|
||||
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")
|
||||
if r.URL.Path == "/api/v1/events" {
|
||||
return r.URL.Query().Get("access_token")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// sessionAlive reports whether a token still belongs to a live session of
|
||||
// an enabled account, for long requests that outlast their authentication.
|
||||
func (s *Server) sessionAlive(token string) bool {
|
||||
alive := false
|
||||
s.st.Read(func(st *store.State) {
|
||||
sess := st.Sessions[token]
|
||||
if sess == nil || time.Now().After(sess.ExpiresAt) {
|
||||
return
|
||||
}
|
||||
u := st.Users[sess.UserID]
|
||||
alive = u != nil && !u.Disabled
|
||||
})
|
||||
return alive
|
||||
}
|
||||
|
||||
func (s *Server) newSession(st *store.State, userID, label string) *store.Session {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"cc.tiredsysadmin.keeporg/internal/secret"
|
||||
"cc.tiredsysadmin.keeporg/internal/sqldb"
|
||||
"cc.tiredsysadmin.keeporg/internal/store"
|
||||
)
|
||||
|
||||
// OpenStore opens the configured database, proves the master key is the one
|
||||
// it was written with, and loads the metadata. keeporgd -reset-admin uses it
|
||||
// too, so a reset goes through the same checks as the server.
|
||||
func OpenStore(cfg Config, kr *secret.Keyring) (*store.Store, *sqldb.DB, error) {
|
||||
cfg.Defaults()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
db, err := sqldb.Open(ctx, cfg.Database)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := db.CheckKey(ctx, kr.Derive("check")); err != nil {
|
||||
db.Close()
|
||||
return nil, nil, fmt.Errorf("database %s: %w", db.Where, err)
|
||||
}
|
||||
st, err := store.Open(ctx, db, kr.Derive("records"))
|
||||
if err != nil {
|
||||
db.Close()
|
||||
return nil, nil, fmt.Errorf("database %s: %w", db.Where, err)
|
||||
}
|
||||
return st, db, nil
|
||||
}
|
||||
|
||||
// Close stops the store's watcher and closes the database.
|
||||
func (s *Server) Close() {
|
||||
s.st.Close()
|
||||
s.db.Close()
|
||||
}
|
||||
|
||||
// legacyState and legacyBlobs are where servers before 0.11 kept everything,
|
||||
// in the data directory.
|
||||
const (
|
||||
legacyState = "state.json"
|
||||
legacyBlobs = "blobs"
|
||||
)
|
||||
|
||||
// importLegacy moves the data of a server from before 0.11 into the
|
||||
// database, once: when the database is empty and the old files are there.
|
||||
// The payloads go first and the metadata last, in one transaction, so a
|
||||
// crash part-way leaves an empty store and the import simply runs again.
|
||||
//
|
||||
// The old files are renamed rather than deleted, so nothing is lost if the
|
||||
// import turns out wrong. They are not encrypted the way the database is —
|
||||
// state.json holds session tokens in the clear — so the log asks for them to
|
||||
// be deleted once the server is known to work.
|
||||
func (s *Server) importLegacy() error {
|
||||
statePath := filepath.Join(s.cfg.DataDir, legacyState)
|
||||
if _, err := os.Stat(statePath); errors.Is(err, fs.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if !s.st.Empty() {
|
||||
s.log.Printf("WARNING: %s is still there, but the database already holds data, "+
|
||||
"so it was not imported again. Delete it, and %s, once you no longer need them.",
|
||||
statePath, filepath.Join(s.cfg.DataDir, legacyBlobs))
|
||||
return nil
|
||||
}
|
||||
old, err := store.ReadLegacy(statePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := s.blobs.ImportDir(filepath.Join(s.cfg.DataDir, legacyBlobs))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.st.Replace(old); err != nil {
|
||||
return err
|
||||
}
|
||||
stamp := time.Now().UTC().Format("20060102-150405")
|
||||
var renamed []string
|
||||
for _, name := range []string{legacyState, legacyBlobs} {
|
||||
from := filepath.Join(s.cfg.DataDir, name)
|
||||
to := from + ".imported-" + stamp
|
||||
if err := os.Rename(from, to); err == nil {
|
||||
renamed = append(renamed, to)
|
||||
}
|
||||
}
|
||||
s.log.Printf("imported %d accounts, %d databases and %d stored versions from %s into %s",
|
||||
len(old.Users), len(old.Databases), n, s.cfg.DataDir, s.db.Where)
|
||||
s.log.Printf("the old files were kept as %v. They are not encrypted like the database "+
|
||||
"(state.json holds session tokens): delete them once this server works", renamed)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DatabaseWhere is the database's location without any password, for logs.
|
||||
func (s *Server) DatabaseWhere() string { return s.db.Where }
|
||||
@@ -0,0 +1,99 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cc.tiredsysadmin.keeporg/internal/sqldb"
|
||||
"cc.tiredsysadmin.keeporg/internal/sqldb/sqldbtest"
|
||||
"cc.tiredsysadmin.keeporg/internal/store"
|
||||
)
|
||||
|
||||
// A data directory written by a server before 0.11 is imported on the first
|
||||
// start: accounts, sessions and every stored version, after which the old
|
||||
// files are set aside.
|
||||
func TestOldDataDirectoryIsImported(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
old := map[string]any{
|
||||
"users": map[string]any{"u1": map[string]any{"id": "u1", "username": "alice"}},
|
||||
"groups": map[string]any{"g1": map[string]any{"id": "g1", "name": "everyone"}},
|
||||
"databases": map[string]any{"d1": map[string]any{"id": "d1", "name": "Team",
|
||||
"owner_group_id": "g1", "merge_strategy": "server-wins", "version": 2}},
|
||||
"sessions": map[string]any{"old-token": map[string]any{"token": "old-token",
|
||||
"user_id": "u1", "expires_at": time.Now().Add(time.Hour)}},
|
||||
"memberships": []any{map[string]any{"user_id": "u1", "group_id": "g1", "role": "admin"}},
|
||||
}
|
||||
b, _ := json.Marshal(old)
|
||||
os.WriteFile(filepath.Join(dir, "state.json"), b, 0o600)
|
||||
os.MkdirAll(filepath.Join(dir, "blobs", "d1"), 0o700)
|
||||
for v, body := range map[string]string{"1": "first", "2": "second"} {
|
||||
os.WriteFile(filepath.Join(dir, "blobs", "d1", "0000000000000000000"+v+".kdbx"), []byte(body), 0o600)
|
||||
}
|
||||
|
||||
var logs bytes.Buffer
|
||||
cfg := Config{DataDir: dir, Database: sqldbtest.URL(t), MasterKeyFile: filepath.Join(t.TempDir(), "mk")}
|
||||
srv, err := New(cfg, log.New(&logs, "", 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer srv.Close()
|
||||
|
||||
srv.Store().Read(func(st *store.State) {
|
||||
if st.Users["u1"] == nil || st.Sessions["old-token"] == nil || len(st.Memberships) != 1 {
|
||||
t.Fatalf("imported state = %+v", st)
|
||||
}
|
||||
})
|
||||
if got, err := srv.blobs.Get("d1", 2); err != nil || string(got) != "second" {
|
||||
t.Fatalf("imported version 2 = %q, %v", got, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "state.json")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatal("state.json was left in place, to be imported again")
|
||||
}
|
||||
if !strings.Contains(logs.String(), "delete them") {
|
||||
t.Fatalf("the log does not ask for the old files to be deleted:\n%s", logs.String())
|
||||
}
|
||||
|
||||
// A second start finds the database populated and imports nothing.
|
||||
srv.Close()
|
||||
again, err := New(cfg, log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer again.Close()
|
||||
again.Store().Read(func(st *store.State) {
|
||||
if len(st.Users) != 1 {
|
||||
t.Fatalf("users after restart = %d", len(st.Users))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestServerRefusesAnotherMasterKey(t *testing.T) {
|
||||
url := sqldbtest.URL(t)
|
||||
cfg := Config{DataDir: t.TempDir(), Database: url, MasterKeyFile: filepath.Join(t.TempDir(), "mk")}
|
||||
srv, err := New(cfg, log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv.Close()
|
||||
|
||||
cfg.MasterKeyFile = filepath.Join(t.TempDir(), "another")
|
||||
if _, err := New(cfg, log.New(io.Discard, "", 0)); !errors.Is(err, sqldb.ErrWrongKey) {
|
||||
t.Fatalf("started with a different master key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerRefusesToStartWithoutItsMasterKey(t *testing.T) {
|
||||
t.Setenv("KEEPORG_MASTER_KEY_FILE", filepath.Join(t.TempDir(), "not-mounted"))
|
||||
cfg := Config{DataDir: t.TempDir(), Database: sqldbtest.URL(t)}
|
||||
if _, err := New(cfg, log.New(io.Discard, "", 0)); err == nil || !strings.Contains(err.Error(), "master key") {
|
||||
t.Fatalf("started without its master key: %v", err)
|
||||
}
|
||||
}
|
||||
+33
-7
@@ -1,6 +1,8 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -10,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"cc.tiredsysadmin.keeporg/internal/blob"
|
||||
"cc.tiredsysadmin.keeporg/internal/kdbx"
|
||||
"cc.tiredsysadmin.keeporg/internal/store"
|
||||
)
|
||||
|
||||
@@ -145,8 +148,20 @@ func (s *Server) handlePutBlob(w http.ResponseWriter, r *http.Request, u *store.
|
||||
return
|
||||
}
|
||||
|
||||
// The server opens a merge vault, so a file it will one day have to
|
||||
// open must stay within what it can afford to: a header demanding a
|
||||
// billion KDF rounds is refused here, before it is stored and before
|
||||
// any merge tries it.
|
||||
if strategy == store.StrategyMerge {
|
||||
if err := kdbx.Inspect(data); err != nil {
|
||||
fail(w, http.StatusBadRequest, "invalid_kdbx",
|
||||
"this server merges this vault, and cannot accept this file: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if merging {
|
||||
s.mergePush(w, u, id, current, data)
|
||||
s.mergePush(w, r, u, id, current, data)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -156,7 +171,9 @@ func (s *Server) handlePutBlob(w http.ResponseWriter, r *http.Request, u *store.
|
||||
watchers []string
|
||||
name string
|
||||
)
|
||||
err = s.st.Update(func(st *store.State) error {
|
||||
write := false
|
||||
err = s.st.UpdateWith(func(st *store.State) error {
|
||||
write = false
|
||||
d := st.Databases[id]
|
||||
if d == nil {
|
||||
return errors.New("no such database")
|
||||
@@ -169,15 +186,17 @@ func (s *Server) handlePutBlob(w http.ResponseWriter, r *http.Request, u *store.
|
||||
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
|
||||
}
|
||||
next, write = d.Version+1, true
|
||||
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
|
||||
}, func(ctx context.Context, tx *sql.Tx) error {
|
||||
if !write {
|
||||
return nil
|
||||
}
|
||||
return s.blobs.PutTx(ctx, tx, id, next, data)
|
||||
})
|
||||
var ce conflictError
|
||||
switch {
|
||||
@@ -190,7 +209,8 @@ func (s *Server) handlePutBlob(w http.ResponseWriter, r *http.Request, u *store.
|
||||
})
|
||||
return
|
||||
case err != nil:
|
||||
fail(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
s.log.Printf("push %s: %v", id, err)
|
||||
fail(w, http.StatusInternalServerError, "internal", "the push could not be stored; try again")
|
||||
return
|
||||
}
|
||||
if !unchanged {
|
||||
@@ -212,6 +232,7 @@ func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request, u *store.U
|
||||
fail(w, http.StatusInternalServerError, "internal", "streaming unsupported")
|
||||
return
|
||||
}
|
||||
token := bearer(r)
|
||||
id, ch := s.hub.Subscribe(u.ID)
|
||||
defer s.hub.Unsubscribe(id)
|
||||
|
||||
@@ -237,6 +258,11 @@ func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request, u *store.U
|
||||
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", ev.Type, ev.encode())
|
||||
flusher.Flush()
|
||||
case <-keepalive.C:
|
||||
// A stream outlives the request that authenticated it: end it
|
||||
// once the session is revoked or the account disabled.
|
||||
if !s.sessionAlive(token) {
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, ": keepalive\n\n")
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
@@ -40,6 +40,13 @@ func (s *Server) createVault(u *store.User, name, groupID string, strategy store
|
||||
case strategy.NeedsKey() && s.keyring == nil:
|
||||
return dbView{}, errNoKeyring
|
||||
}
|
||||
// Permission first: the key derivation below is deliberately costly,
|
||||
// and nobody who could not create the vault should get to spend it.
|
||||
var allowed bool
|
||||
s.st.Read(func(st *store.State) { allowed = st.Groups[groupID] != nil && st.IsGroupAdmin(u.ID, groupID) })
|
||||
if !allowed {
|
||||
return dbView{}, errForbidden
|
||||
}
|
||||
|
||||
// Build the empty database before touching any state, so a failure here
|
||||
// leaves nothing behind.
|
||||
@@ -125,16 +132,16 @@ func (s *Server) handleSetVaultKey(w http.ResponseWriter, r *http.Request, u *st
|
||||
}
|
||||
var (
|
||||
version int64
|
||||
perm store.Perm
|
||||
access error
|
||||
)
|
||||
s.st.Read(func(st *store.State) {
|
||||
perm = st.DatabasePerm(u.ID, id)
|
||||
access = adminsDatabase(st, u, id)
|
||||
if d := st.Databases[id]; d != nil {
|
||||
version = d.Version
|
||||
}
|
||||
})
|
||||
if perm != store.PermAdmin {
|
||||
deny(w, errForbidden)
|
||||
if access != nil {
|
||||
deny(w, access)
|
||||
return
|
||||
}
|
||||
if !req.Forget {
|
||||
|
||||
+47
-20
@@ -62,7 +62,7 @@ func (s *Server) webPage(fn webHandler) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
sess := s.webUser(r)
|
||||
if sess == nil {
|
||||
s.renderSignIn(w, "")
|
||||
s.renderSignIn(w, r, "")
|
||||
return
|
||||
}
|
||||
fn(w, r, sess)
|
||||
@@ -75,7 +75,7 @@ func (s *Server) webForm(fn webHandler) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
sess := s.webUser(r)
|
||||
if sess == nil {
|
||||
s.renderSignIn(w, "")
|
||||
s.renderSignIn(w, r, "")
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil || !s.checkCSRF(r, sess) {
|
||||
@@ -108,7 +108,28 @@ func (s *Server) handleWebHome(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/web/vaults", http.StatusFound)
|
||||
return
|
||||
}
|
||||
s.renderSignIn(w, "")
|
||||
s.renderSignIn(w, r, "")
|
||||
}
|
||||
|
||||
// preCookie carries a token that the sign-in form must echo. Without it a
|
||||
// page elsewhere could post the form with its own credentials and sign a
|
||||
// visitor in to the attacker's account, where whatever they then create
|
||||
// lands. Strict, so a cross-site post does not carry it.
|
||||
const preCookie = "keeporg_pre"
|
||||
|
||||
// preSessionToken returns this browser's sign-in form token, issuing one if
|
||||
// it has none.
|
||||
func (s *Server) preSessionToken(w http.ResponseWriter, r *http.Request) string {
|
||||
if c, err := r.Cookie(preCookie); err == nil && len(c.Value) == 64 {
|
||||
return c.Value
|
||||
}
|
||||
tok := secret.Token()
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: preCookie, Value: tok, Path: "/web", HttpOnly: true,
|
||||
Secure: strings.HasPrefix(s.cfg.ExternalURL, "https://"),
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
return tok
|
||||
}
|
||||
|
||||
func (s *Server) handleWebSignIn(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -116,25 +137,28 @@ func (s *Server) handleWebSignIn(w http.ResponseWriter, r *http.Request) {
|
||||
fail(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
if c, err := r.Cookie(preCookie); err != nil || !secret.Equal(c.Value, r.FormValue("pre")) {
|
||||
s.renderSignIn(w, r, "The sign-in form had expired. Sign in again.")
|
||||
return
|
||||
}
|
||||
var token string
|
||||
err := s.st.Update(func(st *store.State) error {
|
||||
u, err := checkPassword(st, r.FormValue("username"), r.FormValue("password"),
|
||||
r.FormValue("totp"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
token = s.newBrowserSession(st, u.ID, r).Token
|
||||
return nil
|
||||
})
|
||||
err := s.signIn(r, r.FormValue("username"), r.FormValue("password"), r.FormValue("totp"),
|
||||
func(st *store.State, u *store.User) error {
|
||||
token = s.newBrowserSession(st, u.ID, r).Token
|
||||
return nil
|
||||
})
|
||||
var throttled *errThrottled
|
||||
switch {
|
||||
case errors.As(err, &throttled):
|
||||
s.renderSignIn(w, r, throttled.Error())
|
||||
case errors.Is(err, errTOTPRequired):
|
||||
s.renderSignIn(w, "This account needs its one-time code.")
|
||||
s.renderSignIn(w, r, "This account needs its one-time code.")
|
||||
case errors.Is(err, errTOTPInvalid):
|
||||
s.renderSignIn(w, "That one-time code was rejected.")
|
||||
s.renderSignIn(w, r, "That one-time code was rejected.")
|
||||
case errors.Is(err, errBadCredentials):
|
||||
s.renderSignIn(w, "Unknown user or wrong password.")
|
||||
s.renderSignIn(w, r, "Unknown user or wrong password.")
|
||||
case err != nil:
|
||||
s.renderSignIn(w, err.Error())
|
||||
s.renderSignIn(w, r, err.Error())
|
||||
default:
|
||||
s.setWebCookie(w, token, s.cfg.SessionTTL)
|
||||
http.Redirect(w, r, "/web/vaults", http.StatusFound)
|
||||
@@ -246,15 +270,18 @@ func (s *Server) handleWebDeleteVaultPage(w http.ResponseWriter, r *http.Request
|
||||
|
||||
func (s *Server) handleWebDeleteVault(w http.ResponseWriter, r *http.Request, sess *webSession) {
|
||||
id := r.PathValue("id")
|
||||
name, err := s.deleteDatabase(sess.user, id, r.FormValue("password"),
|
||||
name, err := s.deleteDatabase(r, sess.user, id, r.FormValue("password"),
|
||||
r.FormValue("totp"), r.FormValue("confirm_name"))
|
||||
var throttled *errThrottled
|
||||
switch {
|
||||
case errors.As(err, &throttled):
|
||||
s.renderDeleteVault(w, sess, id, throttled.Error())
|
||||
case errors.Is(err, errReauth):
|
||||
s.renderDeleteVault(w, sess, id, "Enter your account password.")
|
||||
case errors.Is(err, errTOTPRequired):
|
||||
s.renderDeleteVault(w, sess, id, "Enter your one-time code as well.")
|
||||
case errors.Is(err, errTOTPInvalid):
|
||||
s.renderDeleteVault(w, sess, id, "That one-time code was rejected.")
|
||||
case errors.Is(err, errBadCredentials):
|
||||
s.renderDeleteVault(w, sess, id, "That is not your account password.")
|
||||
s.renderDeleteVault(w, sess, id, "Your account password or one-time code is wrong.")
|
||||
case errors.Is(err, errConfirmName):
|
||||
s.renderDeleteVault(w, sess, id, "Type the vault's name exactly as it is shown.")
|
||||
case err != nil:
|
||||
|
||||
@@ -41,8 +41,18 @@ func (b *browser) get(path string) (int, string) {
|
||||
return resp.StatusCode, string(body)
|
||||
}
|
||||
|
||||
var prePattern = regexp.MustCompile(`name="pre" value="([^"]+)"`)
|
||||
|
||||
func (b *browser) post(path string, form url.Values) (int, string) {
|
||||
b.t.Helper()
|
||||
// The sign-in form carries a token from the page that showed it; fill
|
||||
// it in the way a browser would by having loaded the page first.
|
||||
if path == "/web/signin" && form.Get("pre") == "" {
|
||||
_, page := b.get("/web")
|
||||
if m := prePattern.FindStringSubmatch(page); m != nil {
|
||||
form.Set("pre", m[1])
|
||||
}
|
||||
}
|
||||
resp, err := b.http.PostForm(b.h.http.URL+path, form)
|
||||
if err != nil {
|
||||
b.t.Fatal(err)
|
||||
@@ -107,11 +117,49 @@ func TestWebSignInRejectsWrongPassword(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A sign-in form posted from elsewhere carries no pre-session cookie and is
|
||||
// refused, so a page cannot sign a visitor in to an account of its choosing.
|
||||
func TestWebSignInRefusesACrossSitePost(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
resp, err := h.http.Client().PostForm(h.http.URL+"/web/signin", url.Values{
|
||||
"username": {"admin"}, "password": {adminPassword}, "pre": {strings.Repeat("a", 64)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
for _, c := range resp.Cookies() {
|
||||
if c.Name == webCookie && c.Value != "" {
|
||||
t.Fatal("a cross-site sign-in was accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryResponseCarriesSecurityHeaders(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
for _, path := range []string{"/web", "/device", "/api/v1/databases", "/healthz"} {
|
||||
resp, err := h.http.Client().Get(h.http.URL + path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
for _, k := range []string{"Content-Security-Policy", "X-Frame-Options", "X-Content-Type-Options", "Cache-Control"} {
|
||||
if resp.Header.Get(k) == "" {
|
||||
t.Errorf("%s: no %s", path, k)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(resp.Header.Get("Content-Security-Policy"), "frame-ancestors 'none'") {
|
||||
t.Errorf("%s: frames allowed", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebSessionCookieIsHardened(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
b := newBrowser(t, h)
|
||||
_, page := b.get("/web")
|
||||
resp, err := b.http.PostForm(h.http.URL+"/web/signin", url.Values{
|
||||
"username": {"admin"}, "password": {adminPassword}})
|
||||
"username": {"admin"}, "password": {adminPassword},
|
||||
"pre": {prePattern.FindStringSubmatch(page)[1]}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -138,17 +186,27 @@ func TestWebSessionCookieIsHardened(t *testing.T) {
|
||||
func TestWebCookieIsSecureBehindHTTPS(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
h.srv.cfg.ExternalURL = "https://keeporg.example.com"
|
||||
resp, err := h.http.Client().PostForm(h.http.URL+"/web/signin", url.Values{
|
||||
"username": {"admin"}, "password": {adminPassword}})
|
||||
form := url.Values{"username": {"admin"}, "password": {adminPassword}, "pre": {strings.Repeat("a", 64)}}
|
||||
req, _ := http.NewRequest("POST", h.http.URL+"/web/signin", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.AddCookie(&http.Cookie{Name: preCookie, Value: strings.Repeat("a", 64)})
|
||||
resp, err := h.http.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var found bool
|
||||
for _, c := range resp.Request.Response.Cookies() {
|
||||
if c.Name == webCookie {
|
||||
found = true
|
||||
}
|
||||
if c.Name == webCookie && !c.Secure {
|
||||
t.Fatal("the cookie is not marked Secure although the server is https")
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("no session cookie was issued")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebCreatesAVaultThatOpensWithItsPassword(t *testing.T) {
|
||||
|
||||
@@ -32,6 +32,9 @@ type webAccount struct {
|
||||
TOTPKey string
|
||||
TOTPLink template.URL
|
||||
Sessions []webSessionRow
|
||||
// SSOLink starts linking single sign-on to this account, when the
|
||||
// server has a provider and the account is not linked yet.
|
||||
SSOLink string
|
||||
}
|
||||
|
||||
type webMembership struct {
|
||||
@@ -61,6 +64,9 @@ func (s *Server) accountView(sess *webSession) webAccount {
|
||||
Since: u.CreatedAt.UTC().Format("2006-01-02"),
|
||||
HasPassword: u.PassHash != "", TOTPOn: u.TOTPConfirmed,
|
||||
}
|
||||
if s.oidc != nil && u.OIDCSubject == "" && !u.Disabled {
|
||||
v.SSOLink = "/oidc/login?link=1&csrf=" + url.QueryEscape(sess.csrf)
|
||||
}
|
||||
if u.OIDCIssuer != "" {
|
||||
v.SSO = u.OIDCIssuer
|
||||
if iss, err := url.Parse(u.OIDCIssuer); err == nil && iss.Host != "" {
|
||||
@@ -134,6 +140,14 @@ func (s *Server) handleWebAccount(w http.ResponseWriter, r *http.Request, sess *
|
||||
// handleWebAccountAction applies one control of the settings page.
|
||||
func (s *Server) handleWebAccountAction(w http.ResponseWriter, r *http.Request, sess *webSession) {
|
||||
var notice string
|
||||
// Changes that need the password and code are proven outside the
|
||||
// store's lock, then applied inside it.
|
||||
switch r.FormValue("action") {
|
||||
case "password", "totp-begin", "totp-disable":
|
||||
err := s.accountReconfirmed(r, sess, ¬ice)
|
||||
s.finishAccountAction(w, r, sess, err, notice)
|
||||
return
|
||||
}
|
||||
err := s.st.Update(func(st *store.State) error {
|
||||
switch r.FormValue("action") {
|
||||
case "revoke":
|
||||
@@ -144,21 +158,6 @@ func (s *Server) handleWebAccountAction(w http.ResponseWriter, r *http.Request,
|
||||
case "revoke-others":
|
||||
n := revokeOtherSessions(st, sess.user.ID, sess.token)
|
||||
notice = fmt.Sprintf("Signed out %d other %s.", n, plural(n, "session", "sessions"))
|
||||
case "password":
|
||||
next := r.FormValue("new_password")
|
||||
if next != r.FormValue("new_password2") {
|
||||
return errors.New("the two new passwords do not match")
|
||||
}
|
||||
if err := changePassword(st, sess.user.Username, r.FormValue("current_password"),
|
||||
r.FormValue("totp"), next, sess.token); err != nil {
|
||||
return err
|
||||
}
|
||||
notice = "Password changed. Your other sessions were signed out."
|
||||
case "totp-begin":
|
||||
if _, err := beginTOTP(st, sess.user.ID, r.FormValue("password"), r.FormValue("totp")); err != nil {
|
||||
return err
|
||||
}
|
||||
notice = "Add the key below to your authenticator app, then enter the code it shows."
|
||||
case "totp-confirm":
|
||||
if err := confirmTOTP(st, sess.user.ID, r.FormValue("code")); err != nil {
|
||||
return err
|
||||
@@ -167,16 +166,45 @@ func (s *Server) handleWebAccountAction(w http.ResponseWriter, r *http.Request,
|
||||
case "totp-cancel":
|
||||
cancelTOTP(st, sess.user.ID)
|
||||
notice = "Setup cancelled. Nothing changed."
|
||||
case "totp-disable":
|
||||
if err := disableTOTP(st, sess.user.ID, r.FormValue("password"), r.FormValue("totp")); err != nil {
|
||||
return err
|
||||
}
|
||||
notice = "Two-factor authentication is off."
|
||||
default:
|
||||
return errBadAction
|
||||
}
|
||||
return nil
|
||||
})
|
||||
s.finishAccountAction(w, r, sess, err, notice)
|
||||
}
|
||||
|
||||
// accountReconfirmed applies the settings changes that need the password,
|
||||
// and the one-time code when one is on.
|
||||
func (s *Server) accountReconfirmed(r *http.Request, sess *webSession, notice *string) error {
|
||||
switch r.FormValue("action") {
|
||||
case "password":
|
||||
next := r.FormValue("new_password")
|
||||
if next != r.FormValue("new_password2") {
|
||||
return errors.New("the two new passwords do not match")
|
||||
}
|
||||
if err := s.changePassword(r, sess.user, r.FormValue("current_password"),
|
||||
r.FormValue("totp"), next, sess.token); err != nil {
|
||||
return err
|
||||
}
|
||||
*notice = "Password changed. Your other sessions were signed out."
|
||||
case "totp-begin":
|
||||
if err := s.reconfirm(r, sess.user, r.FormValue("password"), r.FormValue("totp"),
|
||||
func(st *store.State, u *store.User) error { _, err := beginTOTP(st, u.ID); return err }); err != nil {
|
||||
return err
|
||||
}
|
||||
*notice = "Add the key below to your authenticator app, then enter the code it shows."
|
||||
case "totp-disable":
|
||||
if err := s.reconfirm(r, sess.user, r.FormValue("password"), r.FormValue("totp"),
|
||||
func(st *store.State, u *store.User) error { return disableTOTP(st, u.ID) }); err != nil {
|
||||
return err
|
||||
}
|
||||
*notice = "Two-factor authentication is off."
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) finishAccountAction(w http.ResponseWriter, r *http.Request, sess *webSession, err error, notice string) {
|
||||
if err != nil {
|
||||
s.renderAccount(w, sess, accountError(err), "")
|
||||
return
|
||||
@@ -191,13 +219,16 @@ func (s *Server) handleWebAccountAction(w http.ResponseWriter, r *http.Request,
|
||||
}
|
||||
|
||||
func accountError(err error) string {
|
||||
var throttled *errThrottled
|
||||
switch {
|
||||
case errors.As(err, &throttled):
|
||||
return throttled.Error()
|
||||
case errors.Is(err, errReauth), errors.Is(err, errTOTPRequired):
|
||||
return "Confirm with your password and your current one-time code."
|
||||
case errors.Is(err, errTOTPInvalid):
|
||||
return "That one-time code was rejected. Check the clock on the device that shows it."
|
||||
case errors.Is(err, errBadCredentials):
|
||||
return "The password is wrong."
|
||||
return "The password or the one-time code is wrong."
|
||||
case errors.Is(err, errNoSuchSession):
|
||||
return "That session is already signed out."
|
||||
}
|
||||
@@ -232,6 +263,7 @@ var accountTmpl = template.Must(template.Must(pages.Clone()).Parse(`
|
||||
{{if .HasPassword}}<span class="tag">password</span>{{end}}
|
||||
{{if .TOTPOn}}<span class="tag">one-time code</span>{{end}}
|
||||
{{if .SSO}}<span class="tag">single sign-on</span> <small>via {{.SSO}}</small>{{end}}
|
||||
{{if .SSOLink}}<br><a href="{{.SSOLink}}">Link single sign-on to this account</a>{{end}}
|
||||
</td></tr>
|
||||
<tr><th>Role</th><td>{{if .ServerAdmin}}server administrator{{else}}member{{end}}</td></tr>
|
||||
<tr><th>Groups</th><td>{{range $i, $g := .Groups}}{{if $i}}, {{end}}{{$g.Name}} <small>({{$g.Role}})</small>{{else}}none{{end}}</td></tr>
|
||||
@@ -310,6 +342,8 @@ each sign-in also needs a code from an app on your phone.</p>
|
||||
<form method="post" action="/web/account">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="action" value="totp-begin">
|
||||
<label for="tb_password">Your password</label>
|
||||
<input id="tb_password" name="password" type="password" autocomplete="current-password" required>
|
||||
<button type="submit">Set up two-factor authentication</button>
|
||||
</form>
|
||||
{{end}}
|
||||
|
||||
@@ -362,10 +362,18 @@ func userRows(st *store.State, u *store.User) []webUserRow {
|
||||
case target.OIDCSubject != "":
|
||||
signIn = "single sign-on"
|
||||
}
|
||||
out = append(out, webUserRow{ID: target.ID, Username: target.Username,
|
||||
row := webUserRow{ID: target.ID, Username: target.Username,
|
||||
Email: target.Email, SignIn: signIn, Groups: strings.Join(mine, ", "),
|
||||
TOTP: target.TOTPConfirmed, ServerAdmin: target.ServerAdmin,
|
||||
Disabled: target.Disabled, Self: target.ID == u.ID})
|
||||
Disabled: target.Disabled, Self: target.ID == u.ID}
|
||||
// A group administrator manages access, not accounts: how somebody
|
||||
// signs in, their address and whether they run the server are no
|
||||
// business of theirs, and a map of weak accounts to whoever can add
|
||||
// people to a group.
|
||||
if !u.ServerAdmin && !row.Self {
|
||||
row.Email, row.SignIn, row.TOTP, row.ServerAdmin = "", "", false, false
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
// Package sqldb is the server's connection to its database: SQLite for a
|
||||
// single node with nothing to set up, PostgreSQL for a deployment that has a
|
||||
// database service. Both are reached through database/sql with pure-Go
|
||||
// drivers, so the server still builds without cgo.
|
||||
//
|
||||
// The schema is three tables and deliberately says nothing about the data:
|
||||
//
|
||||
// keeporg_meta name → value: schema version, write generation, key check
|
||||
// keeporg_records id → sealed record (one user, group, session, …)
|
||||
// keeporg_blobs (ref, version) → sealed KDBX payload
|
||||
//
|
||||
// Every row is encrypted by the caller before it gets here, and named by a
|
||||
// keyed digest rather than by anything readable. What the database, its
|
||||
// backups and its administrators can see is how many rows there are and how
|
||||
// big they are.
|
||||
package sqldb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib" // registers "pgx"
|
||||
_ "modernc.org/sqlite" // registers "sqlite"
|
||||
|
||||
"cc.tiredsysadmin.keeporg/internal/secret"
|
||||
)
|
||||
|
||||
// Dialect is the SQL engine behind a DB.
|
||||
type Dialect string
|
||||
|
||||
const (
|
||||
SQLite Dialect = "sqlite"
|
||||
Postgres Dialect = "postgres"
|
||||
)
|
||||
|
||||
// schemaVersion is the layout this code reads and writes.
|
||||
const schemaVersion = 1
|
||||
|
||||
// DB is an open, migrated database.
|
||||
type DB struct {
|
||||
*sql.DB
|
||||
Dialect Dialect
|
||||
// Where is the database's location with any password removed, for logs.
|
||||
Where string
|
||||
}
|
||||
|
||||
// Open connects to a database and brings its schema up to date. The URL is
|
||||
// postgres://… or postgresql://… for PostgreSQL, and sqlite:<path> or a bare
|
||||
// path for SQLite, whose file is created owner-only if it does not exist.
|
||||
func Open(ctx context.Context, dbURL string) (*DB, error) {
|
||||
var (
|
||||
db *DB
|
||||
err error
|
||||
)
|
||||
switch {
|
||||
case strings.HasPrefix(dbURL, "postgres://"), strings.HasPrefix(dbURL, "postgresql://"):
|
||||
db, err = openPostgres(dbURL)
|
||||
default:
|
||||
db, err = openSQLite(strings.TrimPrefix(dbURL, "sqlite:"))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("database %s: %w", db.Where, err)
|
||||
}
|
||||
if err := db.migrate(ctx); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("database %s: %w", db.Where, err)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func openPostgres(dbURL string) (*DB, error) {
|
||||
sqldb, err := sql.Open("pgx", dbURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
where := dbURL
|
||||
if u, err := url.Parse(dbURL); err == nil {
|
||||
where = u.Redacted()
|
||||
}
|
||||
return &DB{DB: sqldb, Dialect: Postgres, Where: where}, nil
|
||||
}
|
||||
|
||||
func openSQLite(path string) (*DB, error) {
|
||||
if path == "" {
|
||||
return nil, errors.New("sqlite: no database path")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Created here so the file is owner-only from its first byte; SQLite
|
||||
// would otherwise create it with the process umask.
|
||||
if f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
f.Close()
|
||||
}
|
||||
// WAL lets the blob reads of synchronization run beside a write. FULL
|
||||
// synchronous is the durable setting, and the right one for passwords.
|
||||
// An immediate transaction takes the write lock at BEGIN, which is what
|
||||
// makes the generation check in a write transaction meaningful.
|
||||
dsn := "file:" + path +
|
||||
"?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=synchronous(FULL)" +
|
||||
"&_txlock=immediate"
|
||||
sqldb, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &DB{DB: sqldb, Dialect: SQLite, Where: path}, nil
|
||||
}
|
||||
|
||||
func (db *DB) blobType() string {
|
||||
if db.Dialect == Postgres {
|
||||
return "BYTEA"
|
||||
}
|
||||
return "BLOB"
|
||||
}
|
||||
|
||||
func (db *DB) migrate(ctx context.Context) error {
|
||||
stmts := []string{
|
||||
`CREATE TABLE IF NOT EXISTS keeporg_meta (
|
||||
name TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL)`,
|
||||
`CREATE TABLE IF NOT EXISTS keeporg_records (
|
||||
id TEXT PRIMARY KEY,
|
||||
body ` + db.blobType() + ` NOT NULL)`,
|
||||
`CREATE TABLE IF NOT EXISTS keeporg_blobs (
|
||||
ref TEXT NOT NULL,
|
||||
version BIGINT NOT NULL,
|
||||
body ` + db.blobType() + ` NOT NULL,
|
||||
PRIMARY KEY (ref, version))`,
|
||||
}
|
||||
return db.Tx(ctx, func(tx *sql.Tx) error {
|
||||
for _, s := range stmts {
|
||||
if _, err := tx.ExecContext(ctx, s); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
v, err := db.meta(ctx, tx, "schema")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch {
|
||||
case v == "":
|
||||
if err := db.setMeta(ctx, tx, "schema", strconv.Itoa(schemaVersion)); err != nil {
|
||||
return err
|
||||
}
|
||||
return db.setMeta(ctx, tx, "generation", "0")
|
||||
case v != strconv.Itoa(schemaVersion):
|
||||
return fmt.Errorf("schema version %s is not one this server knows (%d); "+
|
||||
"it was written by a newer keeporgd", v, schemaVersion)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// Q rewrites ?-placeholders as $1, $2, … for PostgreSQL. Statements in this
|
||||
// code base are written once, with ?, and pass through here.
|
||||
func (db *DB) Q(q string) string {
|
||||
if db.Dialect != Postgres {
|
||||
return q
|
||||
}
|
||||
var b strings.Builder
|
||||
n := 0
|
||||
for _, r := range q {
|
||||
if r == '?' {
|
||||
n++
|
||||
b.WriteString("$" + strconv.Itoa(n))
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Tx runs fn in a transaction, committing when it returns nil.
|
||||
func (db *DB) Tx(ctx context.Context, fn func(*sql.Tx) error) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fn(tx); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// Querier is what both *sql.DB and *sql.Tx offer.
|
||||
type Querier interface {
|
||||
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
|
||||
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
|
||||
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
|
||||
}
|
||||
|
||||
func (db *DB) meta(ctx context.Context, q Querier, name string) (string, error) {
|
||||
var v string
|
||||
err := q.QueryRowContext(ctx, db.Q(`SELECT value FROM keeporg_meta WHERE name = ?`), name).Scan(&v)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
return v, err
|
||||
}
|
||||
|
||||
func (db *DB) setMeta(ctx context.Context, q Querier, name, value string) error {
|
||||
_, err := q.ExecContext(ctx, db.Q(`INSERT INTO keeporg_meta (name, value) VALUES (?, ?)
|
||||
ON CONFLICT (name) DO UPDATE SET value = excluded.value`), name, value)
|
||||
return err
|
||||
}
|
||||
|
||||
// Generation is the number of committed writes to the records. A process
|
||||
// that holds the records in memory compares it with its own to tell whether
|
||||
// another process has written since.
|
||||
func (db *DB) Generation(ctx context.Context, q Querier) (int64, error) {
|
||||
v, err := db.meta(ctx, q, "generation")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return strconv.ParseInt(v, 10, 64)
|
||||
}
|
||||
|
||||
// LockGeneration reads the generation inside a write transaction and holds
|
||||
// it, so no other writer can commit in between. On PostgreSQL that is a row
|
||||
// lock; a SQLite write transaction already holds the whole database.
|
||||
func (db *DB) LockGeneration(ctx context.Context, tx *sql.Tx) (int64, error) {
|
||||
q := `SELECT value FROM keeporg_meta WHERE name = 'generation'`
|
||||
if db.Dialect == Postgres {
|
||||
q += ` FOR UPDATE`
|
||||
}
|
||||
var v string
|
||||
if err := tx.QueryRowContext(ctx, q).Scan(&v); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return strconv.ParseInt(v, 10, 64)
|
||||
}
|
||||
|
||||
// SetGeneration records a new generation inside the write that made it.
|
||||
func (db *DB) SetGeneration(ctx context.Context, tx *sql.Tx, gen int64) error {
|
||||
return db.setMeta(ctx, tx, "generation", strconv.FormatInt(gen, 10))
|
||||
}
|
||||
|
||||
// ErrWrongKey means the database was encrypted under a different master
|
||||
// key. Carrying on would write rows nobody could read alongside rows this
|
||||
// key cannot, so it is fatal.
|
||||
var ErrWrongKey = errors.New("this master key does not open this database; " +
|
||||
"start with the key it was created with")
|
||||
|
||||
// CheckKey proves that k is the key the database was written with, by
|
||||
// opening a sealed marker stored at its first start. A new database gets the
|
||||
// marker now.
|
||||
func (db *DB) CheckKey(ctx context.Context, k *secret.Keyring) error {
|
||||
const aad = "keeporg key check"
|
||||
v, err := db.meta(ctx, db, "key_check")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if v == "" {
|
||||
sealed := k.Seal([]byte(aad), []byte("keeporg"))
|
||||
return db.setMeta(ctx, db, "key_check", base64.StdEncoding.EncodeToString(sealed))
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(v)
|
||||
if err != nil {
|
||||
return ErrWrongKey
|
||||
}
|
||||
if _, err := k.Open([]byte(aad), raw); err != nil {
|
||||
return ErrWrongKey
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Package sqldbtest gives tests a database of their own: a SQLite file in a
|
||||
// temporary directory, or, when KEEPORG_TEST_POSTGRES names a PostgreSQL
|
||||
// server, a fresh database on it that is dropped afterwards. Every test that
|
||||
// goes through here runs against PostgreSQL by setting one variable.
|
||||
package sqldbtest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"cc.tiredsysadmin.keeporg/internal/secret"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
// PostgresEnv names the PostgreSQL server tests use instead of SQLite, as a
|
||||
// URL whose user may create databases.
|
||||
const PostgresEnv = "KEEPORG_TEST_POSTGRES"
|
||||
|
||||
// URL returns a database URL that is empty and private to t.
|
||||
func URL(t testing.TB) string {
|
||||
t.Helper()
|
||||
admin := os.Getenv(PostgresEnv)
|
||||
if admin == "" {
|
||||
return "sqlite:" + filepath.Join(t.TempDir(), "keeporg.db")
|
||||
}
|
||||
u, err := url.Parse(admin)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", PostgresEnv, err)
|
||||
}
|
||||
name := "keeporg_test_" + secret.ID()
|
||||
db, err := sql.Open("pgx", admin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
if _, err := db.ExecContext(ctx, "CREATE DATABASE "+name); err != nil {
|
||||
db.Close()
|
||||
t.Fatalf("create test database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = db.ExecContext(ctx, "DROP DATABASE IF EXISTS "+name+" WITH (FORCE)")
|
||||
db.Close()
|
||||
})
|
||||
u.Path = "/" + name
|
||||
return u.String()
|
||||
}
|
||||
@@ -124,8 +124,8 @@ func (s *State) Assignments(userID string) []Assignment {
|
||||
// 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 {
|
||||
for id, u := range s.Users {
|
||||
if !u.Disabled && s.DatabasePerm(id, dbID) != PermNone {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
|
||||
+24
-11
@@ -76,17 +76,20 @@ func (s Strategy) Valid() bool {
|
||||
func (s Strategy) NeedsKey() bool { return s == StrategyMerge }
|
||||
|
||||
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"`
|
||||
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"`
|
||||
// TOTPLastStep is the time step of the last one-time code accepted, so
|
||||
// the same code cannot be used twice.
|
||||
TOTPLastStep int64 `json:"totp_last_step,omitempty"`
|
||||
ServerAdmin bool `json:"server_admin"`
|
||||
Disabled bool `json:"disabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type Group struct {
|
||||
@@ -175,6 +178,16 @@ type DeviceAuth struct {
|
||||
Label string `json:"label,omitempty"`
|
||||
SessionToken string `json:"session_token,omitempty"`
|
||||
OIDCState string `json:"oidc_state,omitempty"`
|
||||
// PKCEVerifier proves to the provider that this server started the
|
||||
// round trip that produced a code.
|
||||
PKCEVerifier string `json:"pkce_verifier,omitempty"`
|
||||
// LinkUserID marks a round trip that links an identity to this account
|
||||
// rather than signing in.
|
||||
LinkUserID string `json:"link_user_id,omitempty"`
|
||||
// PendingUserID and ApprovalNonce hold a single sign-on approval that
|
||||
// identified its person but awaits their explicit yes.
|
||||
PendingUserID string `json:"pending_user_id,omitempty"`
|
||||
ApprovalNonce string `json:"approval_nonce,omitempty"`
|
||||
// ReturnURL is where the approval page sends the person back to: the
|
||||
// client's own URL scheme, for a phone whose browser would otherwise be
|
||||
// a dead end. Empty for clients that need no hand-back.
|
||||
|
||||
+367
-57
@@ -1,46 +1,73 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cc.tiredsysadmin.keeporg/internal/secret"
|
||||
"cc.tiredsysadmin.keeporg/internal/sqldb"
|
||||
)
|
||||
|
||||
// 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.
|
||||
// Store is the server's metadata. It is held in memory as one State, which
|
||||
// is what every access decision reads, and persisted in the database as one
|
||||
// sealed row per object.
|
||||
//
|
||||
// Every mutation runs on a private copy of the state; the rows that changed
|
||||
// are written in one transaction, and the copy is swapped in only once that
|
||||
// transaction has committed. A reader never observes a half-applied update,
|
||||
// and a failed one changes nothing.
|
||||
//
|
||||
// Several processes may share one database. Each write first checks the
|
||||
// database's generation: if another process has committed since this one
|
||||
// last loaded, the state is reloaded and the mutation applied again on top.
|
||||
// A background check picks up other processes' writes within a couple of
|
||||
// seconds, so a session revoked through one process stops working in all.
|
||||
type Store struct {
|
||||
path string
|
||||
db *sqldb.DB
|
||||
key *secret.Keyring
|
||||
|
||||
mu sync.RWMutex
|
||||
s *State
|
||||
gen int64
|
||||
rows map[string][32]byte // row id → digest of the plaintext last stored
|
||||
|
||||
stop chan struct{}
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// watchInterval is how often the store checks for writes by other processes.
|
||||
const watchInterval = 2 * time.Second
|
||||
|
||||
// Open loads the state from db. key seals and names the rows; it must be the
|
||||
// key the rows were written with, which sqldb.CheckKey establishes first.
|
||||
func Open(ctx context.Context, db *sqldb.DB, key *secret.Keyring) (*Store, error) {
|
||||
st := &Store{db: db, key: key, stop: make(chan struct{}), done: make(chan struct{})}
|
||||
if err := st.reload(ctx); 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
|
||||
go st.watch()
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// Close stops watching for other processes' writes. The database stays open;
|
||||
// it belongs to whoever opened it.
|
||||
func (st *Store) Close() {
|
||||
select {
|
||||
case <-st.stop:
|
||||
default:
|
||||
close(st.stop)
|
||||
<-st.done
|
||||
}
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
@@ -49,23 +76,104 @@ func (st *Store) Read(fn func(*State)) {
|
||||
fn(st.s)
|
||||
}
|
||||
|
||||
// Empty reports whether nothing has ever been stored.
|
||||
func (st *Store) Empty() bool {
|
||||
st.mu.RLock()
|
||||
defer st.mu.RUnlock()
|
||||
return len(st.rows) == 0
|
||||
}
|
||||
|
||||
// errStale means another process committed since this one loaded.
|
||||
var errStale = errors.New("store: stale generation")
|
||||
|
||||
// Update applies fn to a copy of the state and commits it. If fn or the write
|
||||
// fails, the stored state is left untouched.
|
||||
// fails, the stored state is left untouched. fn may run more than once when
|
||||
// another process writes at the same moment, so it must only change the
|
||||
// state it is given and the variables it reports results through.
|
||||
func (st *Store) Update(fn func(*State) error) error {
|
||||
return st.UpdateWith(fn, nil)
|
||||
}
|
||||
|
||||
// UpdateWith is Update with more writes in the same transaction: inTx runs
|
||||
// after the generation is locked and before the commit, so what it writes
|
||||
// lands together with the state or not at all, and no other process can
|
||||
// write in between. Like fn, it may run more than once.
|
||||
func (st *Store) UpdateWith(fn func(*State) error, inTx func(context.Context, *sql.Tx) error) error {
|
||||
st.mu.Lock()
|
||||
defer st.mu.Unlock()
|
||||
next, err := clone(st.s)
|
||||
if err != nil {
|
||||
return err
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
for attempt := 0; attempt < 5; attempt++ {
|
||||
next, err := clone(st.s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fn(next); err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := st.flatten(next)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
digests := make(map[string][32]byte, len(rows))
|
||||
var upserts []string
|
||||
for id, plain := range rows {
|
||||
d := sha256.Sum256(plain)
|
||||
digests[id] = d
|
||||
if old, ok := st.rows[id]; !ok || old != d {
|
||||
upserts = append(upserts, id)
|
||||
}
|
||||
}
|
||||
var deletes []string
|
||||
for id := range st.rows {
|
||||
if _, ok := rows[id]; !ok {
|
||||
deletes = append(deletes, id)
|
||||
}
|
||||
}
|
||||
if len(upserts) == 0 && len(deletes) == 0 && inTx == nil {
|
||||
st.s = next
|
||||
return nil
|
||||
}
|
||||
err = st.db.Tx(ctx, func(tx *sql.Tx) error {
|
||||
gen, err := st.db.LockGeneration(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if gen != st.gen {
|
||||
return errStale
|
||||
}
|
||||
if inTx != nil {
|
||||
if err := inTx(ctx, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, id := range upserts {
|
||||
if _, err := tx.ExecContext(ctx, st.db.Q(`INSERT INTO keeporg_records (id, body)
|
||||
VALUES (?, ?) ON CONFLICT (id) DO UPDATE SET body = excluded.body`),
|
||||
id, st.key.Seal([]byte(id), rows[id])); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, id := range deletes {
|
||||
if _, err := tx.ExecContext(ctx, st.db.Q(`DELETE FROM keeporg_records WHERE id = ?`), id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return st.db.SetGeneration(ctx, tx, gen+1)
|
||||
})
|
||||
if errors.Is(err, errStale) {
|
||||
if err := st.reloadLocked(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: %w", err)
|
||||
}
|
||||
st.s, st.rows, st.gen = next, digests, st.gen+1
|
||||
return nil
|
||||
}
|
||||
if err := fn(next); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := st.persist(next); err != nil {
|
||||
return err
|
||||
}
|
||||
st.s = next
|
||||
return nil
|
||||
return errors.New("store: the database kept changing under this update; try again")
|
||||
}
|
||||
|
||||
// GC drops expired sessions, device logins and invites.
|
||||
@@ -90,12 +198,214 @@ func (st *Store) GC(now time.Time) error {
|
||||
})
|
||||
}
|
||||
|
||||
func (st *Store) persist(s *State) error {
|
||||
b, err := json.MarshalIndent(s, "", " ")
|
||||
// watch reloads the state when another process has written to the database.
|
||||
func (st *Store) watch() {
|
||||
defer close(st.done)
|
||||
t := time.NewTicker(watchInterval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-st.stop:
|
||||
return
|
||||
case <-t.C:
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), watchInterval)
|
||||
gen, err := st.db.Generation(ctx, st.db)
|
||||
st.mu.RLock()
|
||||
stale := err == nil && gen != st.gen
|
||||
st.mu.RUnlock()
|
||||
if stale {
|
||||
// A failed reload is retried on the next tick; meanwhile the
|
||||
// last good state keeps serving.
|
||||
_ = st.reload(ctx)
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func (st *Store) reload(ctx context.Context) error {
|
||||
st.mu.Lock()
|
||||
defer st.mu.Unlock()
|
||||
return st.reloadLocked(ctx)
|
||||
}
|
||||
|
||||
// reloadLocked reads every row. The generation and the rows are read in one
|
||||
// transaction, so they describe the same moment.
|
||||
func (st *Store) reloadLocked(ctx context.Context) error {
|
||||
s := newState()
|
||||
digests := map[string][32]byte{}
|
||||
var gen int64
|
||||
err := st.db.Tx(ctx, func(tx *sql.Tx) error {
|
||||
var err error
|
||||
if gen, err = st.db.Generation(ctx, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := tx.QueryContext(ctx, `SELECT id, body FROM keeporg_records`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var (
|
||||
id string
|
||||
body []byte
|
||||
)
|
||||
if err := rows.Scan(&id, &body); err != nil {
|
||||
return err
|
||||
}
|
||||
plain, err := st.key.Open([]byte(id), body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("record %s: %w", id, err)
|
||||
}
|
||||
if err := s.addRecord(plain); err != nil {
|
||||
return fmt.Errorf("record %s: %w", id, err)
|
||||
}
|
||||
digests[id] = sha256.Sum256(plain)
|
||||
}
|
||||
return rows.Err()
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: load: %w", err)
|
||||
}
|
||||
s.sortLists()
|
||||
st.s, st.rows, st.gen = s, digests, gen
|
||||
return nil
|
||||
}
|
||||
|
||||
// record is the plaintext of one row: what kind of object, and the object.
|
||||
type record struct {
|
||||
Kind string `json:"k"`
|
||||
Data json.RawMessage `json:"d"`
|
||||
}
|
||||
|
||||
const (
|
||||
kindUser = "user"
|
||||
kindGroup = "group"
|
||||
kindDatabase = "database"
|
||||
kindInvite = "invite"
|
||||
kindSession = "session"
|
||||
kindDevice = "device"
|
||||
kindMembership = "membership"
|
||||
kindGrant = "grant"
|
||||
)
|
||||
|
||||
// flatten turns a state into rows. A row's id is a keyed digest of its kind
|
||||
// and natural key, so ids are stable across writes, and a session token or
|
||||
// an invite token never appears in the database except inside a sealed row.
|
||||
func (st *Store) flatten(s *State) (map[string][]byte, error) {
|
||||
out := map[string][]byte{}
|
||||
put := func(kind, key string, v any) error {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
plain, err := json.Marshal(record{Kind: kind, Data: data})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out[st.key.MAC(kind+"\x00"+key)] = plain
|
||||
return nil
|
||||
}
|
||||
var err error
|
||||
for k, v := range s.Users {
|
||||
err = errors.Join(err, put(kindUser, k, v))
|
||||
}
|
||||
for k, v := range s.Groups {
|
||||
err = errors.Join(err, put(kindGroup, k, v))
|
||||
}
|
||||
for k, v := range s.Databases {
|
||||
err = errors.Join(err, put(kindDatabase, k, v))
|
||||
}
|
||||
for k, v := range s.Invites {
|
||||
err = errors.Join(err, put(kindInvite, k, v))
|
||||
}
|
||||
for k, v := range s.Sessions {
|
||||
err = errors.Join(err, put(kindSession, k, v))
|
||||
}
|
||||
for k, v := range s.Devices {
|
||||
err = errors.Join(err, put(kindDevice, k, v))
|
||||
}
|
||||
for _, m := range s.Memberships {
|
||||
err = errors.Join(err, put(kindMembership, m.UserID+"\x00"+m.GroupID, m))
|
||||
}
|
||||
for _, g := range s.Grants {
|
||||
err = errors.Join(err, put(kindGrant, g.GroupID+"\x00"+g.DatabaseID, g))
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
|
||||
// addRecord puts one row's object back where it belongs in the state.
|
||||
func (s *State) addRecord(plain []byte) error {
|
||||
var r record
|
||||
if err := json.Unmarshal(plain, &r); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeFileAtomic(st.path, b, 0o600)
|
||||
switch r.Kind {
|
||||
case kindUser:
|
||||
v := &User{}
|
||||
if err := json.Unmarshal(r.Data, v); err != nil {
|
||||
return err
|
||||
}
|
||||
s.Users[v.ID] = v
|
||||
case kindGroup:
|
||||
v := &Group{}
|
||||
if err := json.Unmarshal(r.Data, v); err != nil {
|
||||
return err
|
||||
}
|
||||
s.Groups[v.ID] = v
|
||||
case kindDatabase:
|
||||
v := &Database{}
|
||||
if err := json.Unmarshal(r.Data, v); err != nil {
|
||||
return err
|
||||
}
|
||||
s.Databases[v.ID] = v
|
||||
case kindInvite:
|
||||
v := &Invite{}
|
||||
if err := json.Unmarshal(r.Data, v); err != nil {
|
||||
return err
|
||||
}
|
||||
s.Invites[v.Token] = v
|
||||
case kindSession:
|
||||
v := &Session{}
|
||||
if err := json.Unmarshal(r.Data, v); err != nil {
|
||||
return err
|
||||
}
|
||||
s.Sessions[v.Token] = v
|
||||
case kindDevice:
|
||||
v := &DeviceAuth{}
|
||||
if err := json.Unmarshal(r.Data, v); err != nil {
|
||||
return err
|
||||
}
|
||||
s.Devices[v.DeviceCode] = v
|
||||
case kindMembership:
|
||||
var v Membership
|
||||
if err := json.Unmarshal(r.Data, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
s.Memberships = append(s.Memberships, v)
|
||||
case kindGrant:
|
||||
var v Grant
|
||||
if err := json.Unmarshal(r.Data, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
s.Grants = append(s.Grants, v)
|
||||
default:
|
||||
return fmt.Errorf("unknown record kind %q", r.Kind)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sortLists gives the two lists a stable order, since rows come back in
|
||||
// whatever order the database likes.
|
||||
func (s *State) sortLists() {
|
||||
sort.Slice(s.Memberships, func(i, j int) bool {
|
||||
a, b := s.Memberships[i], s.Memberships[j]
|
||||
return a.UserID+"\x00"+a.GroupID < b.UserID+"\x00"+b.GroupID
|
||||
})
|
||||
sort.Slice(s.Grants, func(i, j int) bool {
|
||||
a, b := s.Grants[i], s.Grants[j]
|
||||
return a.GroupID+"\x00"+a.DatabaseID < b.GroupID+"\x00"+b.DatabaseID
|
||||
})
|
||||
}
|
||||
|
||||
func clone(s *State) (*State, error) {
|
||||
@@ -111,26 +421,26 @@ func clone(s *State) (*State, error) {
|
||||
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)
|
||||
// ReadLegacy reads the state.json document that servers before 0.11 kept,
|
||||
// for importing it into a database.
|
||||
func ReadLegacy(path string) (*State, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
if _, err := f.Write(data); err != nil {
|
||||
f.Close()
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
s := newState()
|
||||
if err := json.Unmarshal(b, s); err != nil {
|
||||
return nil, fmt.Errorf("store: parse %s: %w", path, 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)
|
||||
s.init()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Replace overwrites the whole state with s, for an import.
|
||||
func (st *Store) Replace(s *State) error {
|
||||
return st.Update(func(next *State) error {
|
||||
*next = *s
|
||||
next.init()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
+145
-17
@@ -1,44 +1,128 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cc.tiredsysadmin.keeporg/internal/secret"
|
||||
"cc.tiredsysadmin.keeporg/internal/sqldb"
|
||||
"cc.tiredsysadmin.keeporg/internal/sqldb/sqldbtest"
|
||||
)
|
||||
|
||||
func TestUpdatePersistsAndSurvivesReopen(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "state.json")
|
||||
st, err := Open(p)
|
||||
var testKey, _ = secret.NewKeyring(bytes.Repeat([]byte{7}, 32))
|
||||
|
||||
// open opens a store on url, closing it when the test ends.
|
||||
func open(t *testing.T, url string) (*Store, *sqldb.DB) {
|
||||
t.Helper()
|
||||
db, err := sqldb.Open(context.Background(), url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, err := Open(context.Background(), db, testKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close(); db.Close() })
|
||||
return st, db
|
||||
}
|
||||
|
||||
func TestUpdatePersistsAndSurvivesReopen(t *testing.T) {
|
||||
url := sqldbtest.URL(t)
|
||||
st, _ := open(t, url)
|
||||
if err := st.Update(func(s *State) error {
|
||||
s.Users["u1"] = &User{ID: "u1", Username: "alice"}
|
||||
s.Sessions["tok"] = &Session{Token: "tok", UserID: "u1"}
|
||||
s.Memberships = append(s.Memberships, Membership{UserID: "u1", GroupID: "g", Role: RoleAdmin})
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, err := Open(p)
|
||||
again, _ := open(t, url)
|
||||
again.Read(func(s *State) {
|
||||
if s.Users["u1"] == nil || s.Users["u1"].Username != "alice" || s.Sessions["tok"] == nil ||
|
||||
len(s.Memberships) != 1 || s.Memberships[0].Role != RoleAdmin {
|
||||
t.Fatalf("reopened state = %+v", s)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRowsAreSealedAndNamedOpaquely(t *testing.T) {
|
||||
st, db := open(t, sqldbtest.URL(t))
|
||||
if err := st.Update(func(s *State) error {
|
||||
s.Users["u1"] = &User{ID: "u1", Username: "alice", Email: "alice@example.com"}
|
||||
s.Sessions["secret-session-token"] = &Session{Token: "secret-session-token", UserID: "u1"}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows, err := db.Query(`SELECT id, body FROM keeporg_records`)
|
||||
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)
|
||||
defer rows.Close()
|
||||
n := 0
|
||||
for rows.Next() {
|
||||
var id string
|
||||
var body []byte
|
||||
if err := rows.Scan(&id, &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, plain := range []string{"alice", "example.com", "secret-session-token", "u1", "user"} {
|
||||
if bytes.Contains([]byte(id), []byte(plain)) || bytes.Contains(body, []byte(plain)) {
|
||||
t.Fatalf("row %s carries %q in the clear", id, plain)
|
||||
}
|
||||
}
|
||||
n++
|
||||
}
|
||||
if fi, _ := os.Stat(p); fi.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("state file mode = %v", fi.Mode().Perm())
|
||||
if n != 2 {
|
||||
t.Fatalf("rows = %d, want 2", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyChangedRowsAreWritten(t *testing.T) {
|
||||
st, db := open(t, sqldbtest.URL(t))
|
||||
_ = st.Update(func(s *State) error {
|
||||
s.Users["u1"] = &User{ID: "u1", Username: "alice"}
|
||||
s.Users["u2"] = &User{ID: "u2", Username: "bob"}
|
||||
return nil
|
||||
})
|
||||
body := func() map[string]string {
|
||||
out := map[string]string{}
|
||||
rows, _ := db.Query(`SELECT id, body FROM keeporg_records`)
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id string
|
||||
var b []byte
|
||||
rows.Scan(&id, &b)
|
||||
out[id] = string(b)
|
||||
}
|
||||
return out
|
||||
}
|
||||
before := body()
|
||||
gen, _ := db.Generation(context.Background(), db)
|
||||
_ = st.Update(func(s *State) error { s.Users["u2"].Email = "bob@example.com"; return nil })
|
||||
after := body()
|
||||
changed := 0
|
||||
for id := range before {
|
||||
if before[id] != after[id] {
|
||||
changed++
|
||||
}
|
||||
}
|
||||
if changed != 1 {
|
||||
t.Fatalf("%d rows rewritten for one changed user", changed)
|
||||
}
|
||||
// A no-op update writes nothing and does not move the generation.
|
||||
_ = st.Update(func(s *State) error { return nil })
|
||||
if g, _ := db.Generation(context.Background(), db); g != gen+1 {
|
||||
t.Fatalf("generation %d after one write from %d", g, gen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailedUpdateLeavesStateUntouched(t *testing.T) {
|
||||
st, err := Open(filepath.Join(t.TempDir(), "state.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, _ := open(t, sqldbtest.URL(t))
|
||||
boom := errors.New("boom")
|
||||
if err := st.Update(func(s *State) error {
|
||||
s.Users["u1"] = &User{ID: "u1"}
|
||||
@@ -53,8 +137,52 @@ func TestFailedUpdateLeavesStateUntouched(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Two processes on one database: a write by one is seen by the other, and a
|
||||
// write based on stale state is re-applied on top of the newer one instead
|
||||
// of overwriting it.
|
||||
func TestTwoStoresShareOneDatabase(t *testing.T) {
|
||||
url := sqldbtest.URL(t)
|
||||
a, _ := open(t, url)
|
||||
b, _ := open(t, url)
|
||||
|
||||
_ = a.Update(func(s *State) error { s.Users["u1"] = &User{ID: "u1", Username: "alice"}; return nil })
|
||||
if err := b.Update(func(s *State) error { s.Users["u2"] = &User{ID: "u2", Username: "bob"}; return nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.Read(func(s *State) {
|
||||
if s.Users["u1"] == nil || s.Users["u2"] == nil {
|
||||
t.Fatalf("b lost a's write: %v", s.Users)
|
||||
}
|
||||
})
|
||||
// a learns of b's write without writing itself.
|
||||
deadline := time.Now().Add(3 * watchInterval)
|
||||
for {
|
||||
var ok bool
|
||||
a.Read(func(s *State) { ok = s.Users["u2"] != nil })
|
||||
if ok {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("a never saw b's write")
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrongKeyIsRefused(t *testing.T) {
|
||||
url := sqldbtest.URL(t)
|
||||
_, db := open(t, url)
|
||||
if err := db.CheckKey(context.Background(), testKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other, _ := secret.NewKeyring(bytes.Repeat([]byte{8}, 32))
|
||||
if err := db.CheckKey(context.Background(), other); !errors.Is(err, sqldb.ErrWrongKey) {
|
||||
t.Fatalf("a different key was accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGCDropsExpired(t *testing.T) {
|
||||
st, _ := Open(filepath.Join(t.TempDir(), "state.json"))
|
||||
st, _ := open(t, sqldbtest.URL(t))
|
||||
now := time.Now()
|
||||
_ = st.Update(func(s *State) error {
|
||||
s.Sessions["old"] = &Session{Token: "old", ExpiresAt: now.Add(-time.Hour)}
|
||||
|
||||
+12
-4
@@ -52,21 +52,29 @@ func Code(secret string, t time.Time) (string, error) {
|
||||
// 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 {
|
||||
_, ok := Match(secret, given, t)
|
||||
return ok
|
||||
}
|
||||
|
||||
// Match is Check that also says which time step the code belongs to. A
|
||||
// caller that remembers the last step it accepted, and refuses that one and
|
||||
// any before it, makes each code good for one use only.
|
||||
func Match(secret, given string, t time.Time) (int64, bool) {
|
||||
key, err := decode(secret)
|
||||
if err != nil {
|
||||
return false
|
||||
return 0, false
|
||||
}
|
||||
given = strings.TrimSpace(given)
|
||||
if len(given) != digits {
|
||||
return false
|
||||
return 0, 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 int64(ctr) + int64(d), true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func decode(secret string) ([]byte, error) {
|
||||
|
||||
Reference in New Issue
Block a user