Raw
  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}