Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 19bb0e56a6 | |||
| 7e2f62520d | |||
| 52c80ac010 | |||
| ca801d16af | |||
| 18987bbd2f | |||
| d64b0c052d |
@@ -14,3 +14,7 @@ worker.id
|
|||||||
/chat
|
/chat
|
||||||
*.exe
|
*.exe
|
||||||
registry.db
|
registry.db
|
||||||
|
|
||||||
|
# local worktree resolution (do not commit)
|
||||||
|
go.work
|
||||||
|
go.work.sum
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
name: unibus
|
name: unibus
|
||||||
lang: go
|
lang: go
|
||||||
domain: infra
|
domain: infra
|
||||||
version: 0.11.0
|
version: 0.12.0
|
||||||
description: "Bus de mensajería unificado sobre NATS+JetStream con cifrado E2E por room (megolm/olm reducido): service de membresía/claves, librería cliente y peers demo."
|
description: "Bus de mensajería unificado sobre NATS+JetStream con cifrado E2E por room (megolm/olm reducido): service de membresía/claves, librería cliente y peers demo."
|
||||||
tags: [service, messaging, nats, e2e]
|
tags: [service, messaging, nats, e2e]
|
||||||
uses_functions:
|
uses_functions:
|
||||||
@@ -137,6 +137,26 @@ Para apuntar a un NATS externo en producción: `--nats-url nats://host:4222` en
|
|||||||
firme el primer `POST /users`); a partir de ahí toda la gestión es HTTP admin-only. El
|
firme el primer `POST /users`); a partir de ahí toda la gestión es HTTP admin-only. El
|
||||||
alta es idempotente igual que la CLI: re-alta de una clave ya registrada = 409, sin
|
alta es idempotente igual que la CLI: re-alta de una clave ya registrada = 409, sin
|
||||||
sobrescribir ni elevar rol; el revoke es un flip de status (sin hard-delete), auditable.
|
sobrescribir ni elevar rol; el revoke es un flip de status (sin hard-delete), auditable.
|
||||||
|
- **Cuentas estilo WhatsApp: alta por invitación, baja por hard-delete.** Sobre la API
|
||||||
|
admin anterior, `unibus` añade el modelo wallet de cuentas. El admin NO genera claves:
|
||||||
|
`POST /invites` (admin-only) acuña un enlace de invitación de un solo uso con caducidad
|
||||||
|
(token de 32 bytes `crypto/rand` en hex; TTL default 7 días), fijando `handle` y `role`.
|
||||||
|
El nuevo usuario abre el enlace en SU cliente, que genera el par de claves localmente
|
||||||
|
(la privada nunca sale del dispositivo) y llama `POST /register` con `{token, sign_pub,
|
||||||
|
kex_pub}`. `/register` es la ÚNICA ruta que añade al allowlist sin firma admin —
|
||||||
|
autorizada por el TOKEN, porque la identidad nueva aún no está en el allowlist y no puede
|
||||||
|
firmar. Está endurecida: token fuerte de un solo uso (consumo atómico, doble uso → 409),
|
||||||
|
caducidad (→ 410), `handle`/`role` fijados por el invite (sin escalado), validación
|
||||||
|
estricta de ambas claves hex de 64 chars, y rate-limit por IP heredado del control plane
|
||||||
|
(solo `/healthz` está exento). El borrado de cuenta es `DELETE /users/{signpub}`
|
||||||
|
(admin-only): hard-delete real del allowlist, distinto del `revoke` (que se mantiene:
|
||||||
|
revoke = quitar acceso dejando rastro auditable; delete = purga). Tras hard-delete, las
|
||||||
|
membresías de rooms del ex-usuario quedan inertes (ya no puede autenticarse en ningún
|
||||||
|
plano); NO se limpian a medias — un owner expulsa/rekey su room si quiere forward secrecy.
|
||||||
|
Invites y users viven en el MISMO store (SQLite `invites`/`users`, KV `UNIBUS_invites`/
|
||||||
|
`UNIBUS_users`). `pkg/client` gana `CreateInvite/ListInvites/CancelInvite/Register/
|
||||||
|
DeleteUser`; solo `Register` va sin firmar. Recovery: hard-delete del último admin se
|
||||||
|
recupera con la CLI local `membershipd user add` (mismo seam que siembra el admin #0).
|
||||||
- **Identidad = secreto crítico.** El archivo de identidad (`worker.id`,
|
- **Identidad = secreto crítico.** El archivo de identidad (`worker.id`,
|
||||||
`chat.id`) contiene las claves privadas (Ed25519 + X25519). Se escribe 0600.
|
`chat.id`) contiene las claves privadas (Ed25519 + X25519). Se escribe 0600.
|
||||||
Perderlo = mensajes ilegibles, sin recuperación. Trátalo como una clave SSH.
|
Perderlo = mensajes ilegibles, sin recuperación. Trátalo como una clave SSH.
|
||||||
@@ -169,6 +189,35 @@ agent.<nombre>.{in,out} inbox/outbox de agente LLM (agent.scout.in)
|
|||||||
|
|
||||||
## Capability growth log
|
## Capability growth log
|
||||||
|
|
||||||
|
- v0.12.0 (2026-06-07) — capa de CUENTAS estilo WhatsApp sobre el modelo wallet: alta de
|
||||||
|
usuario por enlace de invitación de un solo uso + baja por hard-delete real. El admin
|
||||||
|
nunca ve la clave privada del usuario. (1) **Invites**: nuevo backend de datos en ambos
|
||||||
|
stores (SQLite `invites` vía migración aditiva `003_invites.sql`; KV `UNIBUS_invites`).
|
||||||
|
Tipo `Invite{Token, Handle, Role, ExpiresAt, Used, CreatedAt}` + campos de auditoría del
|
||||||
|
consumo (`UsedAt/UsedSignPub/UsedKexPub`). Métodos `Store.CreateInvite` (token 32 bytes
|
||||||
|
`crypto/rand` hex, TTL default 7d), `GetInvite`, `ListInvites`, `ConsumeInvite` (valida
|
||||||
|
existe/no-usado/no-caducado → registra el sign_pub con el handle/role del invite → marca
|
||||||
|
usado, atómico) y `CancelInvite`. Consumo single-use garantizado en ambos backends: tx
|
||||||
|
SQLite (mark guard `used=0` + insert) y CAS sobre la revisión KV (mark-first); burn-on-
|
||||||
|
claim idéntico si la clave ya existe. (2) **Hard-delete**: `Store.DeleteUser` (SQLite
|
||||||
|
`DELETE FROM users`, KV `users.Delete`) purga el allowlist — distinto del `revoke`
|
||||||
|
(status flip, conservado). Las membresías de rooms del ex-usuario quedan inertes
|
||||||
|
(documentado, sin limpieza parcial). (3) **Endpoints HTTP**: `POST /invites`, `GET
|
||||||
|
/invites` (solo pendientes), `DELETE /invites/{token}`, `DELETE /users/{signpub}`
|
||||||
|
(todos admin-only vía `requireAdmin`) y `POST /register` — la única ruta auth-exempt de
|
||||||
|
firma admin (autorizada por el token), rate-limited (se separa `isRateExempt`, solo
|
||||||
|
`/healthz`, de `isAuthExempt`) y con validación hex estricta de `sign_pub`+`kex_pub`
|
||||||
|
ANTES de gastar el token. Errores mapeados: token desconocido 404, usado 409, caducado
|
||||||
|
410, identidad ya registrada 409. (4) **pkg/client**: `CreateInvite/ListInvites/
|
||||||
|
CancelInvite/Register/DeleteUser`; `Register` va sin firma vía un helper `doUnsigned`.
|
||||||
|
(5) Fix de consistencia: el `GetUser` de SQLite ahora mapea `sql.ErrNoRows` → `ErrNotFound`
|
||||||
|
como el KV y como documenta `store.go`. Tests nuevos: suite de invites store-level en
|
||||||
|
AMBOS backends (golden + single-use + token desconocido + caducado + cancel + hard-delete
|
||||||
|
+ burn-on-claim), suite HTTP (crear invite → register sin auth → aparece en allowlist →
|
||||||
|
re-register 409 → caducado 410 → no-admin 403 en las 4 rutas admin → hard-delete purga),
|
||||||
|
y test de cliente end-to-end (admin acuña invite → joiner no-registrado redime sin firma →
|
||||||
|
aparece → hard-delete desaparece). Cambios 100% aditivos: el comportamiento previo no
|
||||||
|
cambia; build/vet/test verdes (`CGO_ENABLED=0`).
|
||||||
- v0.11.0 (2026-06-07) — flag dedicado `UNIBUS_NATS_MONITOR` que abre el endpoint
|
- v0.11.0 (2026-06-07) — flag dedicado `UNIBUS_NATS_MONITOR` que abre el endpoint
|
||||||
de monitoring HTTP del nats-server embebido (`127.0.0.1:8222`, loopback only) de
|
de monitoring HTTP del nats-server embebido (`127.0.0.1:8222`, loopback only) de
|
||||||
forma DESACOPLADA del debug-log. Antes el monitoring solo se abría con
|
forma DESACOPLADA del debug-log. Antes el monitoring solo se abría con
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -331,6 +331,60 @@ func (c *Client) doJSON(method, path string, body, out any) error {
|
|||||||
return fmt.Errorf("client: %s %s: all control planes failed: %w", method, path, lastErr)
|
return fmt.Errorf("client: %s %s: all control planes failed: %w", method, path, lastErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// doUnsigned performs a control-plane request WITHOUT the transport signature
|
||||||
|
// headers, for the one endpoint a not-yet-registered identity must reach: POST
|
||||||
|
// /register. The registering peer is not in the allowlist, so it cannot produce
|
||||||
|
// an accepted signature; authorization is the single-use invite token inside the
|
||||||
|
// body. Like doJSON it fails over across the control-plane endpoints (any node
|
||||||
|
// serves the same state) and surfaces the server's structured error message.
|
||||||
|
func (c *Client) doUnsigned(method, path string, body, out any) error {
|
||||||
|
var bodyBytes []byte
|
||||||
|
if body != nil {
|
||||||
|
b, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("client: marshal request: %w", err)
|
||||||
|
}
|
||||||
|
bodyBytes = b
|
||||||
|
}
|
||||||
|
var lastErr error
|
||||||
|
for _, base := range c.ctrlURLs {
|
||||||
|
var rdr io.Reader
|
||||||
|
if bodyBytes != nil {
|
||||||
|
rdr = bytes.NewReader(bodyBytes)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(method, base+path, rdr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("client: new request: %w", err)
|
||||||
|
}
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
continue // dead node: try the next control plane
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
var er struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(respBody, &er) == nil && er.Error != "" {
|
||||||
|
return fmt.Errorf("%s (HTTP %d)", er.Error, resp.StatusCode)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("client: %s %s -> %d: %s", method, path, resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
if out != nil {
|
||||||
|
if err := json.Unmarshal(respBody, out); err != nil {
|
||||||
|
return fmt.Errorf("client: decode response: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("client: %s %s: all control planes failed: %w", method, path, lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
// signRequest signs the canonical bytes of req (req must already have its Sig
|
// signRequest signs the canonical bytes of req (req must already have its Sig
|
||||||
// field cleared) with the client's Ed25519 key. It is symmetric with the
|
// field cleared) with the client's Ed25519 key. It is symmetric with the
|
||||||
// server's verifyOwnerSig. This is the PAYLOAD-level owner signature that
|
// server's verifyOwnerSig. This is the PAYLOAD-level owner signature that
|
||||||
@@ -473,6 +527,35 @@ type addUserReq struct {
|
|||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// createInviteReq / createInviteResp mirror the server's POST /invites types.
|
||||||
|
type createInviteReq struct {
|
||||||
|
Handle string `json:"handle"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
TTLSecs int `json:"ttl_secs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type createInviteResp struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
ExpiresAt string `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// inviteJSON mirrors the server's GET /invites row.
|
||||||
|
type inviteJSON 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerReq mirrors the server's POST /register body.
|
||||||
|
type registerReq struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
SignPub string `json:"sign_pub"`
|
||||||
|
KexPub string `json:"kex_pub"`
|
||||||
|
}
|
||||||
|
|
||||||
// ---- room operations ------------------------------------------------------
|
// ---- room operations ------------------------------------------------------
|
||||||
|
|
||||||
// RoomRef is a room this peer belongs to, returned by ListMyRooms. It is the
|
// RoomRef is a room this peer belongs to, returned by ListMyRooms. It is the
|
||||||
@@ -560,6 +643,82 @@ func (c *Client) RevokeUser(signPub string) error {
|
|||||||
return c.doJSON("POST", "/users/"+signPub+"/revoke", nil, nil)
|
return c.doJSON("POST", "/users/"+signPub+"/revoke", nil, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteUser hard-deletes a bus user by their signing public key (64-hex) — the
|
||||||
|
// purge counterpart of RevokeUser. The allowlist row is removed entirely (no
|
||||||
|
// audit trail); the ex-user can no longer authenticate, so their room
|
||||||
|
// memberships become inert. The caller must be signing as an admin.
|
||||||
|
func (c *Client) DeleteUser(signPub string) error {
|
||||||
|
return c.doJSON("DELETE", "/users/"+signPub, nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InviteInfo is a single-use registration invite as returned by the admin invite
|
||||||
|
// endpoints. It is a flat view for the admin panel: the bearer token (to build
|
||||||
|
// the join link), the handle and role the new user will receive, the absolute
|
||||||
|
// expiry, whether it has been used, and when it was minted.
|
||||||
|
type InviteInfo struct {
|
||||||
|
Token string
|
||||||
|
Handle string
|
||||||
|
Role string
|
||||||
|
ExpiresAt string
|
||||||
|
Used bool
|
||||||
|
CreatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateInvite mints a single-use registration invite. handle and role are fixed
|
||||||
|
// here (the registering client cannot change them); role is "admin" or "member"
|
||||||
|
// (empty defaults to member). ttlSecs sets the link lifetime (non-positive uses
|
||||||
|
// the server's 7-day default). The returned InviteInfo carries the token and
|
||||||
|
// expiry; the caller turns the token into a join link. Caller must sign as admin.
|
||||||
|
func (c *Client) CreateInvite(handle, role string, ttlSecs int) (InviteInfo, error) {
|
||||||
|
var resp createInviteResp
|
||||||
|
if err := c.doJSON("POST", "/invites", createInviteReq{Handle: handle, Role: role, TTLSecs: ttlSecs}, &resp); err != nil {
|
||||||
|
return InviteInfo{}, err
|
||||||
|
}
|
||||||
|
r := role
|
||||||
|
if r == "" {
|
||||||
|
r = "member"
|
||||||
|
}
|
||||||
|
return InviteInfo{Token: resp.Token, Handle: handle, Role: r, ExpiresAt: resp.ExpiresAt}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListInvites returns the pending invites (not used, not expired). Caller must
|
||||||
|
// sign as admin.
|
||||||
|
func (c *Client) ListInvites() ([]InviteInfo, error) {
|
||||||
|
var resp []inviteJSON
|
||||||
|
if err := c.doJSON("GET", "/invites", nil, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]InviteInfo, 0, len(resp))
|
||||||
|
for _, inv := range resp {
|
||||||
|
out = append(out, InviteInfo{
|
||||||
|
Token: inv.Token,
|
||||||
|
Handle: inv.Handle,
|
||||||
|
Role: inv.Role,
|
||||||
|
ExpiresAt: inv.ExpiresAt,
|
||||||
|
Used: inv.Used,
|
||||||
|
CreatedAt: inv.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CancelInvite cancels (deletes) a pending invite by its token, so an admin can
|
||||||
|
// revoke a link before it is redeemed. Caller must sign as admin.
|
||||||
|
func (c *Client) CancelInvite(token string) error {
|
||||||
|
return c.doJSON("DELETE", "/invites/"+token, nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register redeems a single-use invite token, joining the bus allowlist. It is
|
||||||
|
// the wallet-model join call: the registering peer generated its own keypair
|
||||||
|
// locally and publishes ONLY its public keys here (signPub Ed25519, kexPub
|
||||||
|
// X25519, both 64-hex). It is UNSIGNED — the bearer token is the authorization,
|
||||||
|
// because this identity is not yet in the allowlist and so cannot sign an
|
||||||
|
// accepted request. On success the identity is registered with the invite's
|
||||||
|
// handle and role and can connect like any other peer.
|
||||||
|
func (c *Client) Register(token, signPub, kexPub string) error {
|
||||||
|
return c.doUnsigned("POST", "/register", registerReq{Token: token, SignPub: signPub, KexPub: kexPub}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
// newRoomKey returns 32 random bytes for a symmetric room key.
|
// newRoomKey returns 32 random bytes for a symmetric room key.
|
||||||
func newRoomKey() ([]byte, error) {
|
func newRoomKey() ([]byte, error) {
|
||||||
k := make([]byte, 32)
|
k := make([]byte, 32)
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package client_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/enmanuel/unibus/pkg/client"
|
||||||
|
"github.com/enmanuel/unibus/pkg/membership"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestClientInvitesAdminAPI drives the wallet-model account flow through the real
|
||||||
|
// pkg/client methods against an in-process membershipd under enforce: an admin
|
||||||
|
// mints an invite, a brand-new identity redeems it via the UNSIGNED Register call
|
||||||
|
// (it is not yet in the allowlist), the admin then sees the user, and finally the
|
||||||
|
// admin hard-deletes it and it vanishes. This is the exact path the admin panel +
|
||||||
|
// the /join client page depend on, so it locks the client/server contract.
|
||||||
|
func TestClientInvitesAdminAPI(t *testing.T) {
|
||||||
|
h := newHarnessMode(t, membership.AuthEnforce)
|
||||||
|
waitHealth(t, h.ctrlURL)
|
||||||
|
|
||||||
|
admin, err := client.New(h.natsURL, h.ctrlURL, mustIdentity(t))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect admin: %v", err)
|
||||||
|
}
|
||||||
|
defer admin.Close()
|
||||||
|
registerClient(t, h, admin, "admin", membership.RoleAdmin)
|
||||||
|
|
||||||
|
// Admin mints a single-use invite fixing handle + role.
|
||||||
|
inv, err := admin.CreateInvite("dora", membership.RoleMember, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("admin CreateInvite: %v", err)
|
||||||
|
}
|
||||||
|
if len(inv.Token) != 64 || inv.ExpiresAt == "" {
|
||||||
|
t.Fatalf("invite malformed: %+v", inv)
|
||||||
|
}
|
||||||
|
if inv.Handle != "dora" || inv.Role != membership.RoleMember {
|
||||||
|
t.Fatalf("invite echo wrong: %+v", inv)
|
||||||
|
}
|
||||||
|
|
||||||
|
// It appears among the pending invites.
|
||||||
|
pend, err := admin.ListInvites()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("admin ListInvites: %v", err)
|
||||||
|
}
|
||||||
|
if !containsToken(pend, inv.Token) {
|
||||||
|
t.Fatalf("minted invite not pending: %+v", pend)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A brand-new identity (NOT in the allowlist) redeems the invite via the
|
||||||
|
// UNSIGNED Register. We model its locally-generated keypair with a fresh
|
||||||
|
// identity and present its two public keys. Redeeming through this joiner
|
||||||
|
// client — which never registered and never seeded an admin — proves Register
|
||||||
|
// needs no admin signature; the bearer token is the sole authorization.
|
||||||
|
newID := mustIdentity(t)
|
||||||
|
signPub := hex.EncodeToString(newID.SignPub)
|
||||||
|
kexPub := hex.EncodeToString(newID.KexPub)
|
||||||
|
joiner, err := client.New(h.natsURL, h.ctrlURL, newID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect joiner: %v", err)
|
||||||
|
}
|
||||||
|
defer joiner.Close()
|
||||||
|
if err := joiner.Register(inv.Token, signPub, kexPub); err != nil {
|
||||||
|
t.Fatalf("joiner Register: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin now sees dora in the allowlist with the invite's handle/role.
|
||||||
|
users, err := admin.ListUsers()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("admin ListUsers: %v", err)
|
||||||
|
}
|
||||||
|
row, ok := findUserInfo(users, signPub)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("registered dora missing from allowlist: %+v", users)
|
||||||
|
}
|
||||||
|
if row.Handle != "dora" || row.Role != membership.RoleMember || row.Status != membership.StatusActive {
|
||||||
|
t.Fatalf("dora row wrong: %+v", row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single-use: redeeming again is an error.
|
||||||
|
if err := joiner.Register(inv.Token, signPub, kexPub); err == nil {
|
||||||
|
t.Fatalf("second Register should error (used token)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin hard-deletes dora; she vanishes from the allowlist entirely.
|
||||||
|
if err := admin.DeleteUser(signPub); err != nil {
|
||||||
|
t.Fatalf("admin DeleteUser: %v", err)
|
||||||
|
}
|
||||||
|
users, err = admin.ListUsers()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("admin ListUsers after delete: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := findUserInfo(users, signPub); ok {
|
||||||
|
t.Fatalf("hard-deleted dora must NOT appear: %+v", users)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsToken(invites []client.InviteInfo, token string) bool {
|
||||||
|
for _, i := range invites {
|
||||||
|
if i.Token == token {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
package membership
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
cs "fn-registry/functions/cybersecurity"
|
||||||
|
)
|
||||||
|
|
||||||
|
// postRegister posts an UNSIGNED /register request (the wallet-model join: the
|
||||||
|
// new identity is not yet in the allowlist, so it cannot sign). It returns the
|
||||||
|
// status and body so a test can assert the precise code.
|
||||||
|
func postRegister(t *testing.T, h *authHarness, body registerReq) (int, string) {
|
||||||
|
t.Helper()
|
||||||
|
b, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal register: %v", err)
|
||||||
|
}
|
||||||
|
resp, err := http.Post(h.ts.URL+"/register", "application/json", bytes.NewReader(b))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("post register: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
rb, _ := io.ReadAll(resp.Body)
|
||||||
|
return resp.StatusCode, string(rb)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInvitesHTTP_Golden is the end-to-end wallet-model flow over real HTTP:
|
||||||
|
// alice (admin) mints an invite, a brand-new identity redeems it UNSIGNED via
|
||||||
|
// /register, the user then appears in the admin allowlist, and a second redeem of
|
||||||
|
// the same token is rejected as used.
|
||||||
|
func TestInvitesHTTP_Golden(t *testing.T) {
|
||||||
|
h := newAuthHarness(t, AuthEnforce)
|
||||||
|
|
||||||
|
// Admin mints an invite.
|
||||||
|
var inv createInviteResp
|
||||||
|
code, body := signedJSON(t, h, "POST", "/invites",
|
||||||
|
createInviteReq{Handle: "newbie", Role: RoleMember}, h.alice, 1)
|
||||||
|
if code != http.StatusCreated {
|
||||||
|
t.Fatalf("admin create invite should be 201, got %d (%s)", code, body)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(body), &inv); err != nil {
|
||||||
|
t.Fatalf("decode invite: %v (%s)", err, body)
|
||||||
|
}
|
||||||
|
if len(inv.Token) != 64 || inv.ExpiresAt == "" {
|
||||||
|
t.Fatalf("invite token/expiry malformed: %+v", inv)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A brand-new identity redeems it WITHOUT any admin signature.
|
||||||
|
id, _ := cs.GenerateIdentity()
|
||||||
|
signPub := hex.EncodeToString(id.SignPub)
|
||||||
|
kexPub := hex.EncodeToString(id.KexPub)
|
||||||
|
if code, body := postRegister(t, h, registerReq{Token: inv.Token, SignPub: signPub, KexPub: kexPub}); code != http.StatusCreated {
|
||||||
|
t.Fatalf("register should be 201, got %d (%s)", code, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The user now appears in the admin allowlist with the invite's handle/role.
|
||||||
|
users := listUsers(t, h, 2)
|
||||||
|
row, ok := findUser(users, signPub)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("registered user missing from allowlist: %+v", users)
|
||||||
|
}
|
||||||
|
if row.Handle != "newbie" || row.Role != RoleMember || row.Status != StatusActive {
|
||||||
|
t.Fatalf("registered user row wrong: %+v", row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The invite is no longer pending.
|
||||||
|
if code, body := signedJSON(t, h, "GET", "/invites", nil, h.alice, 3); code == http.StatusOK {
|
||||||
|
var pend []inviteJSON
|
||||||
|
_ = json.Unmarshal([]byte(body), &pend)
|
||||||
|
for _, p := range pend {
|
||||||
|
if p.Token == inv.Token {
|
||||||
|
t.Fatalf("consumed invite should not be listed as pending: %+v", pend)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single-use: a second redeem of the same token is 409 used.
|
||||||
|
id2, _ := cs.GenerateIdentity()
|
||||||
|
if code, body := postRegister(t, h, registerReq{
|
||||||
|
Token: inv.Token, SignPub: hex.EncodeToString(id2.SignPub), KexPub: hex.EncodeToString(id2.KexPub),
|
||||||
|
}); code != http.StatusConflict {
|
||||||
|
t.Fatalf("second redeem should be 409, got %d (%s)", code, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInvitesHTTP_RegisterValidation covers /register input + state errors: an
|
||||||
|
// unknown token is 404, an expired token is 410, and malformed hex keys are 400 —
|
||||||
|
// each WITHOUT registering anything.
|
||||||
|
func TestInvitesHTTP_RegisterValidation(t *testing.T) {
|
||||||
|
h := newAuthHarness(t, AuthEnforce)
|
||||||
|
id, _ := cs.GenerateIdentity()
|
||||||
|
signPub := hex.EncodeToString(id.SignPub)
|
||||||
|
kexPub := hex.EncodeToString(id.KexPub)
|
||||||
|
|
||||||
|
// Unknown token -> 404.
|
||||||
|
if code, body := postRegister(t, h, registerReq{Token: "deadbeef", SignPub: signPub, KexPub: kexPub}); code != http.StatusNotFound {
|
||||||
|
t.Fatalf("unknown token should be 404, got %d (%s)", code, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Malformed sign_pub -> 400.
|
||||||
|
if code, body := postRegister(t, h, registerReq{Token: "x", SignPub: "abcd", KexPub: kexPub}); code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("malformed sign_pub should be 400, got %d (%s)", code, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Malformed kex_pub -> 400.
|
||||||
|
if code, body := postRegister(t, h, registerReq{Token: "x", SignPub: signPub, KexPub: "zzzz"}); code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("malformed kex_pub should be 400, got %d (%s)", code, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expired token -> 410. Mint via the admin API, then force its deadline past
|
||||||
|
// directly in the store (white-box).
|
||||||
|
var inv createInviteResp
|
||||||
|
_, body := signedJSON(t, h, "POST", "/invites", createInviteReq{Handle: "late", Role: RoleMember}, h.alice, 1)
|
||||||
|
if err := json.Unmarshal([]byte(body), &inv); err != nil {
|
||||||
|
t.Fatalf("decode invite: %v (%s)", err, body)
|
||||||
|
}
|
||||||
|
ss, ok := h.store.(*sqliteStore)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected sqliteStore harness")
|
||||||
|
}
|
||||||
|
past := time.Now().Add(-time.Hour).UTC().Format(time.RFC3339Nano)
|
||||||
|
if _, err := ss.db.Exec(`UPDATE invites SET expires_at = ? WHERE token = ?`, past, inv.Token); err != nil {
|
||||||
|
t.Fatalf("force expire: %v", err)
|
||||||
|
}
|
||||||
|
if code, rb := postRegister(t, h, registerReq{Token: inv.Token, SignPub: signPub, KexPub: kexPub}); code != http.StatusGone {
|
||||||
|
t.Fatalf("expired token should be 410, got %d (%s)", code, rb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInvitesHTTP_NonAdminForbidden is the security spine for the new endpoints:
|
||||||
|
// a REGISTERED non-admin (bob) is denied on POST /invites, GET /invites,
|
||||||
|
// DELETE /invites/{token}, and DELETE /users/{signpub} — each a 403 by role.
|
||||||
|
func TestInvitesHTTP_NonAdminForbidden(t *testing.T) {
|
||||||
|
h := newAuthHarness(t, AuthEnforce)
|
||||||
|
|
||||||
|
bob, _ := cs.GenerateIdentity()
|
||||||
|
register(t, h, bob, "bob") // role member
|
||||||
|
bobPub := hex.EncodeToString(bob.SignPub)
|
||||||
|
|
||||||
|
checks := []struct {
|
||||||
|
name string
|
||||||
|
method string
|
||||||
|
path string
|
||||||
|
body any
|
||||||
|
}{
|
||||||
|
{"create invite", "POST", "/invites", createInviteReq{Handle: "x", Role: RoleMember}},
|
||||||
|
{"list invites", "GET", "/invites", nil},
|
||||||
|
{"cancel invite", "DELETE", "/invites/sometoken", nil},
|
||||||
|
{"delete user", "DELETE", "/users/" + bobPub, nil},
|
||||||
|
}
|
||||||
|
for i, c := range checks {
|
||||||
|
code, body := signedJSON(t, h, c.method, c.path, c.body, bob, i+1)
|
||||||
|
if code != http.StatusForbidden {
|
||||||
|
t.Fatalf("non-admin %s should be 403, got %d (%s)", c.name, code, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUsersHTTP_HardDelete proves DELETE /users/{signpub} purges a user (distinct
|
||||||
|
// from revoke's status flip): alice adds carol, hard-deletes her, and carol then
|
||||||
|
// vanishes from the allowlist entirely (not merely flagged revoked).
|
||||||
|
func TestUsersHTTP_HardDelete(t *testing.T) {
|
||||||
|
h := newAuthHarness(t, AuthEnforce)
|
||||||
|
|
||||||
|
carol, _ := cs.GenerateIdentity()
|
||||||
|
carolPub := hex.EncodeToString(carol.SignPub)
|
||||||
|
if code, body := signedJSON(t, h, "POST", "/users",
|
||||||
|
addUserReq{SignPub: carolPub, Handle: "carol", Role: RoleMember}, h.alice, 1); code != http.StatusCreated {
|
||||||
|
t.Fatalf("add carol should be 201, got %d (%s)", code, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hard-delete carol.
|
||||||
|
if code, body := signedJSON(t, h, "DELETE", "/users/"+carolPub, nil, h.alice, 2); code != http.StatusOK {
|
||||||
|
t.Fatalf("hard-delete carol should be 200, got %d (%s)", code, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// She is gone entirely — not present in the list at all (vs revoke, which
|
||||||
|
// keeps her as status=revoked).
|
||||||
|
users := listUsers(t, h, 3)
|
||||||
|
if _, ok := findUser(users, carolPub); ok {
|
||||||
|
t.Fatalf("hard-deleted carol must NOT appear in the allowlist: %+v", users)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deleting her again is a 404.
|
||||||
|
if code, body := signedJSON(t, h, "DELETE", "/users/"+carolPub, nil, h.alice, 4); code != http.StatusNotFound {
|
||||||
|
t.Fatalf("re-delete should be 404, got %d (%s)", code, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
package membership
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
cs "fn-registry/functions/cybersecurity"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newIDHex generates a fresh identity and returns its signing and key-exchange
|
||||||
|
// public keys as lowercase hex — the two keys a client presents to /register.
|
||||||
|
func newIDHex(t *testing.T) (signPub, kexPub string) {
|
||||||
|
t.Helper()
|
||||||
|
id, err := cs.GenerateIdentity()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("identity: %v", err)
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(id.SignPub), hex.EncodeToString(id.KexPub)
|
||||||
|
}
|
||||||
|
|
||||||
|
// inviteSuite drives the full invite lifecycle against any Store backend: mint,
|
||||||
|
// look up, redeem (which registers the user), reject a second redeem (single-use)
|
||||||
|
// and a non-existent token, reject an expired token (forced past via the
|
||||||
|
// backend-specific forceExpire closure), and hard-delete a user. It is shared by
|
||||||
|
// the SQLite and JetStream tests so both backends prove identical behavior.
|
||||||
|
func inviteSuite(t *testing.T, s Store, forceExpire func(token string)) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Mint an invite fixing handle + role.
|
||||||
|
inv, err := s.CreateInvite("alice-new", RoleMember, 3600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateInvite: %v", err)
|
||||||
|
}
|
||||||
|
if len(inv.Token) != 64 {
|
||||||
|
t.Fatalf("token should be 64 hex chars, got %d (%q)", len(inv.Token), inv.Token)
|
||||||
|
}
|
||||||
|
if inv.Used {
|
||||||
|
t.Fatalf("fresh invite must not be used")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetInvite round-trips it.
|
||||||
|
got, err := s.GetInvite(inv.Token)
|
||||||
|
if err != nil || got.Handle != "alice-new" || got.Role != RoleMember {
|
||||||
|
t.Fatalf("GetInvite mismatch: %+v err=%v", got, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redeem it: the presented signing key joins the allowlist with the invite's
|
||||||
|
// handle and role.
|
||||||
|
signPub, kexPub := newIDHex(t)
|
||||||
|
if err := s.ConsumeInvite(inv.Token, signPub, kexPub); err != nil {
|
||||||
|
t.Fatalf("ConsumeInvite (golden): %v", err)
|
||||||
|
}
|
||||||
|
u, err := s.GetUser(signPub)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUser after register: %v", err)
|
||||||
|
}
|
||||||
|
if u.Handle != "alice-new" || u.Role != RoleMember || u.Status != StatusActive {
|
||||||
|
t.Fatalf("registered user wrong: %+v", u)
|
||||||
|
}
|
||||||
|
if !s.IsAuthorized(signPub) {
|
||||||
|
t.Fatalf("registered user should be authorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single-use: redeeming the same token again (even with a different identity)
|
||||||
|
// is rejected as used.
|
||||||
|
sp2, kp2 := newIDHex(t)
|
||||||
|
if err := s.ConsumeInvite(inv.Token, sp2, kp2); !errors.Is(err, ErrInviteUsed) {
|
||||||
|
t.Fatalf("second redeem should be ErrInviteUsed, got %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.GetUser(sp2); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("second identity must NOT be registered, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown token is ErrNotFound.
|
||||||
|
if err := s.ConsumeInvite("deadbeef", "ab", "cd"); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("unknown token should be ErrNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expired invite: mint one, force its deadline into the past, redeem -> rejected.
|
||||||
|
exp, err := s.CreateInvite("late", RoleMember, 3600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateInvite expired: %v", err)
|
||||||
|
}
|
||||||
|
forceExpire(exp.Token)
|
||||||
|
sp3, kp3 := newIDHex(t)
|
||||||
|
if err := s.ConsumeInvite(exp.Token, sp3, kp3); !errors.Is(err, ErrInviteExpired) {
|
||||||
|
t.Fatalf("expired redeem should be ErrInviteExpired, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CancelInvite removes a pending invite; redeeming it afterward is ErrNotFound.
|
||||||
|
canc, err := s.CreateInvite("cancelme", RoleMember, 3600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateInvite cancel: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.CancelInvite(canc.Token); err != nil {
|
||||||
|
t.Fatalf("CancelInvite: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.ConsumeInvite(canc.Token, sp3, kp3); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("cancelled invite redeem should be ErrNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hard-delete the registered user: it disappears from the allowlist entirely.
|
||||||
|
if err := s.DeleteUser(signPub); err != nil {
|
||||||
|
t.Fatalf("DeleteUser: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.GetUser(signPub); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("deleted user should be ErrNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
if s.IsAuthorized(signPub) {
|
||||||
|
t.Fatalf("deleted user must not be authorized")
|
||||||
|
}
|
||||||
|
// Deleting an unknown key is ErrNotFound.
|
||||||
|
if err := s.DeleteUser(signPub); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("re-delete should be ErrNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInvitesSQLite runs the suite against the default SQLite backend, forcing
|
||||||
|
// expiry with a direct UPDATE on the embedded DB (white-box, same package).
|
||||||
|
func TestInvitesSQLite(t *testing.T) {
|
||||||
|
s := openTestStore(t)
|
||||||
|
inviteSuite(t, s, func(token string) {
|
||||||
|
past := time.Now().Add(-time.Hour).UTC().Format(time.RFC3339Nano)
|
||||||
|
if _, err := s.db.Exec(`UPDATE invites SET expires_at = ? WHERE token = ?`, past, token); err != nil {
|
||||||
|
t.Fatalf("force expire: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInvitesJetStream runs the same suite against the replicated KV backend,
|
||||||
|
// forcing expiry by re-Putting the invite JSON with a past deadline.
|
||||||
|
func TestInvitesJetStream(t *testing.T) {
|
||||||
|
s, _, _ := newKVStore(t)
|
||||||
|
inviteSuite(t, s, func(token string) {
|
||||||
|
inv, err := s.GetInvite(token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("force expire: get invite: %v", err)
|
||||||
|
}
|
||||||
|
inv.ExpiresAt = time.Now().Add(-time.Hour).UTC().Format(time.RFC3339Nano)
|
||||||
|
b, err := json.Marshal(inv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("force expire: marshal: %v", err)
|
||||||
|
}
|
||||||
|
ctx, cancel := s.ctx()
|
||||||
|
defer cancel()
|
||||||
|
if _, err := s.invites.Put(ctx, token, b); err != nil {
|
||||||
|
t.Fatalf("force expire: put: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConsumeInvite_AlreadyRegistered covers the burn-on-claim edge: redeeming a
|
||||||
|
// valid invite with a signing key that is already registered surfaces
|
||||||
|
// ErrUserExists AND spends the invite (both backends behave identically).
|
||||||
|
func TestConsumeInvite_AlreadyRegistered(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
open func(t *testing.T) Store
|
||||||
|
}{
|
||||||
|
{"sqlite", func(t *testing.T) Store { return openTestStore(t) }},
|
||||||
|
{"jetstream", func(t *testing.T) Store { s, _, _ := newKVStore(t); return s }},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
s := tc.open(t)
|
||||||
|
signPub, kexPub := newIDHex(t)
|
||||||
|
if err := s.AddUser(signPub, "existing", RoleMember); err != nil {
|
||||||
|
t.Fatalf("seed user: %v", err)
|
||||||
|
}
|
||||||
|
inv, err := s.CreateInvite("dup", RoleMember, 3600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateInvite: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.ConsumeInvite(inv.Token, signPub, kexPub); !errors.Is(err, ErrUserExists) {
|
||||||
|
t.Fatalf("redeem with registered key should be ErrUserExists, got %v", err)
|
||||||
|
}
|
||||||
|
// The invite is spent (burn-on-claim): a fresh identity cannot reuse it.
|
||||||
|
sp2, kp2 := newIDHex(t)
|
||||||
|
if err := s.ConsumeInvite(inv.Token, sp2, kp2); !errors.Is(err, ErrInviteUsed) {
|
||||||
|
t.Fatalf("invite should be spent after a burned claim, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,7 @@ const (
|
|||||||
bucketByMember = "UNIBUS_rooms_by_member"
|
bucketByMember = "UNIBUS_rooms_by_member"
|
||||||
bucketRoomKeys = "UNIBUS_room_keys"
|
bucketRoomKeys = "UNIBUS_room_keys"
|
||||||
bucketUsers = "UNIBUS_users"
|
bucketUsers = "UNIBUS_users"
|
||||||
|
bucketInvites = "UNIBUS_invites"
|
||||||
defaultKVOpTime = 5 * time.Second
|
defaultKVOpTime = 5 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -71,6 +72,7 @@ type jetstreamStore struct {
|
|||||||
byMember jetstream.KeyValue
|
byMember jetstream.KeyValue
|
||||||
keys jetstream.KeyValue
|
keys jetstream.KeyValue
|
||||||
users jetstream.KeyValue
|
users jetstream.KeyValue
|
||||||
|
invites jetstream.KeyValue
|
||||||
opTimeout time.Duration
|
opTimeout time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +110,7 @@ func OpenJetStream(js jetstream.JetStream, cfg JetStreamConfig) (Store, error) {
|
|||||||
{bucketByMember, &s.byMember},
|
{bucketByMember, &s.byMember},
|
||||||
{bucketRoomKeys, &s.keys},
|
{bucketRoomKeys, &s.keys},
|
||||||
{bucketUsers, &s.users},
|
{bucketUsers, &s.users},
|
||||||
|
{bucketInvites, &s.invites},
|
||||||
} {
|
} {
|
||||||
var kv jetstream.KeyValue
|
var kv jetstream.KeyValue
|
||||||
var lastErr error
|
var lastErr error
|
||||||
@@ -498,6 +501,28 @@ func (s *jetstreamStore) RevokeUser(signPub string) error {
|
|||||||
return nil
|
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
|
// IsAuthorized reports whether signPub is an active bus user. Any backend error
|
||||||
// (including a KV quorum loss or timeout) yields false: fail closed.
|
// (including a KV quorum loss or timeout) yields false: fail closed.
|
||||||
func (s *jetstreamStore) IsAuthorized(signPub string) bool {
|
func (s *jetstreamStore) IsAuthorized(signPub string) bool {
|
||||||
@@ -533,6 +558,173 @@ func (s *jetstreamStore) HasAdmin() bool {
|
|||||||
return false
|
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) ---------------------
|
// ---- snapshot import / export (issue 0003c migration) ---------------------
|
||||||
|
|
||||||
// importSnapshot writes a full Snapshot into the KV buckets, preserving each
|
// importSnapshot writes a full Snapshot into the KV buckets, preserving each
|
||||||
|
|||||||
@@ -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);
|
||||||
+233
-7
@@ -144,9 +144,11 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
// Per-IP rate limit runs first, ahead of auth and body reads, so a flood is
|
// Per-IP rate limit runs first, ahead of auth and body reads, so a flood is
|
||||||
// shed at the cheapest possible point. The health probe is exempt so liveness
|
// shed at the cheapest possible point. ONLY the health probe is exempt so
|
||||||
// checks are never throttled.
|
// liveness checks are never throttled — note this is isRateExempt, NOT
|
||||||
if !isAuthExempt(r) && !s.limiter.allow(clientIP(r), now) {
|
// isAuthExempt: POST /register is auth-exempt (no admin signature) but stays
|
||||||
|
// rate-limited, since it is the one un-signed path that mutates the allowlist.
|
||||||
|
if !isRateExempt(r) && !s.limiter.allow(clientIP(r), now) {
|
||||||
writeErr(w, http.StatusTooManyRequests, "rate limit exceeded")
|
writeErr(w, http.StatusTooManyRequests, "rate limit exceeded")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -308,13 +310,29 @@ func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) (string, b
|
|||||||
return pubHex, true
|
return pubHex, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// isAuthExempt lists requests that bypass control-plane auth even under enforce.
|
// isRateExempt lists requests that bypass the per-IP rate limiter. Only the
|
||||||
// Only the unauthenticated health probe qualifies: it carries no data and is
|
// health probe qualifies: a load balancer / systemd / smoke check polls it and
|
||||||
// needed by load balancers / smoke checks / systemd before any identity exists.
|
// must never be throttled. Everything else — including POST /register — is rate
|
||||||
func isAuthExempt(r *http.Request) bool {
|
// limited.
|
||||||
|
func isRateExempt(r *http.Request) bool {
|
||||||
return r.Method == http.MethodGet && r.URL.Path == "/healthz"
|
return r.Method == http.MethodGet && r.URL.Path == "/healthz"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isAuthExempt lists requests that bypass control-plane signature auth even under
|
||||||
|
// enforce. Two qualify:
|
||||||
|
// - GET /healthz: carries no data, needed before any identity exists.
|
||||||
|
// - POST /register: the wallet-model join path. The registering identity is not
|
||||||
|
// yet in the allowlist, so it CANNOT produce an accepted admin signature;
|
||||||
|
// authorization is the single-use bearer invite token, validated inside the
|
||||||
|
// handler (ConsumeInvite). It stays rate-limited (see isRateExempt) and
|
||||||
|
// strictly validates the hex keys before spending the token.
|
||||||
|
func isAuthExempt(r *http.Request) bool {
|
||||||
|
if r.Method == http.MethodGet && r.URL.Path == "/healthz" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return r.Method == http.MethodPost && r.URL.Path == "/register"
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) routes() {
|
func (s *Server) routes() {
|
||||||
s.mux.HandleFunc("GET /healthz", s.handleHealth)
|
s.mux.HandleFunc("GET /healthz", s.handleHealth)
|
||||||
s.mux.HandleFunc("POST /rooms", s.handleCreateRoom)
|
s.mux.HandleFunc("POST /rooms", s.handleCreateRoom)
|
||||||
@@ -333,6 +351,16 @@ func (s *Server) routes() {
|
|||||||
s.mux.HandleFunc("GET /users", s.handleListUsers)
|
s.mux.HandleFunc("GET /users", s.handleListUsers)
|
||||||
s.mux.HandleFunc("POST /users", s.handleAddUser)
|
s.mux.HandleFunc("POST /users", s.handleAddUser)
|
||||||
s.mux.HandleFunc("POST /users/{signpub}/revoke", s.handleRevokeUser)
|
s.mux.HandleFunc("POST /users/{signpub}/revoke", s.handleRevokeUser)
|
||||||
|
// Hard-delete (purge) a user — distinct from revoke (status flip). Admin-only.
|
||||||
|
s.mux.HandleFunc("DELETE /users/{signpub}", s.handleDeleteUser)
|
||||||
|
// Invites — the wallet-model account-creation path. The admin mints a
|
||||||
|
// single-use link (POST /invites, admin-only); the new user's client redeems
|
||||||
|
// it without an admin signature (POST /register, token-authorized). Listing
|
||||||
|
// and cancelling a pending invite are admin-only.
|
||||||
|
s.mux.HandleFunc("POST /invites", s.handleCreateInvite)
|
||||||
|
s.mux.HandleFunc("GET /invites", s.handleListInvites)
|
||||||
|
s.mux.HandleFunc("DELETE /invites/{token}", s.handleCancelInvite)
|
||||||
|
s.mux.HandleFunc("POST /register", s.handleRegister)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- wire types -----------------------------------------------------------
|
// ---- wire types -----------------------------------------------------------
|
||||||
@@ -431,6 +459,46 @@ type addUserReq struct {
|
|||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// createInviteReq is the POST /invites body (admin-only): the handle and role the
|
||||||
|
// future user will receive (fixed here, NOT chosen by the registering client) and
|
||||||
|
// an optional TTL in seconds (non-positive uses the 7-day default).
|
||||||
|
type createInviteReq struct {
|
||||||
|
Handle string `json:"handle"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
TTLSecs int `json:"ttl_secs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// createInviteResp is the POST /invites reply: the bearer token to put in the
|
||||||
|
// join link and its absolute expiry. The token is shown ONCE here; the admin
|
||||||
|
// copies the link immediately.
|
||||||
|
type createInviteResp struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
ExpiresAt string `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// inviteJSON is the wire representation of a pending invite on GET /invites. It
|
||||||
|
// omits the audit fields (used_*) because the listing is of pending invites only;
|
||||||
|
// used_at is carried so a client can render "expires in N".
|
||||||
|
type inviteJSON 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerReq is the POST /register body. It is the ONLY allowlist-mutating
|
||||||
|
// request that carries no admin signature: the bearer Token authorizes it. The
|
||||||
|
// client supplies its freshly-generated public keys (sign_pub = Ed25519 identity,
|
||||||
|
// kex_pub = X25519 key-exchange), both 64-hex. The handle and role come from the
|
||||||
|
// invite, never from this body — the client cannot escalate.
|
||||||
|
type registerReq struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
SignPub string `json:"sign_pub"`
|
||||||
|
KexPub string `json:"kex_pub"`
|
||||||
|
}
|
||||||
|
|
||||||
// ---- helpers --------------------------------------------------------------
|
// ---- helpers --------------------------------------------------------------
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||||
@@ -840,3 +908,161 @@ func (s *Server) handleRevokeUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
|
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleDeleteUser hard-deletes a bus user by signing key — the purge that the
|
||||||
|
// admin panel's "Eliminar" (permanent) action maps to, distinct from revoke's
|
||||||
|
// status flip. The row is removed entirely (no audit trail kept); use revoke when
|
||||||
|
// an auditable record must remain. Deleting an unknown key is a 404. Admin-only.
|
||||||
|
//
|
||||||
|
// Security note: like revoke, this does NOT special-case the last admin — an
|
||||||
|
// admin can delete the final admin and lock the HTTP user-management surface. The
|
||||||
|
// recovery seam is the local `membershipd user add` CLI (which re-seeds an admin
|
||||||
|
// directly against the store), the same chicken-egg breaker that seeds the first
|
||||||
|
// admin.
|
||||||
|
func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := s.requireAdmin(w, r); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
signPub := r.PathValue("signpub")
|
||||||
|
if err := ValidateSignPubHex(signPub); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.DeleteUser(signPub); err != nil {
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
writeErr(w, http.StatusNotFound, "no user with that key")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeServerErr(w, r, http.StatusInternalServerError, "internal error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- invite handlers ------------------------------------------------------
|
||||||
|
|
||||||
|
// handleCreateInvite mints a single-use registration invite. The handle and role
|
||||||
|
// are fixed here by the admin; the role is validated (admin|member, empty ->
|
||||||
|
// member) so an unknown role is a clean 400 rather than an opaque 500. The reply
|
||||||
|
// carries the bearer token and its expiry — the admin turns the token into the
|
||||||
|
// join link. Admin-only.
|
||||||
|
func (s *Server) handleCreateInvite(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := s.requireAdmin(w, r); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req createInviteReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "bad json: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Handle == "" {
|
||||||
|
writeErr(w, http.StatusBadRequest, "handle required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Role != "" && req.Role != RoleAdmin && req.Role != RoleMember {
|
||||||
|
writeErr(w, http.StatusBadRequest,
|
||||||
|
fmt.Sprintf("invalid role %q (want %q or %q)", req.Role, RoleAdmin, RoleMember))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inv, err := s.store.CreateInvite(req.Handle, req.Role, req.TTLSecs)
|
||||||
|
if err != nil {
|
||||||
|
writeServerErr(w, r, http.StatusInternalServerError, "internal error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, createInviteResp{Token: inv.Token, ExpiresAt: inv.ExpiresAt})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListInvites returns the PENDING invites (not yet used and not expired), so
|
||||||
|
// the admin panel shows only live links worth copying. Consumed/expired invites
|
||||||
|
// are filtered out here rather than at the store, which exposes the full set for
|
||||||
|
// other callers. Admin-only.
|
||||||
|
func (s *Server) handleListInvites(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := s.requireAdmin(w, r); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
invites, err := s.store.ListInvites()
|
||||||
|
if err != nil {
|
||||||
|
writeServerErr(w, r, http.StatusInternalServerError, "internal error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]inviteJSON, 0, len(invites))
|
||||||
|
for _, inv := range invites {
|
||||||
|
if inv.Used || inviteIsExpired(inv.ExpiresAt) {
|
||||||
|
continue // pending only
|
||||||
|
}
|
||||||
|
out = append(out, inviteJSON{
|
||||||
|
Token: inv.Token,
|
||||||
|
Handle: inv.Handle,
|
||||||
|
Role: inv.Role,
|
||||||
|
ExpiresAt: inv.ExpiresAt,
|
||||||
|
Used: inv.Used,
|
||||||
|
CreatedAt: inv.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCancelInvite cancels (hard-deletes) a pending invite, so an admin can
|
||||||
|
// revoke a link before it is redeemed. Cancelling an unknown token is a 404.
|
||||||
|
// Admin-only.
|
||||||
|
func (s *Server) handleCancelInvite(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := s.requireAdmin(w, r); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token := r.PathValue("token")
|
||||||
|
if token == "" {
|
||||||
|
writeErr(w, http.StatusBadRequest, "token required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.CancelInvite(token); err != nil {
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
writeErr(w, http.StatusNotFound, "no such invite")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeServerErr(w, r, http.StatusInternalServerError, "internal error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "cancelled"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRegister redeems an invite: the wallet-model join path. It is auth-exempt
|
||||||
|
// (no admin signature; see isAuthExempt) but rate-limited and strictly validated.
|
||||||
|
// The client presents the single-use token plus its freshly-generated public keys
|
||||||
|
// (sign_pub Ed25519, kex_pub X25519). Both keys are validated as 64-hex BEFORE the
|
||||||
|
// token is spent, the handle and role come from the invite (never this body), and
|
||||||
|
// ConsumeInvite enforces single-use atomically. Errors map to precise codes so a
|
||||||
|
// client can tell "unknown" from "used" from "expired".
|
||||||
|
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req registerReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "bad json: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Token == "" {
|
||||||
|
writeErr(w, http.StatusBadRequest, "token required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := ValidateSignPubHex(req.SignPub); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := ValidateKexPubHex(req.KexPub); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := s.store.ConsumeInvite(req.Token, req.SignPub, req.KexPub)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]string{"status": "registered"})
|
||||||
|
case errors.Is(err, ErrNotFound):
|
||||||
|
writeErr(w, http.StatusNotFound, "invalid or unknown invite token")
|
||||||
|
case errors.Is(err, ErrInviteUsed):
|
||||||
|
writeErr(w, http.StatusConflict, "invite already used")
|
||||||
|
case errors.Is(err, ErrInviteExpired):
|
||||||
|
writeErr(w, http.StatusGone, "invite expired")
|
||||||
|
case errors.Is(err, ErrUserExists):
|
||||||
|
writeErr(w, http.StatusConflict, "identity already registered")
|
||||||
|
default:
|
||||||
|
writeServerErr(w, r, http.StatusInternalServerError, "internal error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -80,9 +80,23 @@ type Store interface {
|
|||||||
GetUser(signPub string) (User, error)
|
GetUser(signPub string) (User, error)
|
||||||
ListUsers() ([]User, error)
|
ListUsers() ([]User, error)
|
||||||
RevokeUser(signPub string) 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
|
IsAuthorized(signPub string) bool
|
||||||
HasAdmin() 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.
|
// Lifecycle.
|
||||||
Close() error
|
Close() error
|
||||||
}
|
}
|
||||||
|
|||||||
+49
-2
@@ -53,6 +53,23 @@ func ValidateSignPubHex(signPub string) error {
|
|||||||
return nil
|
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
|
// normalizeSignPub lowercases the hex key so lookups are case-insensitive: the
|
||||||
// primary key is stored lowercase and every query normalizes its input the same
|
// primary key is stored lowercase and every query normalizes its input the same
|
||||||
// way, so a caller passing uppercase hex still matches.
|
// way, so a caller passing uppercase hex still matches.
|
||||||
@@ -90,8 +107,10 @@ func (s *sqliteStore) AddUser(signPub, handle, role string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUser returns the user with the given signing public key. It returns
|
// GetUser returns the user with the given signing public key. A miss returns
|
||||||
// sql.ErrNoRows (wrapped) when there is no such user.
|
// 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) {
|
func (s *sqliteStore) GetUser(signPub string) (User, error) {
|
||||||
signPub = normalizeSignPub(signPub)
|
signPub = normalizeSignPub(signPub)
|
||||||
var u User
|
var u User
|
||||||
@@ -101,6 +120,9 @@ func (s *sqliteStore) GetUser(signPub string) (User, error) {
|
|||||||
signPub,
|
signPub,
|
||||||
).Scan(&u.SignPub, &u.Handle, &u.Role, &u.Status, &u.CreatedAt, &revoked)
|
).Scan(&u.SignPub, &u.Handle, &u.Role, &u.Status, &u.CreatedAt, &revoked)
|
||||||
if err != nil {
|
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)
|
return User{}, fmt.Errorf("membership: get user %q: %w", signPub, err)
|
||||||
}
|
}
|
||||||
u.RevokedAt = revoked.String
|
u.RevokedAt = revoked.String
|
||||||
@@ -153,6 +175,31 @@ func (s *sqliteStore) RevokeUser(signPub string) error {
|
|||||||
return nil
|
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
|
// IsAuthorized reports whether signPub belongs to an active (non-revoked) bus
|
||||||
// user. It is the single authorization predicate consulted by both the control
|
// user. It is the single authorization predicate consulted by both the control
|
||||||
// plane (HTTP request middleware) and the data plane (NATS nkey authenticator),
|
// plane (HTTP request middleware) and the data plane (NATS nkey authenticator),
|
||||||
|
|||||||
Reference in New Issue
Block a user