feat: add Android app and end-to-end encrypted library sync

Introduce the Kotlin/Compose Android companion app and extend the TMDB
proxy into a combo server that synchronizes the whole library as an
encrypted snapshot, with all three components (Go server, Android,
desktop) sharing one zero-knowledge crypto envelope and JSON schema.

- server: add PostgreSQL-backed /sync/library (GET/PUT) with optimistic
  concurrency (revision + 409 on conflict); sync stays disabled unless
  DATABASE_URL is set. Add docker-compose with Postgres.
- desktop: add libsodium CryptoEnvelope, LibrarySerializer and SyncClient;
  storage-mode and passphrase settings; a "Sync now" toolbar action with
  conflict resolution.
- android: Room-backed library with local/cloud storage modes, encrypted
  sync client, and settings UI. The proxy server (URL + token) can be
  configured as a TMDB proxy even in local-only mode.

Crypto is identical across platforms: Argon2id (libsodium crypto_pwhash,
INTERACTIVE) + XChaCha20-Poly1305 in a versioned "UMTS" envelope, so a
snapshot encrypted on one client decrypts on the other.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tronax 2026-06-21 00:35:54 +02:00
parent 83a26ef0cf
commit de353f0218
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
65 changed files with 4178 additions and 0 deletions

View file

@ -31,3 +31,13 @@ SESSION_SECRET=change-me-to-a-long-random-string
# How long an issued desktop-app token stays valid (in days).
TOKEN_TTL_DAYS=90
# --- Library-Sync (optional) -------------------------------------------
# PostgreSQL-DSN für den verschlüsselten Bibliotheks-Sync. Leer lassen, um
# den Sync zu deaktivieren (dann läuft der Server als reiner TMDB-Proxy).
# Der Server speichert nur Chiffretext (Ende-zu-Ende, Zero-Knowledge).
# Bei Nutzung von docker-compose.yml wird dies automatisch gesetzt.
# DATABASE_URL=postgres://umt:change-me@db:5432/umt?sslmode=disable
# Passwort des Postgres-Containers (nur für docker-compose.yml relevant).
POSTGRES_PASSWORD=change-me

View file

@ -22,6 +22,7 @@ type Config struct {
SessionKey []byte // HMAC secret for signing access tokens / state cookies
AllowGroups []string // if set, the user must be in one of these Authentik groups
TokenTTL time.Duration // lifetime of issued desktop-app tokens
DatabaseURL string // PostgreSQL DSN for library sync; empty disables sync
}
func getenv(key, def string) string {
@ -41,6 +42,7 @@ func loadConfig() (*Config, error) {
ClientID: getenv("OIDC_CLIENT_ID", ""),
ClientSecret: getenv("OIDC_CLIENT_SECRET", ""),
RedirectURL: getenv("OIDC_REDIRECT_URL", ""),
DatabaseURL: getenv("DATABASE_URL", ""),
}
if c.RedirectURL == "" {

44
server/docker-compose.yml Normal file
View file

@ -0,0 +1,44 @@
# Kombi-Server: TMDB-Proxy + verschlüsselter Bibliotheks-Sync.
# Auf Unraid: dieses Compose-File über "Compose Manager" einbinden oder die
# beiden Container (db + app) einzeln anlegen. Werte aus .env beziehen.
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: umt
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me}
POSTGRES_DB: umt
volumes:
- umt_pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U umt -d umt"]
interval: 10s
timeout: 5s
retries: 5
app:
build: .
image: umt-combo-server:latest
restart: unless-stopped
depends_on:
db:
condition: service_healthy
ports:
- "8080:8080"
environment:
LISTEN_ADDR: ":8080"
PUBLIC_URL: ${PUBLIC_URL}
TMDB_API_KEY: ${TMDB_API_KEY}
OIDC_ISSUER: ${OIDC_ISSUER}
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID}
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET}
OIDC_REDIRECT_URL: ${OIDC_REDIRECT_URL}
SESSION_SECRET: ${SESSION_SECRET}
ALLOWED_GROUPS: ${ALLOWED_GROUPS:-}
TOKEN_TTL_DAYS: ${TOKEN_TTL_DAYS:-90}
# Library-Sync: leer lassen, um den Sync zu deaktivieren.
DATABASE_URL: postgres://umt:${POSTGRES_PASSWORD:-change-me}@db:5432/umt?sslmode=disable
volumes:
umt_pgdata:

