diff --git a/migrations/003_invites.sql b/migrations/003_invites.sql new file mode 100644 index 00000000..0379eb8b --- /dev/null +++ b/migrations/003_invites.sql @@ -0,0 +1,28 @@ +-- 003_invites.sql — single-use registration invites (issue: user accounts / wallet model). +-- +-- An admin mints an invite so a brand-new identity can join the bus allowlist +-- WITHOUT the admin ever handling its private key. The token is the bearer +-- secret that authorizes POST /register: the registering client generates its +-- keypair locally and publishes only its public keys, fixing the link between an +-- invite and the identity it creates via the audit columns below. The handle and +-- role are fixed by the admin at mint time and cannot be changed by the client +-- (no privilege escalation). +-- +-- Additive and idempotent: safe to apply repeatedly. Never modify this file; +-- further schema changes go in new numbered migrations (see +-- .claude/rules/db_migrations.md). The embedded copy under +-- pkg/membership/migrations/003_invites.sql mirrors this file byte-for-byte. + +CREATE TABLE IF NOT EXISTS invites ( + token TEXT PRIMARY KEY, -- 32 random bytes in lowercase hex (the bearer secret) + handle TEXT NOT NULL, -- handle the new user will get (fixed by admin) + role TEXT NOT NULL DEFAULT 'member', -- 'admin' | 'member' (fixed by admin) + expires_at TEXT NOT NULL, -- RFC3339; past this the invite is dead + used INTEGER NOT NULL DEFAULT 0, -- 0 pending, 1 consumed (single-use) + created_at TEXT NOT NULL, + used_at TEXT, -- RFC3339 when consumed (NULL until used) + used_sign_pub TEXT, -- Ed25519 key that consumed it (audit; NULL until used) + used_kex_pub TEXT -- X25519 key presented at registration (audit; NULL until used) +); + +CREATE INDEX IF NOT EXISTS idx_invites_used ON invites(used); diff --git a/pkg/membership/invites.go b/pkg/membership/invites.go new file mode 100644 index 00000000..176e924f --- /dev/null +++ b/pkg/membership/invites.go @@ -0,0 +1,296 @@ +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") +} diff --git a/pkg/membership/jetstream_store.go b/pkg/membership/jetstream_store.go index 264f1294..0e8ad6fe 100644 --- a/pkg/membership/jetstream_store.go +++ b/pkg/membership/jetstream_store.go @@ -50,6 +50,7 @@ const ( bucketByMember = "UNIBUS_rooms_by_member" bucketRoomKeys = "UNIBUS_room_keys" bucketUsers = "UNIBUS_users" + bucketInvites = "UNIBUS_invites" defaultKVOpTime = 5 * time.Second ) @@ -71,6 +72,7 @@ type jetstreamStore struct { byMember jetstream.KeyValue keys jetstream.KeyValue users jetstream.KeyValue + invites jetstream.KeyValue opTimeout time.Duration } @@ -108,6 +110,7 @@ func OpenJetStream(js jetstream.JetStream, cfg JetStreamConfig) (Store, error) { {bucketByMember, &s.byMember}, {bucketRoomKeys, &s.keys}, {bucketUsers, &s.users}, + {bucketInvites, &s.invites}, } { var kv jetstream.KeyValue var lastErr error @@ -498,6 +501,28 @@ func (s *jetstreamStore) RevokeUser(signPub string) error { return nil } +// DeleteUser hard-deletes a user from the KV allowlist (the purge counterpart of +// RevokeUser's status flip). It checks existence first so deleting an unknown key +// is ErrNotFound (KV Delete is otherwise idempotent and would not signal a miss). +// Only the allowlist key is removed; room memberships the ex-user holds become +// inert because they can no longer authenticate — see the SQLite DeleteUser for +// the full rationale on why room state is left untouched. +func (s *jetstreamStore) DeleteUser(signPub string) error { + signPub = normalizeSignPub(signPub) + ctx, cancel := s.ctx() + defer cancel() + if _, err := s.users.Get(ctx, signPub); err != nil { + if errors.Is(err, jetstream.ErrKeyNotFound) { + return fmt.Errorf("membership: delete user %q: %w", signPub, ErrNotFound) + } + return fmt.Errorf("membership: delete user %q: %w", signPub, err) + } + if err := s.users.Delete(ctx, signPub); err != nil { + return fmt.Errorf("membership: delete user %q: %w", signPub, err) + } + return nil +} + // IsAuthorized reports whether signPub is an active bus user. Any backend error // (including a KV quorum loss or timeout) yields false: fail closed. func (s *jetstreamStore) IsAuthorized(signPub string) bool { @@ -533,6 +558,173 @@ func (s *jetstreamStore) HasAdmin() bool { return false } +// ---- invites (single-use registration tokens) ---------------------------- + +func (s *jetstreamStore) 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), + } + b, err := json.Marshal(inv) + if err != nil { + return Invite{}, fmt.Errorf("membership: marshal invite: %w", err) + } + ctx, cancel := s.ctx() + defer cancel() + // Create (not Put) so a token collision is rejected rather than silently + // overwriting a live invite — a 32-byte random collision is astronomically + // unlikely, but Create makes the single-use guarantee unconditional. + if _, err := s.invites.Create(ctx, token, b); err != nil { + if errors.Is(err, jetstream.ErrKeyExists) { + return Invite{}, fmt.Errorf("membership: create invite: token collision") + } + return Invite{}, fmt.Errorf("membership: create invite: %w", err) + } + return inv, nil +} + +func (s *jetstreamStore) GetInvite(token string) (Invite, error) { + ctx, cancel := s.ctx() + defer cancel() + e, err := s.invites.Get(ctx, token) + if err != nil { + if errors.Is(err, jetstream.ErrKeyNotFound) { + return Invite{}, fmt.Errorf("membership: get invite %q: %w", token, ErrNotFound) + } + return Invite{}, fmt.Errorf("membership: get invite %q: %w", token, err) + } + var inv Invite + if err := json.Unmarshal(e.Value(), &inv); err != nil { + return Invite{}, fmt.Errorf("membership: unmarshal invite: %w", err) + } + return inv, nil +} + +func (s *jetstreamStore) ListInvites() ([]Invite, error) { + ctx, cancel := s.ctx() + w, err := s.invites.WatchAll(ctx, jetstream.IgnoreDeletes()) + if err != nil { + cancel() + return nil, fmt.Errorf("membership: list invites: %w", err) + } + defer cancel() + defer w.Stop() + var out []Invite + for { + select { + case e := <-w.Updates(): + if e == nil { + sort.Slice(out, func(i, j int) bool { + if out[i].CreatedAt != out[j].CreatedAt { + return out[i].CreatedAt > out[j].CreatedAt // newest first + } + return out[i].Token < out[j].Token + }) + return out, nil + } + var inv Invite + if err := json.Unmarshal(e.Value(), &inv); err != nil { + return nil, fmt.Errorf("membership: unmarshal invite: %w", err) + } + out = append(out, inv) + case <-ctx.Done(): + return nil, ctx.Err() + } + } +} + +// ConsumeInvite spends a KV invite and registers the presented signing key. With +// no multi-key transaction, single-use is enforced by a compare-and-swap on the +// invite: the token is marked used via Update against the revision read by Get, +// so only ONE concurrent consumer can win the swap; the loser sees a revision +// mismatch and is rejected as used. The user is registered AFTER the successful +// swap. Burn-on-claim: if the signing key is already registered the swap has +// already spent the token and we surface ErrUserExists — the SQLite store commits +// the same way, so both backends behave identically. +func (s *jetstreamStore) ConsumeInvite(token, signPub, kexPub string) error { + signPub = normalizeSignPub(signPub) + kexPub = normalizeSignPub(kexPub) + if signPub == "" { + return fmt.Errorf("membership: ConsumeInvite: sign_pub required") + } + + ctx, cancel := s.ctx() + defer cancel() + e, err := s.invites.Get(ctx, token) + if err != nil { + if errors.Is(err, jetstream.ErrKeyNotFound) { + return fmt.Errorf("membership: consume invite %q: %w", token, ErrNotFound) + } + return fmt.Errorf("membership: consume invite %q: %w", token, err) + } + var inv Invite + if err := json.Unmarshal(e.Value(), &inv); err != nil { + return fmt.Errorf("membership: unmarshal invite: %w", err) + } + if inv.Used { + return fmt.Errorf("membership: consume invite %q: %w", token, ErrInviteUsed) + } + if inviteIsExpired(inv.ExpiresAt) { + return fmt.Errorf("membership: consume invite %q: %w", token, ErrInviteExpired) + } + + inv.Used = true + inv.UsedAt = nowRFC3339() + inv.UsedSignPub = signPub + inv.UsedKexPub = kexPub + b, err := json.Marshal(inv) + if err != nil { + return fmt.Errorf("membership: marshal invite: %w", err) + } + // CAS: Update only succeeds if the invite is still at the revision we read, so + // a racing consumer that already flipped it loses here. A failed swap is + // conservatively treated as "already used" (the common cause); the caller can + // re-read to learn the precise state. + if _, err := s.invites.Update(ctx, token, b, e.Revision()); err != nil { + return fmt.Errorf("membership: consume invite %q: %w", token, ErrInviteUsed) + } + + // Token is now spent. Register the user with the invite-fixed handle and role. + if err := s.AddUser(signPub, inv.Handle, inv.Role); err != nil { + if errors.Is(err, ErrUserExists) { + return ErrUserExists + } + return fmt.Errorf("membership: consume invite %q: register user: %w", token, err) + } + return nil +} + +func (s *jetstreamStore) CancelInvite(token string) error { + ctx, cancel := s.ctx() + defer cancel() + if _, err := s.invites.Get(ctx, token); err != nil { + if errors.Is(err, jetstream.ErrKeyNotFound) { + return fmt.Errorf("membership: cancel invite %q: %w", token, ErrNotFound) + } + return fmt.Errorf("membership: cancel invite %q: %w", token, err) + } + if err := s.invites.Delete(ctx, token); err != nil { + return fmt.Errorf("membership: cancel invite %q: %w", token, err) + } + return nil +} + // ---- snapshot import / export (issue 0003c migration) --------------------- // importSnapshot writes a full Snapshot into the KV buckets, preserving each diff --git a/pkg/membership/migrations/003_invites.sql b/pkg/membership/migrations/003_invites.sql new file mode 100644 index 00000000..0379eb8b --- /dev/null +++ b/pkg/membership/migrations/003_invites.sql @@ -0,0 +1,28 @@ +-- 003_invites.sql — single-use registration invites (issue: user accounts / wallet model). +-- +-- An admin mints an invite so a brand-new identity can join the bus allowlist +-- WITHOUT the admin ever handling its private key. The token is the bearer +-- secret that authorizes POST /register: the registering client generates its +-- keypair locally and publishes only its public keys, fixing the link between an +-- invite and the identity it creates via the audit columns below. The handle and +-- role are fixed by the admin at mint time and cannot be changed by the client +-- (no privilege escalation). +-- +-- Additive and idempotent: safe to apply repeatedly. Never modify this file; +-- further schema changes go in new numbered migrations (see +-- .claude/rules/db_migrations.md). The embedded copy under +-- pkg/membership/migrations/003_invites.sql mirrors this file byte-for-byte. + +CREATE TABLE IF NOT EXISTS invites ( + token TEXT PRIMARY KEY, -- 32 random bytes in lowercase hex (the bearer secret) + handle TEXT NOT NULL, -- handle the new user will get (fixed by admin) + role TEXT NOT NULL DEFAULT 'member', -- 'admin' | 'member' (fixed by admin) + expires_at TEXT NOT NULL, -- RFC3339; past this the invite is dead + used INTEGER NOT NULL DEFAULT 0, -- 0 pending, 1 consumed (single-use) + created_at TEXT NOT NULL, + used_at TEXT, -- RFC3339 when consumed (NULL until used) + used_sign_pub TEXT, -- Ed25519 key that consumed it (audit; NULL until used) + used_kex_pub TEXT -- X25519 key presented at registration (audit; NULL until used) +); + +CREATE INDEX IF NOT EXISTS idx_invites_used ON invites(used); diff --git a/pkg/membership/store.go b/pkg/membership/store.go index fb57257c..5b57c6c0 100644 --- a/pkg/membership/store.go +++ b/pkg/membership/store.go @@ -80,9 +80,23 @@ type Store interface { GetUser(signPub string) (User, error) ListUsers() ([]User, error) RevokeUser(signPub string) error + // DeleteUser hard-deletes a user (the purge counterpart of RevokeUser's + // status flip): the row is removed, not just flagged. The ex-user can no + // longer authenticate, so any room memberships they hold become inert. + DeleteUser(signPub string) error IsAuthorized(signPub string) bool HasAdmin() bool + // Invites (single-use registration tokens; the wallet-model join path). + // CreateInvite mints a token fixing handle+role; ConsumeInvite is the only + // path that adds to the allowlist without an admin signature (the bearer + // token is the authorization), spending the token exactly once. + CreateInvite(handle, role string, ttlSecs int) (Invite, error) + GetInvite(token string) (Invite, error) + ListInvites() ([]Invite, error) + ConsumeInvite(token, signPub, kexPub string) error + CancelInvite(token string) error + // Lifecycle. Close() error } diff --git a/pkg/membership/users.go b/pkg/membership/users.go index 741194e1..10b7da2b 100644 --- a/pkg/membership/users.go +++ b/pkg/membership/users.go @@ -53,6 +53,23 @@ func ValidateSignPubHex(signPub string) error { return nil } +// ValidateKexPubHex ensures kexPub is exactly a 32-byte X25519 public key in hex +// (64 hex chars). It is the registration-side counterpart of ValidateSignPubHex: +// POST /register receives both the new identity's signing key and its key-exchange +// key, and both must be well-formed before the invite is consumed. An X25519 +// public key is 32 bytes, identical in length to Ed25519, so the check is the +// same shape with a key-exchange-specific message. +func ValidateKexPubHex(kexPub string) error { + b, err := hex.DecodeString(kexPub) + if err != nil { + return fmt.Errorf("kex-pub is not valid hex: %w", err) + } + if len(b) != 32 { + return fmt.Errorf("kex-pub must be a 32-byte X25519 public key (64 hex chars), got %d bytes", len(b)) + } + return nil +} + // normalizeSignPub lowercases the hex key so lookups are case-insensitive: the // primary key is stored lowercase and every query normalizes its input the same // way, so a caller passing uppercase hex still matches. @@ -90,8 +107,10 @@ func (s *sqliteStore) AddUser(signPub, handle, role string) error { return nil } -// GetUser returns the user with the given signing public key. It returns -// sql.ErrNoRows (wrapped) when there is no such user. +// GetUser returns the user with the given signing public key. A miss returns +// ErrNotFound (wrapped), matching the storage-agnostic contract in store.go and +// the JetStream backend, so callers can branch on ErrNotFound regardless of which +// store is active (the SQLite-specific sql.ErrNoRows is mapped here). func (s *sqliteStore) GetUser(signPub string) (User, error) { signPub = normalizeSignPub(signPub) var u User @@ -101,6 +120,9 @@ func (s *sqliteStore) GetUser(signPub string) (User, error) { signPub, ).Scan(&u.SignPub, &u.Handle, &u.Role, &u.Status, &u.CreatedAt, &revoked) if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return User{}, fmt.Errorf("membership: get user %q: %w", signPub, ErrNotFound) + } return User{}, fmt.Errorf("membership: get user %q: %w", signPub, err) } u.RevokedAt = revoked.String @@ -153,6 +175,31 @@ func (s *sqliteStore) RevokeUser(signPub string) error { return nil } +// DeleteUser hard-deletes a user from the allowlist (admin "remove user", the +// purge counterpart of RevokeUser's status flip). It removes ONLY the allowlist +// row: the ex-user can no longer authenticate on either plane, so any room +// memberships they still hold become inert (they cannot fetch a sealed key, sign +// a request, or open a NATS connection). We deliberately do NOT chase down and +// rewrite those room memberships here — that would be a partial, racy cleanup of +// state owned by each room's owner; a room owner kicks/rekeys to achieve forward +// secrecy when needed. Deleting an unknown key returns ErrNotFound (wrapped) so +// the HTTP layer can answer 404. +func (s *sqliteStore) DeleteUser(signPub string) error { + signPub = normalizeSignPub(signPub) + res, err := s.db.Exec(`DELETE FROM users WHERE sign_pub = ?`, signPub) + if err != nil { + return fmt.Errorf("membership: delete user %q: %w", signPub, err) + } + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("membership: delete user %q: rows affected: %w", signPub, err) + } + if n == 0 { + return fmt.Errorf("membership: delete user %q: %w", signPub, ErrNotFound) + } + return nil +} + // IsAuthorized reports whether signPub belongs to an active (non-revoked) bus // user. It is the single authorization predicate consulted by both the control // plane (HTTP request middleware) and the data plane (NATS nkey authenticator),