arnaucube
arnaucube/jellyfin-toolsPublic Self-hosted repository
jellyfin-tools/jellyfin-year-review/internal/auth/offline.go
jellyfin-year-review/internal/auth/offline.goRaw
  1package auth
  2
  3import (
  4	"bytes"
  5	"context"
  6	"crypto/pbkdf2"
  7	"crypto/sha1"
  8	"crypto/sha256"
  9	"crypto/sha512"
 10	"crypto/subtle"
 11	"encoding/hex"
 12	"encoding/json"
 13	"fmt"
 14	"io"
 15	"os"
 16	"path/filepath"
 17	"strconv"
 18	"strings"
 19	"time"
 20	"unicode/utf8"
 21
 22	"reelreview/internal/model"
 23	"reelreview/internal/source"
 24)
 25
 26const maxAuthBytes = 1 << 20
 27
 28type Credential struct {
 29	ID           string `json:"id"`
 30	Username     string `json:"username"`
 31	PasswordHash string `json:"passwordHash"`
 32	Disabled     bool   `json:"disabled"`
 33}
 34
 35// UnmarshalJSON requires explicit account status rather than treating a missing
 36// disabled flag as an enabled account in a malformed private export.
 37func (c *Credential) UnmarshalJSON(b []byte) error {
 38	var fields struct {
 39		ID           *string `json:"id"`
 40		Username     *string `json:"username"`
 41		PasswordHash *string `json:"passwordHash"`
 42		Disabled     *bool   `json:"disabled"`
 43	}
 44	d := json.NewDecoder(bytes.NewReader(b))
 45	d.DisallowUnknownFields()
 46	if d.Decode(&fields) != nil || fields.ID == nil || fields.Username == nil || fields.PasswordHash == nil || fields.Disabled == nil {
 47		return source.ErrUnavailable
 48	}
 49	*c = Credential{ID: *fields.ID, Username: *fields.Username, PasswordHash: *fields.PasswordHash, Disabled: *fields.Disabled}
 50	return nil
 51}
 52
 53type CredentialFile struct {
 54	Version       int          `json:"schemaVersion"`
 55	SourceVersion string       `json:"sourceVersion"`
 56	ExportedAt    time.Time    `json:"exportedAt"`
 57	Users         []Credential `json:"users"`
 58}
 59type verifier struct {
 60	algorithm  string
 61	iterations int
 62	salt, hash []byte
 63}
 64type offlineUser struct {
 65	credential Credential
 66	verifier   verifier
 67	revision   string
 68}
 69type offlineUsers struct {
 70	byName map[string]offlineUser
 71	byID   map[string]offlineUser
 72}
 73
 74// Offline rereads a private, atomically replaced file for every authentication
 75// check. Invalid or missing files fail closed without cached credential fallback.
 76type Offline struct {
 77	path string
 78	work chan struct{}
 79}
 80
 81func NewOffline(path string) (*Offline, error) {
 82	absolute, e := filepath.Abs(path)
 83	if e != nil || path == "" {
 84		return nil, fmt.Errorf("offline mode requires a private credential file")
 85	}
 86	p := &Offline{path: absolute, work: make(chan struct{}, 2)}
 87	if _, e = p.load(); e != nil {
 88		return nil, fmt.Errorf("cannot load offline credentials: check file format, path and private permissions")
 89	}
 90	return p, nil
 91}
 92func (p *Offline) Mode() string { return "offline" }
 93func (p *Offline) Path() string { return p.path }
 94
 95// loginKey preserves exact spelling for non-ASCII names to avoid differing .NET
 96// and Go Unicode normalization versions. ASCII account names ignore case.
 97func loginKey(name string) string {
 98	for _, r := range name {
 99		if r > 127 {
100			return "unicode:" + name
101		}
102	}
103	return "ascii:" + strings.ToLower(name)
104}
105
106// parseVerifier accepts Jellyfin hex PBKDF2 strings within bounded work parameters.
107func parseVerifier(encoded string) (verifier, error) {
108	var v verifier
109	fields := strings.Split(encoded, "$")
110	if len(fields) != 5 || fields[0] != "" || !strings.HasPrefix(fields[2], "iterations=") {
111		return v, source.ErrUnavailable
112	}
113	v.algorithm = fields[1]
114	size := 64
115	if v.algorithm == "PBKDF2" {
116		size = 32
117	} else if v.algorithm != "PBKDF2-SHA512" {
118		return v, source.ErrUnavailable
119	}
120	var e error
121	v.iterations, e = strconv.Atoi(strings.TrimPrefix(fields[2], "iterations="))
122	if e != nil || v.iterations < 1 || v.iterations > 1000000 {
123		return v, source.ErrUnavailable
124	}
125	v.salt, e = hex.DecodeString(fields[3])
126	if e != nil || len(v.salt) < 8 || len(v.salt) > 128 {
127		return v, source.ErrUnavailable
128	}
129	v.hash, e = hex.DecodeString(fields[4])
130	if e != nil || len(v.hash) != size {
131		return v, source.ErrUnavailable
132	}
133	return v, nil
134}
135func revision(c Credential) string {
136	b, _ := json.Marshal(c)
137	h := sha256.Sum256(b)
138	return hex.EncodeToString(h[:])
139}
140
141// load validates the complete current file before any account is trusted.
142func (p *Offline) load() (offlineUsers, error) {
143	users := offlineUsers{byName: map[string]offlineUser{}, byID: map[string]offlineUser{}}
144	// Refuse symlinks and group/world-readable files, including operator mistakes.
145	st, e := os.Lstat(p.path)
146	if e != nil || !st.Mode().IsRegular() || st.Mode().Perm()&0077 != 0 || st.Size() > maxAuthBytes {
147		return users, source.ErrUnavailable
148	}
149	f, e := os.Open(p.path)
150	if e != nil {
151		return users, source.ErrUnavailable
152	}
153	defer f.Close()
154	opened, e := f.Stat()
155	if e != nil || !os.SameFile(st, opened) || !opened.Mode().IsRegular() || opened.Mode().Perm()&0077 != 0 {
156		return users, source.ErrUnavailable
157	}
158	b, e := io.ReadAll(io.LimitReader(f, maxAuthBytes+1))
159	if e != nil || len(b) > maxAuthBytes {
160		return users, source.ErrUnavailable
161	}
162	var file CredentialFile
163	d := json.NewDecoder(bytes.NewReader(b))
164	d.DisallowUnknownFields()
165	if d.Decode(&file) != nil || d.Decode(new(any)) != io.EOF || file.Version != 1 || file.SourceVersion != "12.2" || file.ExportedAt.IsZero() || file.Users == nil || len(file.Users) > 1000 {
166		return users, source.ErrUnavailable
167	}
168	for _, c := range file.Users {
169		id, e := model.ID(c.ID)
170		if e != nil || id != c.ID || c.Username == "" || len(c.Username) > 256 || !utf8.ValidString(c.Username) || strings.ContainsAny(c.Username, "\x00\r\n") {
171			return users, source.ErrUnavailable
172		}
173		key := loginKey(c.Username)
174		if _, ok := users.byName[key]; ok {
175			return users, source.ErrUnavailable
176		}
177		if _, ok := users.byID[id]; ok {
178			return users, source.ErrUnavailable
179		}
180		var v verifier
181		if !c.Disabled {
182			v, e = parseVerifier(c.PasswordHash)
183			if e != nil {
184				return users, source.ErrUnavailable
185			}
186		} else if c.PasswordHash != "" {
187			return users, source.ErrUnavailable
188		}
189		u := offlineUser{credential: c, verifier: v, revision: revision(c)}
190		users.byName[key] = u
191		users.byID[id] = u
192	}
193	return users, nil
194}
195
196// Login checks the imported verifier without creating an upstream session.
197func (p *Offline) Login(ctx context.Context, name, password, device string) (source.Account, string, error) {
198	select {
199	case p.work <- struct{}{}:
200		defer func() { <-p.work }()
201	case <-ctx.Done():
202		return source.Account{}, "", source.ErrUnavailable
203	}
204	users, e := p.load()
205	if e != nil {
206		return source.Account{}, "", e
207	}
208	u, ok := users.byName[loginKey(name)]
209	// Unknown and disabled accounts perform a real default-cost derivation too.
210	v := u.verifier
211	if !ok || u.credential.Disabled {
212		v = verifier{algorithm: "PBKDF2-SHA512", iterations: 210000, salt: make([]byte, 16), hash: make([]byte, 64)}
213	}
214	var actual []byte
215	if v.algorithm == "PBKDF2" {
216		actual, e = pbkdf2.Key(sha1.New, password, v.salt, v.iterations, 32)
217	} else {
218		actual, e = pbkdf2.Key(sha512.New, password, v.salt, v.iterations, 64)
219	}
220	if e != nil || ctx.Err() != nil {
221		return source.Account{}, "", source.ErrUnavailable
222	}
223	matches := subtle.ConstantTimeCompare(actual, v.hash) == 1
224	if !ok || u.credential.Disabled || !matches {
225		return source.Account{}, "", source.ErrDenied
226	}
227	// Recheck after the expensive derivation so a concurrent replacement can revoke it.
228	session := Session{Account: source.Account{ID: u.credential.ID, Name: u.credential.Username}, Token: u.revision}
229	account, e := p.Validate(ctx, session)
230	if e != nil {
231		return source.Account{}, "", e
232	}
233	return account, u.revision, nil
234}
235
236// Validate revokes changed or removed accounts on their next private request.
237func (p *Offline) Validate(ctx context.Context, s Session) (source.Account, error) {
238	if ctx.Err() != nil {
239		return source.Account{}, source.ErrUnavailable
240	}
241	users, e := p.load()
242	if e != nil {
243		return source.Account{}, e
244	}
245	u, ok := users.byID[s.Account.ID]
246	if !ok || u.credential.Disabled || subtle.ConstantTimeCompare([]byte(s.Token), []byte(u.revision)) != 1 {
247		return source.Account{}, source.ErrDenied
248	}
249	return source.Account{ID: u.credential.ID, Name: u.credential.Username}, nil
250}
251func (p *Offline) Logout(context.Context, Session) error { return nil }