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 }