package membership import ( "crypto/rand" "database/sql" "encoding/hex" "errors" "fmt" "strings" "time" ) // Invite is a single-use registration token the admin mints so a brand-new // identity can join the bus allowlist WITHOUT the admin ever handling its // private key (the wallet model: the key is born and stays on the user's // device; only the public key is published, via POST /register). // // The admin fixes the handle and role at mint time; the registering client may // NOT change them (no privilege escalation). Token is 32 random bytes in // lowercase hex (64 chars). ExpiresAt and CreatedAt are RFC3339Nano UTC. Used // flips to true the instant the invite is consumed, and an invite can be // consumed at most once. The audit fields (UsedAt/UsedSignPub/UsedKexPub) are // empty until the invite is consumed; they record which keys claimed it, so the // link between an invite and the identity it created stays traceable even though // the allowlist row itself stores only the signing key. type Invite struct { Token string `json:"token"` Handle string `json:"handle"` Role string `json:"role"` ExpiresAt string `json:"expires_at"` Used bool `json:"used"` CreatedAt string `json:"created_at"` // Audit (populated on consume; omitted on the wire while pending). UsedAt string `json:"used_at,omitempty"` UsedSignPub string `json:"used_sign_pub,omitempty"` UsedKexPub string `json:"used_kex_pub,omitempty"` } // Invite-flow sentinels. They let callers (and the HTTP layer) map a failed // consume to a precise status code without string-matching: an unknown token is // ErrNotFound (reused from the store), a spent token is ErrInviteUsed, a // past-deadline token is ErrInviteExpired. ErrUserExists (from users.go) is // reused when the presented signing key is already registered. var ( ErrInviteUsed = errors.New("membership: invite already used") ErrInviteExpired = errors.New("membership: invite expired") ) // defaultInviteTTL is the lifetime of an invite when the caller passes a // non-positive ttlSecs. Seven days mirrors a typical "share this link this // week" expectation while keeping the un-authenticated /register window bounded. const defaultInviteTTL = 7 * 24 * time.Hour // newInviteToken returns 32 cryptographically-random bytes as lowercase hex (64 // chars). The token IS the bearer secret that authorizes /register, so it must // be unguessable; crypto/rand is the only acceptable source. func newInviteToken() (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return "", fmt.Errorf("membership: generate invite token: %w", err) } return hex.EncodeToString(b), nil } // inviteTTL resolves a caller-supplied ttlSecs into a concrete duration, // defaulting to defaultInviteTTL when non-positive. func inviteTTL(ttlSecs int) time.Duration { if ttlSecs <= 0 { return defaultInviteTTL } return time.Duration(ttlSecs) * time.Second } // inviteIsExpired reports whether the RFC3339 expiry has passed. A token whose // expiry cannot be parsed is treated as expired (fail closed): a corrupt // deadline must never widen the unauthenticated registration window. func inviteIsExpired(expiresAt string) bool { exp, err := time.Parse(time.RFC3339Nano, expiresAt) if err != nil { return true } return time.Now().UTC().After(exp) } // validateInviteRole normalizes and validates the role an invite may carry. It // mirrors AddUser: empty defaults to member, and only admin|member are allowed // (an admin minting an admin invite is deliberate and permitted). func validateInviteRole(role string) (string, error) { if role == "" { return RoleMember, nil } if role != RoleAdmin && role != RoleMember { return "", fmt.Errorf("membership: invalid role %q (want %q or %q)", role, RoleAdmin, RoleMember) } return role, nil } // ---- SQLite implementation ------------------------------------------------ // CreateInvite mints a single-use invite for a future user. handle is required; // role defaults to member and must be admin|member. ttlSecs sets the lifetime // (non-positive uses the 7-day default). The token is 32 random bytes in hex. func (s *sqliteStore) CreateInvite(handle, role string, ttlSecs int) (Invite, error) { if handle == "" { return Invite{}, fmt.Errorf("membership: CreateInvite: handle required") } role, err := validateInviteRole(role) if err != nil { return Invite{}, err } token, err := newInviteToken() if err != nil { return Invite{}, err } now := time.Now().UTC() inv := Invite{ Token: token, Handle: handle, Role: role, ExpiresAt: now.Add(inviteTTL(ttlSecs)).Format(time.RFC3339Nano), Used: false, CreatedAt: now.Format(time.RFC3339Nano), } if _, err := s.db.Exec( `INSERT INTO invites (token, handle, role, expires_at, used, created_at) VALUES (?, ?, ?, ?, 0, ?)`, inv.Token, inv.Handle, inv.Role, inv.ExpiresAt, inv.CreatedAt, ); err != nil { return Invite{}, fmt.Errorf("membership: insert invite: %w", err) } return inv, nil } // GetInvite returns the invite with the given token, or ErrNotFound (wrapped) // when there is none. func (s *sqliteStore) GetInvite(token string) (Invite, error) { var inv Invite var used int var usedAt, usedSign, usedKex sql.NullString err := s.db.QueryRow( `SELECT token, handle, role, expires_at, used, created_at, used_at, used_sign_pub, used_kex_pub FROM invites WHERE token = ?`, token, ).Scan(&inv.Token, &inv.Handle, &inv.Role, &inv.ExpiresAt, &used, &inv.CreatedAt, &usedAt, &usedSign, &usedKex) if err != nil { if errors.Is(err, sql.ErrNoRows) { return Invite{}, fmt.Errorf("membership: get invite %q: %w", token, ErrNotFound) } return Invite{}, fmt.Errorf("membership: get invite %q: %w", token, err) } inv.Used = used != 0 inv.UsedAt, inv.UsedSignPub, inv.UsedKexPub = usedAt.String, usedSign.String, usedKex.String return inv, nil } // ListInvites returns every invite ordered newest-first (by created_at). It // includes consumed invites so the admin panel can show the full picture; the // caller filters to "pending" when it wants only live links. func (s *sqliteStore) ListInvites() ([]Invite, error) { rows, err := s.db.Query( `SELECT token, handle, role, expires_at, used, created_at, used_at, used_sign_pub, used_kex_pub FROM invites ORDER BY created_at DESC, token`, ) if err != nil { return nil, fmt.Errorf("membership: list invites: %w", err) } defer rows.Close() var out []Invite for rows.Next() { var inv Invite var used int var usedAt, usedSign, usedKex sql.NullString if err := rows.Scan(&inv.Token, &inv.Handle, &inv.Role, &inv.ExpiresAt, &used, &inv.CreatedAt, &usedAt, &usedSign, &usedKex); err != nil { return nil, fmt.Errorf("membership: scan invite: %w", err) } inv.Used = used != 0 inv.UsedAt, inv.UsedSignPub, inv.UsedKexPub = usedAt.String, usedSign.String, usedKex.String out = append(out, inv) } return out, rows.Err() } // ConsumeInvite atomically validates and spends an invite, registering the // presented signing key as a bus user with the invite's handle and role. It is // the ONLY path that adds to the allowlist without an admin signature: the // bearer token is the authorization, so the checks here are the security // boundary. // // Atomicity (single transaction): the invite is marked used FIRST (guarded by // `used = 0`, so two concurrent consumers cannot both win), then the user is // inserted. A token that passes validation is therefore spent exactly once. // Special case: if the signing key is already registered, the user INSERT hits // the PRIMARY KEY and we return ErrUserExists — but the invite stays SPENT (we // commit the mark), matching the JetStream backend's burn-on-claim semantics so // the two stores behave identically. A genuine backend error rolls everything // back, leaving the invite reusable. func (s *sqliteStore) ConsumeInvite(token, signPub, kexPub string) error { signPub = normalizeSignPub(signPub) kexPub = normalizeSignPub(kexPub) if signPub == "" { return fmt.Errorf("membership: ConsumeInvite: sign_pub required") } tx, err := s.db.Begin() if err != nil { return fmt.Errorf("membership: ConsumeInvite: begin: %w", err) } defer tx.Rollback() var handle, role, expiresAt string var used int err = tx.QueryRow( `SELECT handle, role, expires_at, used FROM invites WHERE token = ?`, token, ).Scan(&handle, &role, &expiresAt, &used) if err != nil { if errors.Is(err, sql.ErrNoRows) { return fmt.Errorf("membership: consume invite %q: %w", token, ErrNotFound) } return fmt.Errorf("membership: consume invite %q: %w", token, err) } if used != 0 { return fmt.Errorf("membership: consume invite %q: %w", token, ErrInviteUsed) } if inviteIsExpired(expiresAt) { return fmt.Errorf("membership: consume invite %q: %w", token, ErrInviteExpired) } // Mark used first, guarded by used = 0 so a concurrent consumer that already // flipped it (rows affected = 0) is rejected as used rather than double-spending. now := nowRFC3339() res, err := tx.Exec( `UPDATE invites SET used = 1, used_at = ?, used_sign_pub = ?, used_kex_pub = ? WHERE token = ? AND used = 0`, now, signPub, kexPub, token, ) if err != nil { return fmt.Errorf("membership: consume invite %q: mark used: %w", token, err) } n, err := res.RowsAffected() if err != nil { return fmt.Errorf("membership: consume invite %q: rows affected: %w", token, err) } if n == 0 { return fmt.Errorf("membership: consume invite %q: %w", token, ErrInviteUsed) } // Register the user with the invite-fixed handle and role. _, err = tx.Exec( `INSERT INTO users (sign_pub, handle, role, status, created_at) VALUES (?, ?, ?, ?, ?)`, signPub, handle, role, StatusActive, now, ) if err != nil { // Already-registered key: the invite is still spent (commit the mark) so // the burn-on-claim contract matches the KV store. Any other failure rolls back. if isUniqueViolation(err) { if cErr := tx.Commit(); cErr != nil { return fmt.Errorf("membership: consume invite %q: commit: %w", token, cErr) } return ErrUserExists } return fmt.Errorf("membership: consume invite %q: insert user: %w", token, err) } if err := tx.Commit(); err != nil { return fmt.Errorf("membership: consume invite %q: commit: %w", token, err) } return nil } // CancelInvite removes a pending invite (the admin revoked the link before it // was used). It hard-deletes the row; a consumed invite stays for audit only if // the caller targets a pending token. Deleting an unknown token returns // ErrNotFound so the HTTP layer can answer 404. func (s *sqliteStore) CancelInvite(token string) error { res, err := s.db.Exec(`DELETE FROM invites WHERE token = ?`, token) if err != nil { return fmt.Errorf("membership: cancel invite %q: %w", token, err) } n, err := res.RowsAffected() if err != nil { return fmt.Errorf("membership: cancel invite %q: rows affected: %w", token, err) } if n == 0 { return fmt.Errorf("membership: cancel invite %q: %w", token, ErrNotFound) } return nil } // isUniqueViolation reports whether err is a SQLite UNIQUE/PRIMARY KEY conflict. // modernc.org/sqlite surfaces it as a message fragment; matching it here keeps // the string-matching in one place (the same fragments AddUser checks inline). func isUniqueViolation(err error) bool { if err == nil { return false } msg := err.Error() return strings.Contains(msg, "UNIQUE constraint") || strings.Contains(msg, "PRIMARY KEY") }