View file

@ -4,10 +4,16 @@ go 1.22
require (
github.com/coreos/go-oidc/v3 v3.11.0
github.com/jackc/pgx/v5 v5.6.0
golang.org/x/oauth2 v0.21.0
)
require (
github.com/go-jose/go-jose/v4 v4.0.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
golang.org/x/crypto v0.25.0 // indirect
golang.org/x/sync v0.7.0 // indirect
golang.org/x/text v0.16.0 // indirect
)

View file

@ -1,18 +1,36 @@
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk=
github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -27,6 +27,7 @@ type server struct {
verifier *oidc.IDTokenVerifier
oauth oauth2.Config
client *http.Client
store *store // nil when DATABASE_URL is unset (sync disabled)
}
func main() {
@ -55,6 +56,18 @@ func main() {
client: &http.Client{Timeout: 15 * time.Second},
}
if cfg.DatabaseURL != "" {
st, err := openStore(cfg.DatabaseURL)
if err != nil {
log.Fatalf("database connection failed: %v", err)
}
defer st.Close()
s.store = st
log.Printf("library sync enabled (PostgreSQL)")
} else {
log.Printf("library sync disabled (DATABASE_URL unset)")
}
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
@ -63,6 +76,7 @@ func main() {
mux.HandleFunc("/login", s.handleLogin)
mux.HandleFunc("/callback", s.handleCallback)
mux.HandleFunc("/3/", s.handleProxy)
mux.HandleFunc("/sync/library", s.handleSyncLibrary)
log.Printf("umt-tmdb-proxy listening on %s (public %s)", cfg.ListenAddr, cfg.PublicURL)
srv := &http.Server{

136
server/store.go Normal file
View file

@ -0,0 +1,136 @@
package main
import (
"context"
"database/sql"
"errors"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
)
// errRevisionConflict is returned by putBlob when the caller's baseRevision no
// longer matches the revision currently stored for that user. The client must
// re-pull and resolve before pushing again (optimistic concurrency).
var errRevisionConflict = errors.New("revision conflict")
// libraryBlob is the encrypted, opaque library snapshot stored for one user.
// The server never sees the plaintext or the passphrase — payload is ciphertext.
type libraryBlob struct {
Revision int64
UpdatedAt time.Time
Payload []byte
}
// store wraps the PostgreSQL connection used for the zero-knowledge library
// sync. It is optional: when DATABASE_URL is unset the server runs as a plain
// TMDB proxy and store is nil.
type store struct {
db *sql.DB
}
// openStore connects to PostgreSQL and ensures the schema exists.
func openStore(dsn string) (*store, error) {
db, err := sql.Open("pgx", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(10)
db.SetConnMaxIdleTime(5 * time.Minute)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
db.Close()
return nil, err
}
if err := migrate(ctx, db); err != nil {
db.Close()
return nil, err
}
return &store{db: db}, nil
}
func (s *store) Close() error { return s.db.Close() }
func migrate(ctx context.Context, db *sql.DB) error {
_, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS library_blobs (
user_subject TEXT PRIMARY KEY,
user_email TEXT,
revision BIGINT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
payload BYTEA NOT NULL
);`)
return err
}
// getBlob loads the stored snapshot for a user. It returns (nil, nil) when the
// user has never pushed a library yet.
func (s *store) getBlob(ctx context.Context, subject string) (*libraryBlob, error) {
row := s.db.QueryRowContext(ctx,
`SELECT revision, updated_at, payload FROM library_blobs WHERE user_subject = $1`,
subject)
var b libraryBlob
switch err := row.Scan(&b.Revision, &b.UpdatedAt, &b.Payload); err {
case nil:
return &b, nil
case sql.ErrNoRows:
return nil, nil
default:
return nil, err
}
}
// putBlob writes a new snapshot using optimistic concurrency: the write only
// succeeds when the stored revision equals baseRevision (or the row is absent
// and baseRevision is 0). On success the revision is incremented and returned.
// On mismatch it returns the current revision and errRevisionConflict.
func (s *store) putBlob(ctx context.Context, subject, email string, baseRevision int64, payload []byte) (int64, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer tx.Rollback()
var current int64
var exists bool
row := tx.QueryRowContext(ctx,
`SELECT revision FROM library_blobs WHERE user_subject = $1 FOR UPDATE`, subject)
switch err := row.Scan(&current); err {
case nil:
exists = true
case sql.ErrNoRows:
exists = false
default:
return 0, err
}
if exists {
if current != baseRevision {
return current, errRevisionConflict
}
} else if baseRevision != 0 {
// Client thinks it is updating an existing snapshot, but none exists.
return 0, errRevisionConflict
}
next := baseRevision + 1
now := time.Now().UTC()
if exists {
_, err = tx.ExecContext(ctx,
`UPDATE library_blobs SET user_email = $2, revision = $3, updated_at = $4, payload = $5 WHERE user_subject = $1`,
subject, email, next, now, payload)
} else {
_, err = tx.ExecContext(ctx,
`INSERT INTO library_blobs (user_subject, user_email, revision, updated_at, payload) VALUES ($1, $2, $3, $4, $5)`,
subject, email, next, now, payload)
}
if err != nil {
return 0, err
}
if err := tx.Commit(); err != nil {
return 0, err
}
return next, nil
}

123
server/sync.go Normal file
View file

@ -0,0 +1,123 @@
package main
import (
"encoding/base64"
"encoding/json"
"net/http"
"strings"
"time"
)
// Maximum accepted snapshot size (encrypted). Generous for a personal library.
const maxSyncPayload = 16 << 20 // 16 MiB
type syncGetResponse struct {
Revision int64 `json:"revision"`
UpdatedAt string `json:"updatedAt"`
Payload string `json:"payload"` // base64 ciphertext
}
type syncPutRequest struct {
BaseRevision int64 `json:"baseRevision"`
Payload string `json:"payload"` // base64 ciphertext
}
type syncPutResponse struct {
Revision int64 `json:"revision"`
}
type syncConflictResponse struct {
Error string `json:"error"`
Revision int64 `json:"revision"`
}
// authSubject validates the Bearer token and returns the user's subject/email.
func (s *server) authSubject(r *http.Request) (sub, email string, ok bool) {
bearer := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer"))
claims, err := verifyToken(s.cfg.SessionKey, bearer)
if err != nil {
return "", "", false
}
return claims.Subject, claims.Email, true
}
// handleSyncLibrary dispatches GET/PUT for the encrypted library snapshot.
func (s *server) handleSyncLibrary(w http.ResponseWriter, r *http.Request) {
if s.store == nil {
http.Error(w, "sync is not configured on this server", http.StatusNotImplemented)
return
}
sub, email, ok := s.authSubject(r)
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
switch r.Method {
case http.MethodGet:
s.handleSyncGet(w, r, sub)
case http.MethodPut:
s.handleSyncPut(w, r, sub, email)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *server) handleSyncGet(w http.ResponseWriter, r *http.Request, sub string) {
blob, err := s.store.getBlob(r.Context(), sub)
if err != nil {
http.Error(w, "storage error", http.StatusInternalServerError)
return
}
if blob == nil {
http.Error(w, "no snapshot yet", http.StatusNotFound)
return
}
writeJSON(w, http.StatusOK, syncGetResponse{
Revision: blob.Revision,
UpdatedAt: blob.UpdatedAt.UTC().Format(time.RFC3339),
Payload: base64.StdEncoding.EncodeToString(blob.Payload),
})
}
func (s *server) handleSyncPut(w http.ResponseWriter, r *http.Request, sub, email string) {
var req syncPutRequest
body := http.MaxBytesReader(w, r.Body, maxSyncPayload+(1<<16))
if err := json.NewDecoder(body).Decode(&req); err != nil {
http.Error(w, "bad request body", http.StatusBadRequest)
return
}
payload, err := base64.StdEncoding.DecodeString(req.Payload)
if err != nil {
http.Error(w, "payload is not valid base64", http.StatusBadRequest)
return
}
if len(payload) == 0 {
http.Error(w, "empty payload", http.StatusBadRequest)
return
}
if len(payload) > maxSyncPayload {
http.Error(w, "payload too large", http.StatusRequestEntityTooLarge)
return
}
next, err := s.store.putBlob(r.Context(), sub, email, req.BaseRevision, payload)
if err == errRevisionConflict {
writeJSON(w, http.StatusConflict, syncConflictResponse{
Error: "revision conflict",
Revision: next,
})
return
}
if err != nil {
http.Error(w, "storage error", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, syncPutResponse{Revision: next})
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}