feat(membership): single-use invites + hard-delete in the Store (SQLite + KV)

Add the data layer for WhatsApp-style accounts on the wallet model: the admin
mints a single-use invitation link, the new user redeems it by publishing only
its public keys, and the admin can hard-delete a user.

- Invite type and lifecycle (invites.go): 32-byte crypto/rand hex token, 7-day
  default TTL, fail-closed expiry parsing. Methods CreateInvite/GetInvite/
  ListInvites/ConsumeInvite/CancelInvite on both backends. ConsumeInvite is
  atomic and single-use: SQLite uses a transaction guarded by `used = 0`, the KV
  store uses a compare-and-swap on the entry revision (mark-first). Both burn the
  token on claim, so an already-registered key surfaces ErrUserExists with the
  invite spent — identical semantics across backends.
- DeleteUser (users.go + jetstream_store.go): hard-delete of the allowlist row,
  distinct from RevokeUser's status flip. Room memberships of the ex-user are
  intentionally left inert (they can no longer authenticate); no partial cleanup.
- Migration 003_invites.sql (root + embedded copy, byte-identical): additive
  `invites` table with audit columns, per db_migrations rules.
- Store interface gains DeleteUser, CreateInvite, GetInvite, ListInvites,
  ConsumeInvite, CancelInvite. New UNIBUS_invites KV bucket.
- Consistency fix: SQLite GetUser now maps sql.ErrNoRows to ErrNotFound, matching
  the KV backend and the storage-agnostic contract documented in store.go.
- ValidateKexPubHex added alongside ValidateSignPubHex for /register key checks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 22:14:25 +02:00
parent f31580deec
commit d64b0c052d
6 changed files with 607 additions and 2 deletions
+28
View File
@@ -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);
+296
View File
@@ -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")
}
+192
View File
@@ -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
+28
View File
@@ -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);
+14
View File
@@ -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
}
+49 -2
View File
@@ -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),