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:
+49
-2
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user