1package userbase
2
3import (
4 "bytes"
5 "crypto/sha256"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "slices"
10 "strings"
11 "time"
12 "unicode/utf8"
13
14 "tug/internal/platform/sshkey/openssh"
15 "tug/internal/storage"
16)
17
18const ExportVersion = 4
19
20var (
21 ErrUnknownVersion = errors.New("userbase: unknown export_version")
22 ErrNotCanonical = errors.New("userbase: export document is not canonical")
23)
24
25type Document struct {
26 Version int
27 Snapshot storage.UserbaseSnapshot
28 ExportedAt time.Time
29 DexKeys json.RawMessage
30}
31
32type document struct {
33 ExportVersion int `json:"export_version"`
34 ExportedAt int64 `json:"exported_at"`
35 NextUIDNumber int64 `json:"next_uid_number"`
36 NextGIDNumber int64 `json:"next_gid_number"`
37 Users []user `json:"users"`
38 Groups []group `json:"groups"`
39 Memberships []membership `json:"memberships"`
40 GoogleLinks []googleLink `json:"google_links"`
41 Invitations []invitation `json:"invitations"`
42 InvitationGroups []invitationGroup `json:"invitation_groups"`
43 PendingConfirmations []pendingConfirmation `json:"pending_confirmations"`
44 SSHKeys []sshKey `json:"ssh_keys"`
45 DexKeys json.RawMessage `json:"dex_keys,omitempty"`
46}
47
48type user struct {
49 ID string `json:"id"`
50 Username string `json:"username"`
51 DisplayName string `json:"display_name"`
52 PasswordHash []byte `json:"password_hash"`
53 Disabled bool `json:"disabled"`
54 UIDNumber int64 `json:"uid_number"`
55 CreatedAt int64 `json:"created_at"`
56}
57
58type group struct {
59 ID string `json:"id"`
60 Name string `json:"name"`
61 Description string `json:"description"`
62 GIDNumber int64 `json:"gid_number"`
63 CreatedAt int64 `json:"created_at"`
64}
65
66type membership struct {
67 UserID string `json:"user_id"`
68 GroupID string `json:"group_id"`
69}
70
71type googleLink struct {
72 UserID string `json:"user_id"`
73 GoogleSub string `json:"google_sub"`
74 LinkedAt int64 `json:"linked_at"`
75}
76
77type invitation struct {
78 ID string `json:"id"`
79 TokenHash []byte `json:"token_hash"`
80 CreatedBy *string `json:"created_by"`
81 CreatedAt int64 `json:"created_at"`
82 ExpiresAt int64 `json:"expires_at"`
83 UsedBy *string `json:"used_by"`
84 UsedAt *int64 `json:"used_at"`
85 RevokedAt *int64 `json:"revoked_at"`
86 Label string `json:"label"`
87 BatchID *string `json:"batch_id"`
88}
89
90type invitationGroup struct {
91 InvitationID string `json:"invitation_id"`
92 GroupID *string `json:"group_id"`
93 GroupName string `json:"group_name"`
94}
95
96type pendingConfirmation struct {
97 UserID string `json:"user_id"`
98 CodeHash []byte `json:"code_hash"`
99 IssuedAt int64 `json:"issued_at"`
100 ExpiresAt int64 `json:"expires_at"`
101 WrongCodes int64 `json:"wrong_codes"`
102}
103
104type sshKey struct {
105 ID string `json:"id"`
106 UserID string `json:"user_id"`
107 Fingerprint string `json:"fingerprint"`
108 KeyType string `json:"key_type"`
109 KeyData []byte `json:"key_data"`
110 Label string `json:"label"`
111 AddedAt int64 `json:"added_at"`
112}
113
114func Encode(snapshot storage.UserbaseSnapshot, exportedAt time.Time, dexKeys json.RawMessage) ([]byte, error) {
115 doc := fromSnapshot(snapshot, exportedAt.UTC().UnixMicro(), dexKeys)
116 if err := checkStrings(doc); err != nil {
117 return nil, err
118 }
119 if len(doc.DexKeys) > 0 && doc.DexKeys[0] != '{' {
120 return nil, fmt.Errorf("userbase: encode: dex_keys is not a JSON object")
121 }
122 return marshal(doc)
123}
124
125func ContentDigest(snapshot storage.UserbaseSnapshot, dexKeys json.RawMessage) ([32]byte, error) {
126 data, err := Encode(snapshot, time.UnixMicro(0), dexKeys)
127 if err != nil {
128 return [32]byte{}, err
129 }
130 return sha256.Sum256(data), nil
131}
132
133func Decode(data []byte) (Document, error) {
134 var head struct {
135 ExportVersion int `json:"export_version"`
136 }
137 if err := json.Unmarshal(data, &head); err != nil {
138 return Document{}, fmt.Errorf("userbase: decode: %w", err)
139 }
140 switch head.ExportVersion {
141 case 1:
142 return decodeV1(data)
143 case 2:
144 return decodeV2(data)
145 case 3:
146 return decodeV3(data)
147 case ExportVersion:
148 default:
149 return Document{}, fmt.Errorf("%w: export_version %d, this binary reads 1 to %d", ErrUnknownVersion, head.ExportVersion, ExportVersion)
150 }
151 decoder := json.NewDecoder(bytes.NewReader(data))
152 decoder.DisallowUnknownFields()
153 var doc document
154 if err := decoder.Decode(&doc); err != nil {
155 return Document{}, fmt.Errorf("userbase: decode: %w", err)
156 }
157 out := Document{Version: ExportVersion, Snapshot: toSnapshot(doc), ExportedAt: time.UnixMicro(doc.ExportedAt).UTC(), DexKeys: doc.DexKeys}
158 again, err := Encode(out.Snapshot, out.ExportedAt, out.DexKeys)
159 if err != nil {
160 return Document{}, err
161 }
162 if !bytes.Equal(again, data) {
163 return Document{}, ErrNotCanonical
164 }
165 if err := checkSSHKeys(doc.SSHKeys); err != nil {
166 return Document{}, err
167 }
168 return out, nil
169}
170
171func marshal(doc any) ([]byte, error) {
172 var buf bytes.Buffer
173 encoder := json.NewEncoder(&buf)
174 encoder.SetEscapeHTML(false)
175 if err := encoder.Encode(doc); err != nil {
176 return nil, fmt.Errorf("userbase: encode: %w", err)
177 }
178 return buf.Bytes(), nil
179}
180
181func fromSnapshot(s storage.UserbaseSnapshot, exportedAt int64, dexKeys json.RawMessage) document {
182 doc := document{
183 ExportVersion: ExportVersion,
184 ExportedAt: exportedAt,
185 NextUIDNumber: s.NextUIDNumber,
186 NextGIDNumber: s.NextGIDNumber,
187 Users: make([]user, 0, len(s.Users)),
188 Groups: make([]group, 0, len(s.Groups)),
189 Memberships: make([]membership, 0, len(s.Memberships)),
190 GoogleLinks: make([]googleLink, 0, len(s.GoogleLinks)),
191 Invitations: make([]invitation, 0, len(s.Invitations)),
192 InvitationGroups: make([]invitationGroup, 0, len(s.InvitationGroups)),
193 PendingConfirmations: make([]pendingConfirmation, 0, len(s.PendingConfirmations)),
194 SSHKeys: make([]sshKey, 0, len(s.SSHKeys)),
195 DexKeys: dexKeys,
196 }
197 for _, u := range s.Users {
198 doc.Users = append(doc.Users, user(u))
199 }
200 for _, g := range s.Groups {
201 doc.Groups = append(doc.Groups, group(g))
202 }
203 for _, m := range s.Memberships {
204 doc.Memberships = append(doc.Memberships, membership(m))
205 }
206 for _, l := range s.GoogleLinks {
207 doc.GoogleLinks = append(doc.GoogleLinks, googleLink(l))
208 }
209 for _, i := range s.Invitations {
210 doc.Invitations = append(doc.Invitations, invitation(i))
211 }
212 for _, g := range s.InvitationGroups {
213 doc.InvitationGroups = append(doc.InvitationGroups, invitationGroup(g))
214 }
215 for _, p := range s.PendingConfirmations {
216 doc.PendingConfirmations = append(doc.PendingConfirmations, pendingConfirmation(p))
217 }
218 for _, k := range s.SSHKeys {
219 doc.SSHKeys = append(doc.SSHKeys, sshKey(k))
220 }
221 doc.Users = sortedRows(doc.Users, func(u user) (string, string) { return u.ID, "" })
222 doc.Groups = sortedRows(doc.Groups, groupKey)
223 doc.Memberships = sortedRows(doc.Memberships, membershipKey)
224 doc.GoogleLinks = sortedRows(doc.GoogleLinks, func(l googleLink) (string, string) { return l.UserID, "" })
225 doc.Invitations = sortedRows(doc.Invitations, func(i invitation) (string, string) { return i.ID, "" })
226 doc.InvitationGroups = sortedRows(doc.InvitationGroups, invitationGroupKey)
227 doc.PendingConfirmations = sortedRows(doc.PendingConfirmations, pendingKey)
228 doc.SSHKeys = sortedRows(doc.SSHKeys, sshKeyKey)
229 return doc
230}
231
232func sortedRows[T any](rows []T, key func(T) (string, string)) []T {
233 out := append(make([]T, 0, len(rows)), rows...)
234 slices.SortFunc(out, func(a, b T) int {
235 a1, a2 := key(a)
236 b1, b2 := key(b)
237 if c := strings.Compare(a1, b1); c != 0 {
238 return c
239 }
240 return strings.Compare(a2, b2)
241 })
242 return out
243}
244
245func groupKey(g group) (string, string) { return g.ID, "" }
246func membershipKey(m membership) (string, string) { return m.UserID, m.GroupID }
247func invitationGroupKey(g invitationGroup) (string, string) { return g.InvitationID, g.GroupName }
248func pendingKey(p pendingConfirmation) (string, string) { return p.UserID, "" }
249func sshKeyKey(k sshKey) (string, string) { return k.ID, "" }
250
251func toSnapshot(doc document) storage.UserbaseSnapshot {
252 s := storage.UserbaseSnapshot{NextUIDNumber: doc.NextUIDNumber, NextGIDNumber: doc.NextGIDNumber}
253 for _, u := range doc.Users {
254 s.Users = append(s.Users, storage.UserbaseUser(u))
255 }
256 for _, g := range doc.Groups {
257 s.Groups = append(s.Groups, storage.UserbaseGroup(g))
258 }
259 for _, m := range doc.Memberships {
260 s.Memberships = append(s.Memberships, storage.UserbaseMembership(m))
261 }
262 for _, l := range doc.GoogleLinks {
263 s.GoogleLinks = append(s.GoogleLinks, storage.UserbaseGoogleLink(l))
264 }
265 for _, i := range doc.Invitations {
266 s.Invitations = append(s.Invitations, storage.UserbaseInvitation(i))
267 }
268 for _, g := range doc.InvitationGroups {
269 s.InvitationGroups = append(s.InvitationGroups, storage.UserbaseInvitationGroup(g))
270 }
271 for _, p := range doc.PendingConfirmations {
272 s.PendingConfirmations = append(s.PendingConfirmations, storage.UserbasePendingConfirmation(p))
273 }
274 for _, k := range doc.SSHKeys {
275 s.SSHKeys = append(s.SSHKeys, storage.UserbaseSSHKey(k))
276 }
277 return s
278}
279
280func checkStrings(doc document) error {
281 var bad []string
282 check := func(field string, values ...*string) {
283 for _, value := range values {
284 if value != nil && !utf8.ValidString(*value) {
285 bad = append(bad, field)
286 return
287 }
288 }
289 }
290 for i := range doc.Users {
291 u := &doc.Users[i]
292 check(fmt.Sprintf("users[%d]", i), &u.ID, &u.Username, &u.DisplayName)
293 }
294 for i := range doc.Groups {
295 g := &doc.Groups[i]
296 check(fmt.Sprintf("groups[%d]", i), &g.ID, &g.Name, &g.Description)
297 }
298 for i := range doc.Memberships {
299 m := &doc.Memberships[i]
300 check(fmt.Sprintf("memberships[%d]", i), &m.UserID, &m.GroupID)
301 }
302 for i := range doc.GoogleLinks {
303 l := &doc.GoogleLinks[i]
304 check(fmt.Sprintf("google_links[%d]", i), &l.UserID, &l.GoogleSub)
305 }
306 for i := range doc.Invitations {
307 v := &doc.Invitations[i]
308 check(fmt.Sprintf("invitations[%d]", i), &v.ID, v.CreatedBy, v.UsedBy, &v.Label, v.BatchID)
309 }
310 for i := range doc.InvitationGroups {
311 g := &doc.InvitationGroups[i]
312 check(fmt.Sprintf("invitation_groups[%d]", i), &g.InvitationID, g.GroupID, &g.GroupName)
313 }
314 for i := range doc.PendingConfirmations {
315 p := &doc.PendingConfirmations[i]
316 check(fmt.Sprintf("pending_confirmations[%d]", i), &p.UserID)
317 }
318 for i := range doc.SSHKeys {
319 k := &doc.SSHKeys[i]
320 check(fmt.Sprintf("ssh_keys[%d]", i), &k.ID, &k.UserID, &k.Fingerprint, &k.KeyType, &k.Label)
321 }
322 if len(bad) > 0 {
323 return fmt.Errorf("userbase: encode: not valid UTF-8: %s", strings.Join(bad, ", "))
324 }
325 return nil
326}
327
328func checkSSHKeys(keys []sshKey) error {
329 for i, k := range keys {
330 parsed, err := openssh.FromData(k.KeyType, k.KeyData)
331 if err != nil {
332 return fmt.Errorf("userbase: decode: ssh_keys[%d]: %w", i, err)
333 }
334 if parsed.Fingerprint != k.Fingerprint {
335 return fmt.Errorf("userbase: decode: ssh_keys[%d]: fingerprint %s is not that of the key's data", i, k.Fingerprint)
336 }
337 }
338 return nil
339}