2 Commits

Author SHA1 Message Date
egutierrez 4994ea1483 feat(web): wallet join/recover/login (BIP39 seed identity)
Add the device-local wallet onboarding to the SPA. The user's identity
is derived deterministically from a 12-word BIP39 mnemonic and lives on
the device; the browser never signs, never talks NATS, and never sends
the seed to the server.

Wallet layer (web/src/wallet/):
- derive.ts: deterministic identity from a mnemonic. seed = BIP39 seed,
  then HKDF-SHA256 domain-separated into an Ed25519 signing key
  (info "unibus-sign-v1") and an X25519 key-exchange key (info
  "unibus-kex-v1"). The same mnemonic always yields the same sign_pub,
  which is what makes recovery possible without admin intervention. The
  four halves match cs.Identity on the Go side exactly.
- bip39.ts: thin wrappers over @scure/bip39 (generate, validate,
  normalize) so the checksum logic stays in the audited library.
- crypto.ts: at-rest encryption of the private key with WebCrypto only
  (PBKDF2-SHA256 210k iters -> AES-256-GCM). The password never leaves
  the device and only protects the local key copy.
- store.ts: IndexedDB persistence of the encrypted identity (private key
  encrypted; public halves + handle in the clear for display).
- account.ts: saveAndOpen / unlockAndOpen / localIdentity compose the
  primitives with the gateway session API.

Screens:
- Welcome: choose invite link or recover-with-seed on an empty device.
- Join: generate seed, show it once behind an acknowledge gate, confirm
  3 random words, set a local password, register the PUBLIC key with the
  bus via the invite token, then open the session.
- Recover: paste the 12 words, validate, show the reconstructed sign_pub,
  set a new local password, open the session. No register (the identity
  is already in the allowlist).
- WalletLogin: unlock the device's stored identity with the password.
- AuthShell: shared card/header for all pre-chat screens.
- App.tsx: route between join / welcome / login / recover / chat based on
  the invite link, a live gateway session, and any stored identity.

api.ts/types.ts: add register() and session() against the gateway
contract; vite dev server on :5183.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:21:50 +02:00
egutierrez 7d93d550d1 feat(webgw): per-user wallet sessions + invite register
Add the gateway backend for the wallet onboarding flow so each browser
session carries its OWN bus identity instead of sharing the single
operator client.

- POST /api/session (session.go): the browser hands its full wallet
  keypair (unlocked from the local encrypted key, over TLS) and the
  gateway spins up a dedicated bus client that acts AS that user. The
  private key lives only in process memory for the life of the session
  and is dropped on logout/shutdown. identityFromHex enforces the exact
  key sizes (sign_pub 32, sign_priv 64, kex_pub 32, kex_priv 32) that
  match cs.Identity on the Go side.
- POST /api/register (register.go): unauthenticated onboarding gated by
  a one-shot invite token. Validates the two PUBLIC key halves, then
  either consumes a configured --mock-tokens invite (local testing) or
  proxies to the bus POST /register (--register-url, bus >= 0.12.0). The
  handle/role come from the invite, never from the client.
- server.go: sessions move from a token->time map to a sessionStore of
  per-user *session records; auth() now resolves the session and passes
  its gateway to each handler. The legacy operator passphrase login
  (POST /api/login) is kept, bound to the shared operator gateway.
- main.go: build a busTemplate config that wallet sessions clone with
  their own Identity; wire --register-url / --mock-tokens.
- webgw_test.go: identity-size validation, hex-key validation, mock
  token parsing, and single-use register (201 then 409) using a fixed
  browser-derived wallet vector.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:21:33 +02:00
22 changed files with 1870 additions and 94 deletions
+4
View File
@@ -12,5 +12,9 @@ worker.id
/membershipd
/worker
/chat
/webgw
*.exe
registry.db
# Local session infra (machine-specific absolute paths; never distributed).
.mcp.json
+24 -5
View File
@@ -50,8 +50,10 @@ func main() {
caPath = flag.String("ca", "", "bus CA cert path; set to talk TLS+nkey to a secured bus (empty = plaintext dev)")
identityFile = flag.String("identity-file", "", "path to the operator identity JSON file (0600). Mutually exclusive with --identity-pass")
identityPass = flag.String("identity-pass", "", "pass(1) entry holding the operator identity JSON, e.g. unibus/operator-identity")
unlockPass = flag.String("unlock-pass", "", "literal passphrase the browser must send to unlock a session (dev). Prefer --unlock-pass-entry")
unlockEntry = flag.String("unlock-pass-entry", "unibus/admin-panel-password", "pass(1) entry holding the unlock passphrase (used when --unlock-pass is empty)")
unlockPass = flag.String("unlock-pass", "", "literal passphrase the browser must send to unlock a LEGACY operator session (dev). Prefer --unlock-pass-entry")
unlockEntry = flag.String("unlock-pass-entry", "unibus/admin-panel-password", "pass(1) entry holding the operator unlock passphrase (used when --unlock-pass is empty)")
registerURL = flag.String("register-url", "", "bus POST /register URL for wallet onboarding. Empty = derive from --ctrl-url (<ctrl-url>/register)")
mockTokens = flag.String("mock-tokens", "", "DEV ONLY: comma-separated one-shot invite tokens for local testing, 'token=handle:role'. Empty in production (real invites come from the bus). Example: demo=demo:member")
webDir = flag.String("web-dir", "", "OPTIONAL path to the built SPA (web/dist) to serve. Empty = API only (use vite dev server)")
)
flag.Parse()
@@ -77,19 +79,34 @@ func main() {
resolvedWebDir := resolveWebDir(*webDir)
gw, err := newGateway(gatewayConfig{
// busTemplate is the connection config every bus client uses. The operator
// gateway uses it as-is; each wallet session clones it and overrides Identity
// with the logged-in user's keypair.
busTemplate := gatewayConfig{
Identity: id,
NatsURL: *natsURL,
CtrlURL: *ctrlURL,
CtrlURLs: splitCSV(*ctrlURLs),
NatsURLs: splitCSV(*natsURLs),
CAPath: *caPath,
})
}
gw, err := newGateway(busTemplate)
if err != nil {
log.Fatalf("%v", err)
}
defer gw.Close()
// Wallet onboarding backend: POST /api/register targets the bus's /register
// (added by the user-accounts work). When --register-url is empty we derive it
// from --ctrl-url; --mock-tokens supplies one-shot invites for local testing
// before that endpoint is deployed.
regURL := *registerURL
if regURL == "" {
regURL = strings.TrimRight(*ctrlURL, "/") + "/register"
}
registrar := newRegistrar(regURL, *mockTokens)
log.Printf("operator endpoint: %s", gw.endpoint)
log.Printf("control plane: %s (+%d failover)", *ctrlURL, len(splitCSV(*ctrlURLs)))
tls := "OFF (plaintext dev)"
@@ -103,7 +120,9 @@ func main() {
log.Printf("API only (no --web-dir): use the vite dev server with a /api+stream proxy")
}
srv := newServer(gw, unlock, resolvedWebDir)
log.Printf("wallet register: %s (mock tokens: %d)", regURL, mockTokenCount(*mockTokens))
srv := newServer(gw, busTemplate, registrar, unlock, resolvedWebDir)
addr := *bind + ":" + *port
httpSrv := &http.Server{
Addr: addr,
+193
View File
@@ -0,0 +1,193 @@
package main
import (
"bytes"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
)
// registerReq is the POST /api/register body. It mirrors the bus contract exactly
// (token + the two PUBLIC key halves, each 64 hex chars). The private key never
// appears here — registration only publishes the public identity. The handle and
// role are NOT accepted from the client; they are fixed by the invite the token
// belongs to (no privilege escalation).
type registerReq struct {
Token string `json:"token"`
SignPub string `json:"sign_pub"`
KexPub string `json:"kex_pub"`
}
// registerResp is what we return to the browser on success. The bus's /register
// (issue: user-accounts) decides handle/role from the invite; in mock mode the
// gateway echoes the configured pair so the SPA can greet the new user.
type registerResp struct {
Handle string `json:"handle"`
Role string `json:"role"`
}
// registrar fulfils POST /api/register. It targets the bus's POST /register
// endpoint (added by the user-accounts work, bus >= 0.12.0). Until that endpoint
// is rolled out, a built-in mock validates against a configured set of one-shot
// tokens so the whole wallet flow is testable locally. Mock tokens are checked
// first; anything else is proxied to the real bus when --register-url is set.
type registrar struct {
mu sync.Mutex
registerURL string // bus POST /register; empty => mock-only
httpc *http.Client // for proxying to the bus
mockTokens map[string]*mockToken // configured one-shot invites for local testing
}
// mockToken is a local stand-in for a bus invite: a token that maps to a fixed
// handle+role and can be consumed exactly once.
type mockToken struct {
handle string
role string
used bool
}
// newRegistrar parses the --mock-tokens spec ("tok=handle:role,tok2=h2:role2")
// and configures the optional proxy target.
func newRegistrar(registerURL, mockSpec string) *registrar {
r := &registrar{
registerURL: strings.TrimSpace(registerURL),
httpc: &http.Client{Timeout: 10 * time.Second},
mockTokens: map[string]*mockToken{},
}
for _, part := range strings.Split(mockSpec, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
// tok=handle:role (role optional, defaults to member)
eq := strings.IndexByte(part, '=')
if eq < 0 {
continue
}
tok := strings.TrimSpace(part[:eq])
hr := strings.TrimSpace(part[eq+1:])
handle, role := hr, "member"
if c := strings.IndexByte(hr, ':'); c >= 0 {
handle, role = strings.TrimSpace(hr[:c]), strings.TrimSpace(hr[c+1:])
}
if tok != "" && handle != "" {
r.mockTokens[tok] = &mockToken{handle: handle, role: role}
}
}
return r
}
// mockTokenCount counts configured mock tokens in a --mock-tokens spec (for the
// startup log line).
func mockTokenCount(spec string) int {
n := 0
for _, part := range strings.Split(spec, ",") {
if p := strings.TrimSpace(part); p != "" && strings.ContainsRune(p, '=') {
n++
}
}
return n
}
// validHexKey reports whether s is exactly 64 lowercase/uppercase hex chars (a
// 32-byte key). Both sign_pub and kex_pub are 32-byte keys.
func validHexKey(s string) bool {
if len(s) != 64 {
return false
}
_, err := hex.DecodeString(s)
return err == nil
}
// handleRegister validates the keys and consumes the token. Order of resolution:
// 1. strict validation of the public keys (defends both mock and proxy paths);
// 2. mock token (one-shot) if configured;
// 3. proxy to the bus /register if --register-url is set;
// 4. otherwise reject with a clear error.
func (s *server) handleRegister(w http.ResponseWriter, r *http.Request) {
var req registerReq
if !decode(w, r, &req) {
return
}
req.Token = strings.TrimSpace(req.Token)
if req.Token == "" {
writeErr(w, http.StatusBadRequest, "token required")
return
}
if !validHexKey(req.SignPub) {
writeErr(w, http.StatusBadRequest, "sign_pub must be 64 hex chars (32 bytes)")
return
}
if !validHexKey(req.KexPub) {
writeErr(w, http.StatusBadRequest, "kex_pub must be 64 hex chars (32 bytes)")
return
}
reg := s.registrar
// 2) mock one-shot token.
reg.mu.Lock()
mt, isMock := reg.mockTokens[req.Token]
if isMock {
if mt.used {
reg.mu.Unlock()
writeErr(w, http.StatusConflict, "invite already used")
return
}
mt.used = true
handle, role := mt.handle, mt.role
reg.mu.Unlock()
writeJSON(w, http.StatusCreated, registerResp{Handle: handle, Role: role})
return
}
reg.mu.Unlock()
// 3) proxy to the real bus /register when configured.
if reg.registerURL != "" {
s.proxyRegister(w, req)
return
}
// 4) no mock match, no proxy target.
writeErr(w, http.StatusBadRequest, "invalid or unknown token (and no bus /register configured)")
}
// proxyRegister forwards the registration to the bus's POST /register. The bus
// validates the invite (existence, not-used, not-expired) and adds the public
// identity to the allowlist with the invite's handle+role. This is unsigned by
// design: the TOKEN authorizes the call, not an admin signature.
func (s *server) proxyRegister(w http.ResponseWriter, req registerReq) {
body, _ := json.Marshal(req)
resp, err := s.registrar.httpc.Post(
s.registrar.registerURL,
"application/json",
bytes.NewReader(body),
)
if err != nil {
writeErr(w, http.StatusBadGateway, "bus register unreachable: "+err.Error())
return
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
// On success, try to pass through the bus's handle/role if it returned them;
// otherwise a bare 201 is still success.
if resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusOK {
var rr registerResp
_ = json.Unmarshal(raw, &rr)
writeJSON(w, http.StatusCreated, rr)
return
}
// Forward the bus's error verbatim where possible.
msg := strings.TrimSpace(string(raw))
if msg == "" {
msg = fmt.Sprintf("bus register failed (HTTP %d)", resp.StatusCode)
}
writeErr(w, resp.StatusCode, msg)
}
+81 -55
View File
@@ -9,7 +9,6 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"time"
)
@@ -19,28 +18,37 @@ import (
// authenticate the stream. It is HttpOnly so page JS can never read the token.
const sessionCookie = "unibus_session"
// server is the gateway's HTTP surface: a small REST/SSE API under /api gated by
// a session cookie, plus an optional static file server for the built SPA. The
// gateway's privileged operator identity never leaves the process; the browser
// authenticates with a passphrase and thereafter holds only an opaque session
// token.
// server is the gateway's HTTP surface: a small REST/SSE API under /api plus an
// optional static file server for the built SPA.
//
// Two ways to get a session:
// - POST /api/session — the WALLET model. The browser hands its own bus
// identity (unlocked from its local encrypted key) and the gateway connects a
// dedicated bus client AS that user. Per-user, the primary path.
// - POST /api/login — the legacy operator passphrase. Binds the session to the
// single shared operator gateway. Kept for backward compatibility.
// - POST /api/register — the WALLET onboarding. Unauthenticated (the invite
// token authorizes), it consumes a token and publishes the new user's PUBLIC
// identity to the bus allowlist.
type server struct {
gw *gateway
unlock string // passphrase that unlocks a session (compared in constant time)
operatorGW *gateway // shared operator client (legacy passphrase login)
busTemplate gatewayConfig // bus connection config; Identity is overridden per user session
registrar *registrar // POST /api/register backend (mock + proxy)
unlock string // passphrase that unlocks an operator session (constant-time compare)
webDir string // optional path to the built SPA (web/dist); empty = API only
mux *http.ServeMux
mu sync.Mutex
sessions map[string]time.Time // token -> issued-at
sessions *sessionStore
}
func newServer(gw *gateway, unlock, webDir string) *server {
func newServer(operatorGW *gateway, busTemplate gatewayConfig, registrar *registrar, unlock, webDir string) *server {
s := &server{
gw: gw,
operatorGW: operatorGW,
busTemplate: busTemplate,
registrar: registrar,
unlock: unlock,
webDir: webDir,
mux: http.NewServeMux(),
sessions: map[string]time.Time{},
sessions: newSessionStore(),
}
s.routes()
return s
@@ -54,11 +62,14 @@ func (s *server) routes() {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
// Auth: login is the only /api route reachable without a session.
s.mux.HandleFunc("POST /api/login", s.handleLogin)
// Unauthenticated onboarding / auth routes.
s.mux.HandleFunc("POST /api/register", s.handleRegister) // invite token authorizes
s.mux.HandleFunc("POST /api/session", s.handleSession) // wallet: per-user identity
s.mux.HandleFunc("POST /api/login", s.handleLogin) // legacy operator passphrase
// Session-gated routes.
s.mux.HandleFunc("POST /api/logout", s.auth(s.handleLogout))
s.mux.HandleFunc("GET /api/me", s.auth(s.handleMe))
s.mux.HandleFunc("GET /api/rooms", s.auth(s.handleListRooms))
s.mux.HandleFunc("POST /api/rooms", s.auth(s.handleCreateRoom))
s.mux.HandleFunc("POST /api/rooms/{id}/join", s.auth(s.handleJoin))
@@ -71,31 +82,39 @@ func (s *server) routes() {
}
}
// meResp is the identity view returned by /api/session, /api/login and /api/me:
// the bus endpoint the session acts as, its signing public key, and the display
// handle.
type meResp struct {
Endpoint string `json:"endpoint"`
SignPub string `json:"sign_pub"`
Handle string `json:"handle"`
}
// ---- auth -----------------------------------------------------------------
// auth wraps a handler so it runs only with a valid session cookie. A missing or
// unknown token yields 401, which the SPA treats as "show the login screen".
func (s *server) auth(next http.HandlerFunc) http.HandlerFunc {
// auth wraps a handler so it runs only with a valid session cookie, resolving the
// session (and thus the per-user gateway) it belongs to. A missing or unknown
// token yields 401, which the SPA treats as "show the login screen".
func (s *server) auth(next func(http.ResponseWriter, *http.Request, *session)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie(sessionCookie)
if err != nil || !s.validSession(c.Value) {
if err != nil {
writeErr(w, http.StatusUnauthorized, "not authenticated")
return
}
next(w, r)
sess, ok := s.sessions.get(c.Value)
if !ok {
writeErr(w, http.StatusUnauthorized, "not authenticated")
return
}
next(w, r, sess)
}
}
func (s *server) validSession(token string) bool {
if token == "" {
return false
}
s.mu.Lock()
defer s.mu.Unlock()
_, ok := s.sessions[token]
return ok
}
// handleLogin is the legacy operator passphrase login: it unlocks a session bound
// to the shared operator gateway. The wallet path (POST /api/session) is
// preferred; this remains for backward compatibility with the single-operator MVP.
func (s *server) handleLogin(w http.ResponseWriter, r *http.Request) {
var req struct {
Passphrase string `json:"passphrase"`
@@ -104,16 +123,17 @@ func (s *server) handleLogin(w http.ResponseWriter, r *http.Request) {
return
}
// Constant-time compare so a wrong passphrase cannot be timed character by
// character. An empty configured passphrase never matches (main refuses to
// start without one, so this is defense in depth).
// character. An empty configured passphrase never matches.
if s.unlock == "" || subtle.ConstantTimeCompare([]byte(req.Passphrase), []byte(s.unlock)) != 1 {
writeErr(w, http.StatusUnauthorized, "wrong passphrase")
return
}
tok := newToken()
s.mu.Lock()
s.sessions[tok] = time.Now()
s.mu.Unlock()
handle := s.operatorGW.endpoint
if len(handle) > 8 {
handle = handle[:8]
}
s.sessions.put(tok, &session{gw: s.operatorGW, owned: false, handle: handle, issuedAt: time.Now()})
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
@@ -122,27 +142,33 @@ func (s *server) handleLogin(w http.ResponseWriter, r *http.Request) {
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
writeJSON(w, http.StatusOK, s.gw.me())
writeJSON(w, http.StatusOK, meResp{Endpoint: s.operatorGW.endpoint, SignPub: hex.EncodeToString(s.operatorGW.id.SignPub), Handle: handle})
}
func (s *server) handleLogout(w http.ResponseWriter, r *http.Request) {
func (s *server) handleLogout(w http.ResponseWriter, r *http.Request, _ *session) {
if c, err := r.Cookie(sessionCookie); err == nil {
s.mu.Lock()
delete(s.sessions, c.Value)
s.mu.Unlock()
if sess, ok := s.sessions.drop(c.Value); ok && sess.owned && sess.gw != nil {
// Per-user session: tear down its bus client so the private key and the
// NATS connection do not outlive the session.
_ = sess.gw.Close()
}
}
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
writeJSON(w, http.StatusOK, map[string]string{"status": "logged_out"})
}
func (s *server) handleMe(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, s.gw.me())
func (s *server) handleMe(w http.ResponseWriter, _ *http.Request, sess *session) {
writeJSON(w, http.StatusOK, meResp{
Endpoint: sess.gw.endpoint,
SignPub: hex.EncodeToString(sess.gw.id.SignPub),
Handle: sess.handle,
})
}
// ---- rooms ----------------------------------------------------------------
func (s *server) handleListRooms(w http.ResponseWriter, _ *http.Request) {
rooms, err := s.gw.listRooms()
func (s *server) handleListRooms(w http.ResponseWriter, _ *http.Request, sess *session) {
rooms, err := sess.gw.listRooms()
if err != nil {
writeErr(w, http.StatusBadGateway, err.Error())
return
@@ -150,12 +176,12 @@ func (s *server) handleListRooms(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, rooms)
}
func (s *server) handleCreateRoom(w http.ResponseWriter, r *http.Request) {
func (s *server) handleCreateRoom(w http.ResponseWriter, r *http.Request, sess *session) {
var req createRoomReq
if !decode(w, r, &req) {
return
}
rv, err := s.gw.createRoom(req)
rv, err := sess.gw.createRoom(req)
if err != nil {
writeErr(w, http.StatusBadGateway, err.Error())
return
@@ -163,15 +189,15 @@ func (s *server) handleCreateRoom(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, rv)
}
func (s *server) handleJoin(w http.ResponseWriter, r *http.Request) {
if err := s.gw.join(r.PathValue("id")); err != nil {
func (s *server) handleJoin(w http.ResponseWriter, r *http.Request, sess *session) {
if err := sess.gw.join(r.PathValue("id")); err != nil {
writeErr(w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "joined"})
}
func (s *server) handleSend(w http.ResponseWriter, r *http.Request) {
func (s *server) handleSend(w http.ResponseWriter, r *http.Request, sess *session) {
var req sendReq
if !decode(w, r, &req) {
return
@@ -180,25 +206,25 @@ func (s *server) handleSend(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusBadRequest, "body required")
return
}
if err := s.gw.send(r.PathValue("id"), req.Body); err != nil {
if err := sess.gw.send(r.PathValue("id"), req.Body); err != nil {
writeErr(w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "sent"})
}
// handleStream is the SSE endpoint: it joins the room, attaches to the room's
// handleStream is the SSE endpoint: it joins the room, attaches to the session's
// fan-out hub, and streams each decrypted message as a `data:` event. For a
// persisted room the hub's underlying subscription delivers history first
// (scrollback) and then live messages; for an ephemeral room only live messages
// flow. The stream ends when the browser disconnects (ctx cancelled).
func (s *server) handleStream(w http.ResponseWriter, r *http.Request) {
func (s *server) handleStream(w http.ResponseWriter, r *http.Request, sess *session) {
flusher, ok := w.(http.Flusher)
if !ok {
writeErr(w, http.StatusInternalServerError, "streaming unsupported")
return
}
ch, cleanup, err := s.gw.openStream(r.PathValue("id"))
ch, cleanup, err := sess.gw.openStream(r.PathValue("id"))
if err != nil {
writeErr(w, http.StatusBadGateway, err.Error())
return
+146
View File
@@ -0,0 +1,146 @@
package main
import (
"encoding/hex"
"fmt"
"net/http"
"sync"
"time"
cs "fn-registry/functions/cybersecurity"
)
// session is one logged-in browser. In the wallet model each session carries the
// user's OWN bus identity: the browser unlocks its locally-encrypted private key
// and hands the full keypair to the gateway over TLS, and the gateway spins up a
// dedicated bus client (a *gateway) that acts AS that user. The private key lives
// only in this process's memory for the life of the session — it is never written
// to disk and is dropped when the session ends.
//
// A session may instead point at the shared operator gateway (the legacy
// passphrase login); `owned` distinguishes the two so logout only closes the bus
// client it created.
type session struct {
gw *gateway
owned bool // true => gw was built for this session and must be Closed on logout
handle string
issuedAt time.Time
}
// sessionStore is the gateway's set of live browser sessions, keyed by the opaque
// cookie token. It is independent of any single bus identity.
type sessionStore struct {
mu sync.Mutex
m map[string]*session
}
func newSessionStore() *sessionStore { return &sessionStore{m: map[string]*session{}} }
func (st *sessionStore) put(token string, s *session) {
st.mu.Lock()
st.m[token] = s
st.mu.Unlock()
}
func (st *sessionStore) get(token string) (*session, bool) {
st.mu.Lock()
defer st.mu.Unlock()
s, ok := st.m[token]
return s, ok
}
// drop removes a session and returns it so the caller can close an owned gateway.
func (st *sessionStore) drop(token string) (*session, bool) {
st.mu.Lock()
defer st.mu.Unlock()
s, ok := st.m[token]
if ok {
delete(st.m, token)
}
return s, ok
}
// closeAll closes every owned per-user gateway (used at shutdown). The shared
// operator gateway is owned by main and closed separately.
func (st *sessionStore) closeAll() {
st.mu.Lock()
defer st.mu.Unlock()
for tok, s := range st.m {
if s.owned && s.gw != nil {
_ = s.gw.Close()
}
delete(st.m, tok)
}
}
// identityFromHex builds a cs.Identity from the four hex halves the browser sends
// on POST /api/session. It enforces the exact key sizes (sign_pub 32, sign_priv
// 64, kex_pub 32, kex_priv 32) so a malformed body cannot produce a half-built
// identity that fails opaquely deep in the bus client.
func identityFromHex(signPub, signPriv, kexPub, kexPriv string) (cs.Identity, error) {
sp, err := hex.DecodeString(signPub)
if err != nil {
return cs.Identity{}, fmt.Errorf("sign_pub: %w", err)
}
spriv, err := hex.DecodeString(signPriv)
if err != nil {
return cs.Identity{}, fmt.Errorf("sign_priv: %w", err)
}
kp, err := hex.DecodeString(kexPub)
if err != nil {
return cs.Identity{}, fmt.Errorf("kex_pub: %w", err)
}
kpriv, err := hex.DecodeString(kexPriv)
if err != nil {
return cs.Identity{}, fmt.Errorf("kex_priv: %w", err)
}
if len(sp) != 32 || len(spriv) != 64 || len(kp) != 32 || len(kpriv) != 32 {
return cs.Identity{}, fmt.Errorf("wrong key sizes (sign_pub=%d sign_priv=%d kex_pub=%d kex_priv=%d; want 32/64/32/32)",
len(sp), len(spriv), len(kp), len(kpriv))
}
return cs.Identity{SignPub: sp, SignPriv: spriv, KexPub: kp, KexPriv: kpriv}, nil
}
// sessionReq is the POST /api/session body: the user's full wallet identity (hex)
// plus a display handle. The private halves arrive only over TLS and are held in
// memory for the session; they are never persisted server-side.
type sessionReq struct {
Handle string `json:"handle"`
SignPub string `json:"sign_pub"`
SignPriv string `json:"sign_priv"`
KexPub string `json:"kex_pub"`
KexPriv string `json:"kex_priv"`
}
// handleSession opens a per-user session. It builds the user's bus identity from
// the posted keypair, connects a dedicated bus client as that user, and issues a
// session cookie bound to it. This is the wallet-model replacement for the
// operator passphrase login.
func (s *server) handleSession(w http.ResponseWriter, r *http.Request) {
var req sessionReq
if !decode(w, r, &req) {
return
}
id, err := identityFromHex(req.SignPub, req.SignPriv, req.KexPub, req.KexPriv)
if err != nil {
writeErr(w, http.StatusBadRequest, "bad identity: "+err.Error())
return
}
cfg := s.busTemplate
cfg.Identity = id
gw, err := newGateway(cfg)
if err != nil {
writeErr(w, http.StatusBadGateway, "connect bus as user: "+err.Error())
return
}
tok := newToken()
s.sessions.put(tok, &session{gw: gw, owned: true, handle: req.Handle, issuedAt: time.Now()})
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: tok,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
writeJSON(w, http.StatusOK, meResp{Endpoint: gw.endpoint, SignPub: req.SignPub, Handle: req.Handle})
}
+114
View File
@@ -0,0 +1,114 @@
package main
import (
"encoding/json"
"net/http/httptest"
"strings"
"testing"
)
// fixed wallet vector derived in the browser from the mnemonic
// "legal winner thank year wave sausage worth useful legal winner thank yellow"
// using the unibus-sign-v1 / unibus-kex-v1 HKDF scheme. Used to assert the Go
// side accepts the browser-derived key sizes.
const (
fixSignPub = "3d594317212e53a3685b305539f6789eb8c538579e350ca795278b180ebb53db"
fixSignPriv = "94485d66ac958e23546be2e3b7575a47e1264bdf082e09abb7ad02ab32fcd55e3d594317212e53a3685b305539f6789eb8c538579e350ca795278b180ebb53db"
fixKexPub = "f3561ca116e4444b8880b8c0a35f2c9e85804d8628006facd84b1a6146208257"
fixKexPriv = "f6ffdf15e5ee2af0494897ff43e61a06d632af425a0372cb53a7c3e0f84c2bb2"
)
func TestIdentityFromHex(t *testing.T) {
id, err := identityFromHex(fixSignPub, fixSignPriv, fixKexPub, fixKexPriv)
if err != nil {
t.Fatalf("identityFromHex valid vector: %v", err)
}
if len(id.SignPub) != 32 || len(id.SignPriv) != 64 || len(id.KexPub) != 32 || len(id.KexPriv) != 32 {
t.Fatalf("wrong sizes: %d/%d/%d/%d", len(id.SignPub), len(id.SignPriv), len(id.KexPub), len(id.KexPriv))
}
// Wrong sign_priv size (32 instead of 64) must be rejected.
if _, err := identityFromHex(fixSignPub, fixSignPub, fixKexPub, fixKexPriv); err == nil {
t.Fatalf("expected error for short sign_priv")
}
// Non-hex must be rejected.
if _, err := identityFromHex("zz", fixSignPriv, fixKexPub, fixKexPriv); err == nil {
t.Fatalf("expected error for non-hex sign_pub")
}
}
func TestValidHexKey(t *testing.T) {
if !validHexKey(fixSignPub) {
t.Fatalf("fixSignPub should be a valid 32-byte hex key")
}
if validHexKey("abcd") {
t.Fatalf("short key should be invalid")
}
if validHexKey(strings.Repeat("z", 64)) {
t.Fatalf("non-hex key should be invalid")
}
}
func TestNewRegistrarParsesMockTokens(t *testing.T) {
r := newRegistrar("", "demo=demo:member, bob=bob, alice=alice:admin")
if len(r.mockTokens) != 3 {
t.Fatalf("want 3 mock tokens, got %d", len(r.mockTokens))
}
if r.mockTokens["demo"].role != "member" || r.mockTokens["demo"].handle != "demo" {
t.Fatalf("demo token parsed wrong: %+v", r.mockTokens["demo"])
}
if r.mockTokens["bob"].role != "member" {
t.Fatalf("bob should default to role member, got %q", r.mockTokens["bob"].role)
}
if r.mockTokens["alice"].role != "admin" {
t.Fatalf("alice should be admin, got %q", r.mockTokens["alice"].role)
}
}
// post builds a server with only a registrar (the register path does not touch a
// gateway) and runs one POST /api/register, returning status + decoded body.
func postRegister(t *testing.T, s *server, body string) (int, map[string]string) {
t.Helper()
req := httptest.NewRequest("POST", "/api/register", strings.NewReader(body))
w := httptest.NewRecorder()
s.handleRegister(w, req)
var m map[string]string
_ = json.Unmarshal(w.Body.Bytes(), &m)
return w.Code, m
}
func TestHandleRegisterMockSingleUse(t *testing.T) {
s := &server{registrar: newRegistrar("", "demo=demo:member")}
// 1) valid token + valid keys => 201 with the invite's handle/role.
code, body := postRegister(t, s, `{"token":"demo","sign_pub":"`+fixSignPub+`","kex_pub":"`+fixKexPub+`"}`)
if code != 201 {
t.Fatalf("first register: want 201, got %d (%v)", code, body)
}
if body["handle"] != "demo" || body["role"] != "member" {
t.Fatalf("first register body: %v", body)
}
// 2) same token again => 409 (single-use consumed).
code, _ = postRegister(t, s, `{"token":"demo","sign_pub":"`+fixSignPub+`","kex_pub":"`+fixKexPub+`"}`)
if code != 409 {
t.Fatalf("reused token: want 409, got %d", code)
}
}
func TestHandleRegisterValidation(t *testing.T) {
s := &server{registrar: newRegistrar("", "demo=demo:member")}
// bad sign_pub (too short) => 400
if code, _ := postRegister(t, s, `{"token":"demo","sign_pub":"abcd","kex_pub":"`+fixKexPub+`"}`); code != 400 {
t.Fatalf("short sign_pub: want 400, got %d", code)
}
// missing token => 400
if code, _ := postRegister(t, s, `{"sign_pub":"`+fixSignPub+`","kex_pub":"`+fixKexPub+`"}`); code != 400 {
t.Fatalf("missing token: want 400, got %d", code)
}
// unknown token with no mock match and no register-url => 400
if code, _ := postRegister(t, s, `{"token":"nope","sign_pub":"`+fixSignPub+`","kex_pub":"`+fixKexPub+`"}`); code != 400 {
t.Fatalf("unknown token: want 400, got %d", code)
}
}
+3
View File
@@ -11,6 +11,9 @@
"dependencies": {
"@mantine/core": "^9.3.0",
"@mantine/hooks": "^9.3.0",
"@noble/curves": "^2.2.0",
"@noble/hashes": "^2.2.0",
"@scure/bip39": "^2.2.0",
"@tabler/icons-react": "^3.36.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
+36
View File
@@ -14,6 +14,15 @@ importers:
'@mantine/hooks':
specifier: ^9.3.0
version: 9.3.0(react@19.2.7)
'@noble/curves':
specifier: ^2.2.0
version: 2.2.0
'@noble/hashes':
specifier: ^2.2.0
version: 2.2.0
'@scure/bip39':
specifier: ^2.2.0
version: 2.2.0
'@tabler/icons-react':
specifier: ^3.36.0
version: 3.44.0(react@19.2.7)
@@ -339,6 +348,14 @@ packages:
peerDependencies:
react: ^19.2.0
'@noble/curves@2.2.0':
resolution: {integrity: sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==}
engines: {node: '>= 20.19.0'}
'@noble/hashes@2.2.0':
resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==}
engines: {node: '>= 20.19.0'}
'@rolldown/pluginutils@1.0.0-beta.27':
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
@@ -480,6 +497,12 @@ packages:
cpu: [x64]
os: [win32]
'@scure/base@2.2.0':
resolution: {integrity: sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==}
'@scure/bip39@2.2.0':
resolution: {integrity: sha512-T/Bj/YvYMNkIPq6EENO6/rcs2e7qTNuyoUXf0KBFDmp0ZDu0H2X4Lq6yC3i0c8PcWkov5EbW+yQZZbdMmk154A==}
'@tabler/icons-react@3.44.0':
resolution: {integrity: sha512-8+rvzBbVm/1Z3sG3x7GUNAaxIKxwgz8xaMhRs23nrCnMTKRFAhEC+82zAIFeAA0seXdrAGX5HFCkaLpGK2rVHg==}
peerDependencies:
@@ -1086,6 +1109,12 @@ snapshots:
dependencies:
react: 19.2.7
'@noble/curves@2.2.0':
dependencies:
'@noble/hashes': 2.2.0
'@noble/hashes@2.2.0': {}
'@rolldown/pluginutils@1.0.0-beta.27': {}
'@rollup/rollup-android-arm-eabi@4.61.1':
@@ -1163,6 +1192,13 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.61.1':
optional: true
'@scure/base@2.2.0': {}
'@scure/bip39@2.2.0':
dependencies:
'@noble/hashes': 2.2.0
'@scure/base': 2.2.0
'@tabler/icons-react@3.44.0(react@19.2.7)':
dependencies:
'@tabler/icons': 3.44.0
+113 -18
View File
@@ -1,44 +1,139 @@
import { useEffect, useState } from "react";
import { Center, Loader } from "@mantine/core";
import { Login } from "./Login";
import { ChatShell } from "./ChatShell";
import { Join } from "./Join";
import { Recover } from "./Recover";
import { WalletLogin } from "./WalletLogin";
import { Welcome } from "./Welcome";
import { api } from "./api";
import { localIdentity } from "./wallet/account";
import type { User } from "./types";
// shortEndpoint hace legible el endpoint id del operador para mostrarlo como
// handle por defecto cuando no se escribió uno en el login.
function shortEndpoint(ep: string) {
return ep.slice(0, 8);
type Route = "loading" | "join" | "welcome" | "login" | "recover" | "chat";
// readJoinToken returns the invite token if the current URL is /join?token=XXX.
function readJoinToken(): string | null {
if (window.location.pathname !== "/join") return null;
return new URLSearchParams(window.location.search).get("token");
}
// clearUrl drops any /join?token from the address bar once consumed, so a refresh
// or a shared screenshot does not replay the (single-use) token.
function clearUrl() {
if (window.location.pathname !== "/") {
window.history.replaceState(null, "", "/");
}
}
export function App() {
const [route, setRoute] = useState<Route>("loading");
const [user, setUser] = useState<User | null>(null);
const [checking, setChecking] = useState(true);
const [token, setToken] = useState("");
const [storedHandle, setStoredHandle] = useState("");
// Al montar, comprueba si ya hay una sesión viva en el gateway (cookie). Si la
// hay, entra directo; si no (401), muestra el login.
// Decide the entry screen on mount: an invite link goes straight to join; a live
// gateway session resumes the chat; a device with a stored identity shows the
// password unlock; an empty device shows the welcome chooser.
useEffect(() => {
api
.me()
.then((me) =>
setUser({ id: me.endpoint, handle: shortEndpoint(me.endpoint) }),
)
.catch(() => {})
.finally(() => setChecking(false));
const t = readJoinToken();
if (t) {
setToken(t);
setRoute("join");
return;
}
let cancelled = false;
(async () => {
try {
const me = await api.me();
if (cancelled) return;
setUser({ id: me.endpoint, handle: me.handle || me.endpoint.slice(0, 8) });
setRoute("chat");
return;
} catch {
// no live session — fall through
}
const stored = await localIdentity();
if (cancelled) return;
if (stored) {
setStoredHandle(stored.handle);
setRoute("login");
} else {
setRoute("welcome");
}
})();
return () => {
cancelled = true;
};
}, []);
const enterChat = (u: User) => {
setUser(u);
setRoute("chat");
clearUrl();
};
const logout = () => {
void api.logout().catch(() => {});
setUser(null);
// Keep the encrypted identity on the device: logging out returns to the
// password unlock, not a full reset.
void localIdentity().then((stored) => {
if (stored) {
setStoredHandle(stored.handle);
setRoute("login");
} else {
setRoute("welcome");
}
});
};
if (checking) {
switch (route) {
case "loading":
return (
<Center h="100vh" bg="dark.9">
<Loader color="brand" />
</Center>
);
case "join":
return (
<Join
token={token}
onJoined={enterChat}
onRecover={() => setRoute("recover")}
/>
);
case "welcome":
return (
<Welcome
onJoinToken={(t) => {
setToken(t);
setRoute("join");
}}
onRecover={() => setRoute("recover")}
/>
);
case "login":
return (
<WalletLogin
handle={storedHandle}
onLoggedIn={enterChat}
onRecover={() => setRoute("recover")}
/>
);
case "recover":
return (
<Recover
onRecovered={enterChat}
onBack={() => setRoute(storedHandle ? "login" : "welcome")}
/>
);
case "chat":
return user ? (
<ChatShell user={user} onLogout={logout} />
) : (
<Center h="100vh" bg="dark.9">
<Loader color="brand" />
</Center>
);
}
if (!user) return <Login onLogin={setUser} />;
return <ChatShell user={user} onLogout={logout} />;
}
+47
View File
@@ -0,0 +1,47 @@
import type { ReactNode } from "react";
import { Card, Center, Stack, Text, ThemeIcon, Title } from "@mantine/core";
// AuthCard is the shared centered card used by every pre-chat screen (welcome,
// join, recover, wallet login) so they all look like one flow.
export function AuthCard({
width = 460,
children,
}: {
width?: number;
children: ReactNode;
}) {
return (
<Center h="100vh" bg="dark.9" p="md">
<Card w={width} p="xl" radius="lg" withBorder bg="dark.7">
<Stack gap="lg">{children}</Stack>
</Card>
</Center>
);
}
// AuthHeader is the icon + title + subtitle block at the top of an auth card.
export function AuthHeader({
icon,
title,
subtitle,
}: {
icon: ReactNode;
title: string;
subtitle?: string;
}) {
return (
<Stack align="center" gap="xs">
<ThemeIcon size={56} radius="xl" variant="light" color="brand">
{icon}
</ThemeIcon>
<Title order={3} ta="center">
{title}
</Title>
{subtitle && (
<Text c="dimmed" size="sm" ta="center">
{subtitle}
</Text>
)}
</Stack>
);
}
+322
View File
@@ -0,0 +1,322 @@
import { useEffect, useMemo, useState } from "react";
import {
Alert,
Button,
Card,
Center,
Checkbox,
CopyButton,
Group,
Loader,
PasswordInput,
SimpleGrid,
Stack,
Text,
TextInput,
} from "@mantine/core";
import {
IconAlertTriangle,
IconCheck,
IconCopy,
IconKey,
IconShieldLock,
} from "@tabler/icons-react";
import { api, ApiError } from "./api";
import { AuthCard, AuthHeader } from "./AuthShell";
import type { User } from "./types";
import { newMnemonic, mnemonicWords } from "./wallet/bip39";
import { deriveIdentity, type WalletIdentity } from "./wallet/derive";
import { saveAndOpen } from "./wallet/account";
type Step = "generating" | "show-seed" | "confirm-seed" | "password" | "joining";
// pickPositions chooses `count` distinct word positions (0-based) to ask the user
// to confirm. This is a UI choice, not key material, so Math.random is fine.
function pickPositions(total: number, count: number): number[] {
const all = Array.from({ length: total }, (_, i) => i);
for (let i = all.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[all[i], all[j]] = [all[j], all[i]];
}
return all.slice(0, count).sort((a, b) => a - b);
}
// Join is the onboarding page reached from an invite link (/join?token=XXX). It
// generates a brand-new BIP39 seed, derives the identity, shows the seed exactly
// once with a confirmation gate, takes a local password, registers the PUBLIC key
// with the bus using the token, and enters the chat. The seed is never persisted
// and never sent to the server.
export function Join({
token,
onJoined,
onRecover,
}: {
token: string;
onJoined: (u: User) => void;
onRecover: () => void;
}) {
const [step, setStep] = useState<Step>("generating");
const [mnemonic, setMnemonic] = useState("");
const [identity, setIdentity] = useState<WalletIdentity | null>(null);
const [error, setError] = useState<string | null>(null);
// Generate the seed + identity once on mount. Deriving is fast and pure.
useEffect(() => {
if (!token) {
setError("Enlace de invitación inválido: falta el token.");
return;
}
try {
const m = newMnemonic();
setMnemonic(m);
setIdentity(deriveIdentity(m));
setStep("show-seed");
} catch {
setError("No se pudo generar la identidad en este navegador.");
}
}, [token]);
const words = useMemo(() => mnemonicWords(mnemonic), [mnemonic]);
if (error && step === "generating") {
return (
<AuthCard>
<Alert color="red" icon={<IconAlertTriangle size={18} />} title="Error">
{error}
</Alert>
<Button variant="light" mt="md" onClick={onRecover}>
Recuperar con mi seed
</Button>
</AuthCard>
);
}
if (step === "generating" || !identity) {
return (
<Center h="100vh" bg="dark.9">
<Loader color="brand" />
</Center>
);
}
if (step === "show-seed") {
return (
<ShowSeed words={words} onContinue={() => setStep("confirm-seed")} />
);
}
if (step === "confirm-seed") {
return (
<ConfirmSeed
words={words}
onBack={() => setStep("show-seed")}
onConfirmed={() => setStep("password")}
/>
);
}
// step === "password" | "joining"
return (
<SetPassword
busy={step === "joining"}
error={error}
onSubmit={async (password) => {
setStep("joining");
setError(null);
try {
// Register the PUBLIC identity with the bus (token authorizes), then
// encrypt the private key locally and open the per-user session.
const res = await api.register(token, identity.signPub, identity.kexPub);
const user = await saveAndOpen(identity, res.handle, password);
onJoined(user);
} catch (e) {
setError(
e instanceof ApiError ? e.message : "No se pudo completar el alta.",
);
setStep("password");
}
}}
/>
);
}
// ---- sub-screens ----------------------------------------------------------
function ShowSeed({
words,
onContinue,
}: {
words: string[];
onContinue: () => void;
}) {
const [acknowledged, setAcknowledged] = useState(false);
const phrase = words.join(" ");
return (
<AuthCard>
<AuthHeader
icon={<IconShieldLock size={30} />}
title="Guarda tu frase de recuperación"
subtitle="Estas 12 palabras son tu ÚNICA forma de recuperar tu cuenta si olvidas la contraseña o cambias de dispositivo. No las compartas con nadie."
/>
<Card bg="dark.8" radius="md" p="md" withBorder>
<SimpleGrid cols={3} spacing="xs" verticalSpacing="xs">
{words.map((w, i) => (
<Group gap={6} wrap="nowrap" key={i}>
<Text size="xs" c="dimmed" w={18} ta="right">
{i + 1}
</Text>
<Text size="sm" ff="monospace" fw={600}>
{w}
</Text>
</Group>
))}
</SimpleGrid>
</Card>
<Group justify="space-between">
<CopyButton value={phrase}>
{({ copied, copy }) => (
<Button
variant="subtle"
size="xs"
color={copied ? "teal" : "gray"}
leftSection={
copied ? <IconCheck size={14} /> : <IconCopy size={14} />
}
onClick={copy}
>
{copied ? "Copiada" : "Copiar"}
</Button>
)}
</CopyButton>
</Group>
<Alert color="yellow" variant="light" icon={<IconAlertTriangle size={16} />}>
unibus NO guarda esta frase. Si la pierdes y olvidas la contraseña, solo
el administrador podrá darte de alta de nuevo.
</Alert>
<Checkbox
checked={acknowledged}
onChange={(e) => setAcknowledged(e.currentTarget.checked)}
label="He guardado mi frase de recuperación en un lugar seguro"
/>
<Button disabled={!acknowledged} onClick={onContinue}>
Continuar
</Button>
</AuthCard>
);
}
function ConfirmSeed({
words,
onBack,
onConfirmed,
}: {
words: string[];
onBack: () => void;
onConfirmed: () => void;
}) {
// Ask the user to re-type 3 random words from their phrase. This proves they
// actually wrote the seed down rather than clicking through.
const positions = useMemo(() => pickPositions(words.length, 3), [words.length]);
const [inputs, setInputs] = useState<Record<number, string>>({});
const allCorrect = positions.every(
(p) => (inputs[p] ?? "").trim().toLowerCase() === words[p],
);
const anyTyped = positions.some((p) => (inputs[p] ?? "").length > 0);
return (
<AuthCard>
<AuthHeader
icon={<IconCheck size={30} />}
title="Confirma tu frase"
subtitle="Escribe las palabras solicitadas para confirmar que la guardaste bien."
/>
<Stack gap="sm">
{positions.map((p) => (
<TextInput
key={p}
label={`Palabra #${p + 1}`}
placeholder={`palabra ${p + 1}`}
value={inputs[p] ?? ""}
error={
(inputs[p] ?? "").length > 0 &&
(inputs[p] ?? "").trim().toLowerCase() !== words[p]
? "No coincide"
: undefined
}
onChange={(e) => {
// Capture the value synchronously: React nulls e.currentTarget
// after dispatch, so reading it inside the state updater (which runs
// later) would throw "Cannot read properties of null".
const v = e.currentTarget.value;
setInputs((prev) => ({ ...prev, [p]: v }));
}}
autoComplete="off"
spellCheck={false}
/>
))}
</Stack>
{!allCorrect && anyTyped && (
<Text size="xs" c="dimmed">
Revisa el orden y la ortografía de las palabras.
</Text>
)}
<Group grow>
<Button variant="default" onClick={onBack}>
Volver a ver
</Button>
<Button disabled={!allCorrect} onClick={onConfirmed}>
Confirmar
</Button>
</Group>
</AuthCard>
);
}
function SetPassword({
busy,
error,
onSubmit,
}: {
busy: boolean;
error: string | null;
onSubmit: (password: string) => void;
}) {
const [pw, setPw] = useState("");
const [pw2, setPw2] = useState("");
const tooShort = pw.length > 0 && pw.length < 8;
const mismatch = pw2.length > 0 && pw !== pw2;
const ready = pw.length >= 8 && pw === pw2 && !busy;
return (
<AuthCard>
<AuthHeader
icon={<IconKey size={30} />}
title="Protege tu identidad"
subtitle="Elige una contraseña para cifrar tu clave en ESTE dispositivo. No se guarda ni se envía a ningún servidor; solo desbloquea tu clave local."
/>
<PasswordInput
label="Contraseña"
description="Mínimo 8 caracteres"
leftSection={<IconKey size={16} />}
value={pw}
error={tooShort ? "Demasiado corta" : undefined}
onChange={(e) => setPw(e.currentTarget.value)}
data-autofocus
/>
<PasswordInput
label="Repite la contraseña"
leftSection={<IconKey size={16} />}
value={pw2}
error={mismatch ? "No coincide" : undefined}
onChange={(e) => setPw2(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && ready && onSubmit(pw)}
/>
{error && (
<Text c="red" size="sm" ta="center">
{error}
</Text>
)}
<Button disabled={!ready} loading={busy} onClick={() => onSubmit(pw)}>
Crear cuenta y entrar
</Button>
</AuthCard>
);
}
+175
View File
@@ -0,0 +1,175 @@
import { useMemo, useState } from "react";
import {
Alert,
Anchor,
Button,
Code,
Group,
PasswordInput,
Stack,
Text,
Textarea,
TextInput,
} from "@mantine/core";
import { IconKey, IconRotateClockwise } from "@tabler/icons-react";
import { AuthCard, AuthHeader } from "./AuthShell";
import { ApiError } from "./api";
import type { User } from "./types";
import { isValidMnemonic, mnemonicWords, normalizeMnemonic } from "./wallet/bip39";
import { deriveIdentity } from "./wallet/derive";
import { saveAndOpen } from "./wallet/account";
type Step = "phrase" | "password";
// Recover re-creates an existing identity from its 12-word seed — no admin needed.
// Validating the BIP39 phrase and re-deriving yields the SAME keypair (same
// sign_pub) the bus already authorizes, so the user lands back in the allowlist
// with their place intact. A new local password then re-encrypts the key on this
// device. Only if the user loses BOTH the password AND the seed must the admin
// re-provision them.
export function Recover({
onRecovered,
onBack,
}: {
onRecovered: (u: User) => void;
onBack: () => void;
}) {
const [step, setStep] = useState<Step>("phrase");
const [phrase, setPhrase] = useState("");
const [handle, setHandle] = useState("");
const [pw, setPw] = useState("");
const [pw2, setPw2] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const normalized = normalizeMnemonic(phrase);
const wordCount = mnemonicWords(phrase).length;
const valid = isValidMnemonic(phrase);
// Re-derive as soon as the phrase is valid, so we can show the user which
// identity (sign_pub) it maps to before they commit a new password.
const identity = useMemo(
() => (valid ? deriveIdentity(normalized) : null),
[valid, normalized],
);
if (step === "phrase") {
return (
<AuthCard>
<AuthHeader
icon={<IconRotateClockwise size={30} />}
title="Recuperar con tu frase"
subtitle="Introduce tus 12 palabras de recuperación. Se quedan en este navegador: nunca se envían al servidor."
/>
<Textarea
label="Frase de recuperación (12 palabras)"
placeholder="palabra1 palabra2 palabra3 …"
autosize
minRows={3}
value={phrase}
onChange={(e) => setPhrase(e.currentTarget.value)}
spellCheck={false}
autoComplete="off"
/>
<Text size="xs" c={valid ? "teal" : "dimmed"}>
{wordCount > 0
? valid
? "Frase válida ✓"
: `${wordCount}/12 palabras — frase aún no válida`
: "Separadas por espacios."}
</Text>
{identity && (
<Alert color="brand" variant="light" title="Identidad reconstruida">
<Text size="xs">Tu clave pública de firma (sign_pub):</Text>
<Code block>{identity.signPub}</Code>
</Alert>
)}
<Group grow>
<Button variant="default" onClick={onBack}>
Volver
</Button>
<Button disabled={!valid} onClick={() => setStep("password")}>
Continuar
</Button>
</Group>
</AuthCard>
);
}
// step === "password"
const tooShort = pw.length > 0 && pw.length < 8;
const mismatch = pw2.length > 0 && pw !== pw2;
const ready = pw.length >= 8 && pw === pw2 && !busy && identity !== null;
const finish = async () => {
if (!ready || !identity) return;
setBusy(true);
setError(null);
try {
// No register here: the identity is already in the allowlist. Just re-encrypt
// locally and open the session as the recovered user.
const user = await saveAndOpen(identity, handle.trim(), pw);
onRecovered(user);
} catch (e) {
setError(
e instanceof ApiError
? e.message
: "No se pudo abrir la sesión con la identidad recuperada.",
);
setBusy(false);
}
};
return (
<AuthCard>
<AuthHeader
icon={<IconKey size={30} />}
title="Nueva contraseña"
subtitle="Elige una contraseña para cifrar tu clave recuperada en este dispositivo."
/>
<Stack gap="sm">
<TextInput
label="Nombre a mostrar (opcional)"
placeholder="tu-handle"
value={handle}
onChange={(e) => setHandle(e.currentTarget.value)}
/>
<PasswordInput
label="Contraseña"
description="Mínimo 8 caracteres"
leftSection={<IconKey size={16} />}
value={pw}
error={tooShort ? "Demasiado corta" : undefined}
onChange={(e) => setPw(e.currentTarget.value)}
data-autofocus
/>
<PasswordInput
label="Repite la contraseña"
leftSection={<IconKey size={16} />}
value={pw2}
error={mismatch ? "No coincide" : undefined}
onChange={(e) => setPw2(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && void finish()}
/>
</Stack>
{error && (
<Text c="red" size="sm" ta="center">
{error}
</Text>
)}
<Group grow>
<Button variant="default" onClick={() => setStep("phrase")}>
Volver
</Button>
<Button disabled={!ready} loading={busy} onClick={() => void finish()}>
Recuperar y entrar
</Button>
</Group>
<Group justify="center">
<Anchor size="xs" c="dimmed" onClick={onBack}>
Cancelar
</Anchor>
</Group>
</AuthCard>
);
}
+77
View File
@@ -0,0 +1,77 @@
import { useState } from "react";
import { Anchor, Button, Group, PasswordInput, Text } from "@mantine/core";
import { IconKey, IconWallet } from "@tabler/icons-react";
import { AuthCard, AuthHeader } from "./AuthShell";
import { ApiError } from "./api";
import type { User } from "./types";
import { unlockAndOpen } from "./wallet/account";
import { WrongPasswordError } from "./wallet/crypto";
// WalletLogin is shown when this device already holds an encrypted identity. The
// password decrypts the local private key and opens a per-user gateway session.
// The password is never stored and never sent to the server.
export function WalletLogin({
handle,
onLoggedIn,
onRecover,
}: {
handle: string;
onLoggedIn: (u: User) => void;
onRecover: () => void;
}) {
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const unlock = async () => {
if (!password || busy) return;
setBusy(true);
setError(null);
try {
const user = await unlockAndOpen(password);
onLoggedIn(user);
} catch (e) {
if (e instanceof WrongPasswordError) {
setError("Contraseña incorrecta.");
} else if (e instanceof ApiError) {
setError(e.message);
} else {
setError("No se pudo abrir tu identidad.");
}
setBusy(false);
}
};
return (
<AuthCard width={400}>
<AuthHeader
icon={<IconWallet size={30} />}
title="unibus"
subtitle={`Desbloquea la identidad de ${handle || "este dispositivo"}`}
/>
<PasswordInput
label="Contraseña"
description="Descifra tu clave guardada en este dispositivo"
placeholder="••••••••"
leftSection={<IconKey size={16} />}
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && void unlock()}
data-autofocus
/>
{error && (
<Text c="red" size="sm" ta="center">
{error}
</Text>
)}
<Button fullWidth onClick={() => void unlock()} disabled={!password} loading={busy}>
Entrar
</Button>
<Group justify="center">
<Anchor size="xs" c="dimmed" onClick={onRecover}>
¿Olvidaste la contraseña? Recupera con tu frase de 12 palabras
</Anchor>
</Group>
</AuthCard>
);
}
+70
View File
@@ -0,0 +1,70 @@
import { useState } from "react";
import { Button, Divider, Stack, Text, TextInput } from "@mantine/core";
import { IconLink, IconRotateClockwise, IconShieldLock } from "@tabler/icons-react";
import { AuthCard, AuthHeader } from "./AuthShell";
// extractToken pulls the invite token out of whatever the user pastes: a full
// link (.../join?token=XXX), a bare "token=XXX", or just the token itself.
function extractToken(input: string): string {
const s = input.trim();
if (!s) return "";
const m = s.match(/[?&]token=([^&\s]+)/);
if (m) return decodeURIComponent(m[1]);
if (s.startsWith("token=")) return s.slice("token=".length);
return s;
}
// Welcome is the entry screen on a device with no local identity. It offers the
// two ways in: open an invite link (new account) or recover an existing account
// from its 12-word seed.
export function Welcome({
onJoinToken,
onRecover,
}: {
onJoinToken: (token: string) => void;
onRecover: () => void;
}) {
const [link, setLink] = useState("");
const token = extractToken(link);
return (
<AuthCard width={420}>
<AuthHeader
icon={<IconShieldLock size={30} />}
title="unibus"
subtitle="Mensajería cifrada de extremo a extremo. Tu identidad vive en tu dispositivo."
/>
<Stack gap="xs">
<Text size="sm" fw={600}>
Tengo un enlace de invitación
</Text>
<TextInput
placeholder="Pega aquí tu enlace /join?token=…"
leftSection={<IconLink size={16} />}
value={link}
onChange={(e) => setLink(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && token && onJoinToken(token)}
/>
<Button disabled={!token} onClick={() => onJoinToken(token)}>
Crear mi cuenta
</Button>
</Stack>
<Divider label="o" labelPosition="center" color="dark.4" />
<Stack gap="xs">
<Text size="sm" fw={600}>
Ya tengo una cuenta
</Text>
<Button
variant="default"
leftSection={<IconRotateClockwise size={16} />}
onClick={onRecover}
>
Recuperar con mi seed (12 palabras)
</Button>
</Stack>
</AuthCard>
);
}
+40 -4
View File
@@ -3,7 +3,15 @@
// bus), cifra/descifra por room y traduce a REST/SSE. El navegador nunca firma,
// nunca habla NATS y nunca ve una clave privada: solo guarda una cookie de
// sesión opaca (HttpOnly) que el gateway emite tras el login.
import type { MeInfo, Message, MsgWire, Room, RoomWire } from "./types";
import type {
MeInfo,
Message,
MsgWire,
RegisterResult,
Room,
RoomWire,
} from "./types";
import type { WalletIdentity } from "./wallet/derive";
export class ApiError extends Error {
status: number;
@@ -68,9 +76,37 @@ export function messageFromWire(m: MsgWire): Message {
}
export const api = {
// ---- sesión -------------------------------------------------------------
// login desbloquea la sesión del gateway con la passphrase del operador. El
// gateway responde con una cookie de sesión; me() comprueba si ya hay una.
// ---- onboarding wallet --------------------------------------------------
// register publica la identidad PÚBLICA del nuevo usuario en el allowlist del
// bus usando el token del enlace de invitación. NO requiere sesión: el token
// autoriza. El handle y el rol los fija el invite, no el cliente. La clave
// privada NUNCA se envía aquí.
register: (token: string, signPub: string, kexPub: string) =>
req<RegisterResult>("/api/register", {
method: "POST",
body: JSON.stringify({ token, sign_pub: signPub, kex_pub: kexPub }),
}),
// session abre una sesión POR USUARIO: el navegador entrega su identidad wallet
// completa (incluida la privada, solo por TLS) y el gateway conecta un cliente
// del bus que actúa COMO ese usuario. La privada vive en memoria del gateway
// mientras dure la sesión; no se persiste en el servidor.
session: (id: WalletIdentity, handle: string) =>
req<MeInfo>("/api/session", {
method: "POST",
body: JSON.stringify({
handle,
sign_pub: id.signPub,
sign_priv: id.signPriv,
kex_pub: id.kexPub,
kex_priv: id.kexPriv,
}),
}),
// ---- sesión (legacy operador) ------------------------------------------
// login desbloquea una sesión ligada al gateway del operador con su passphrase.
// El camino principal ahora es el wallet (session); login se mantiene por
// compatibilidad con el MVP de operador único.
login: (passphrase: string) =>
req<MeInfo>("/api/login", {
method: "POST",
+11 -1
View File
@@ -26,10 +26,20 @@ export interface Room {
// ---- formas de la API del gateway (wire) ---------------------------------
// MeInfo es la identidad del operador que el gateway encarna (GET /api/me).
// MeInfo es la identidad que el gateway encarna en la sesión actual (GET /api/me,
// POST /api/session, POST /api/login). En el modelo wallet es la identidad del
// USUARIO logueado; `handle` es su nombre a mostrar.
export interface MeInfo {
endpoint: string;
sign_pub: string;
handle: string;
}
// RegisterResult es la respuesta de POST /api/register: el handle y rol que el
// invite (token) fijó para el nuevo usuario.
export interface RegisterResult {
handle: string;
role: string;
}
// RoomWire es la fila de room que devuelve el gateway (GET /api/rooms). No trae
+60
View File
@@ -0,0 +1,60 @@
// High-level wallet account operations shared by the join, recover and login
// flows. These compose the low-level primitives (derive / crypto / store) with
// the gateway API so the page components stay thin.
import { api } from "../api";
import type { MeInfo, User } from "../types";
import { decryptJSON, encryptJSON } from "./crypto";
import type { WalletIdentity } from "./derive";
import { getIdentity, putIdentity, type StoredIdentity } from "./store";
function toUser(me: MeInfo): User {
return { id: me.endpoint, handle: me.handle || me.endpoint.slice(0, 8) };
}
// saveAndOpen encrypts the identity under `password`, stores it on this device,
// and opens a gateway session as that user. Used by join (new identity) and
// recover (re-derived identity): both end with a locally-encrypted key plus a
// live per-user session. The mnemonic/seed is NOT touched here — only the derived
// keypair is persisted (encrypted).
export async function saveAndOpen(
identity: WalletIdentity,
handle: string,
password: string,
): Promise<User> {
const enc = await encryptJSON(identity, password);
await putIdentity({
handle,
signPub: identity.signPub,
kexPub: identity.kexPub,
enc,
createdAt: Date.now(),
});
const me = await api.session(identity, handle);
return toUser(me);
}
// unlockAndOpen reads this device's stored identity, decrypts the private key with
// `password`, and opens a gateway session. Throws WrongPasswordError on a bad
// password (GCM auth failure) and NoLocalIdentityError if the device has none.
export async function unlockAndOpen(password: string): Promise<User> {
const stored = await getIdentity();
if (!stored) throw new NoLocalIdentityError();
const identity = await decryptJSON<WalletIdentity>(stored.enc, password);
const me = await api.session(identity, stored.handle);
return toUser(me);
}
// localIdentity returns the device's stored identity record (or null), for the
// router to decide between the password-unlock screen and the welcome screen, and
// to greet the user by handle before unlocking.
export async function localIdentity(): Promise<StoredIdentity | null> {
return getIdentity();
}
export class NoLocalIdentityError extends Error {
constructor() {
super("no local identity on this device");
this.name = "NoLocalIdentityError";
}
}
+55
View File
@@ -0,0 +1,55 @@
// Thin wrappers over @scure/bip39 (a small, audited BIP39 implementation that
// ships the English wordlist and the mnemonic<->entropy conversions). We do not
// roll our own checksum logic — getting the BIP39 checksum wrong silently is a
// classic footgun, so the conversion stays in the library.
import {
generateMnemonic,
validateMnemonic,
mnemonicToEntropy,
} from "@scure/bip39";
import { wordlist } from "@scure/bip39/wordlists/english.js";
// MNEMONIC_STRENGTH_BITS = 128 bits of entropy => exactly 12 words.
export const MNEMONIC_STRENGTH_BITS = 128;
export const MNEMONIC_WORD_COUNT = 12;
// newMnemonic returns a fresh 12-word mnemonic from a CSPRNG (crypto.getRandomValues
// inside @scure). The caller must show it to the user once and never persist it.
export function newMnemonic(): string {
return generateMnemonic(wordlist, MNEMONIC_STRENGTH_BITS);
}
// normalizeMnemonic lowercases, trims and collapses whitespace so a phrase the
// user typed (extra spaces, trailing newline, mixed case) validates the same way
// it would have been generated.
export function normalizeMnemonic(input: string): string {
return input.trim().toLowerCase().split(/\s+/).filter(Boolean).join(" ");
}
// mnemonicWords splits a phrase into its individual words (normalized).
export function mnemonicWords(input: string): string[] {
const n = normalizeMnemonic(input);
return n ? n.split(" ") : [];
}
// isValidMnemonic checks word count, that every word is in the wordlist, and the
// BIP39 checksum. A phrase that fails this must not be used to derive an identity.
export function isValidMnemonic(input: string): boolean {
const n = normalizeMnemonic(input);
if (mnemonicWords(n).length !== MNEMONIC_WORD_COUNT) return false;
try {
return validateMnemonic(n, wordlist);
} catch {
return false;
}
}
// entropyHex returns the underlying entropy (hex) of a valid mnemonic. Used only
// for diagnostics / tests, never sent anywhere.
export function entropyHex(input: string): string {
const bytes = mnemonicToEntropy(normalizeMnemonic(input), wordlist);
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
+124
View File
@@ -0,0 +1,124 @@
// Local at-rest encryption of the wallet's private key, using only the platform
// WebCrypto (crypto.subtle) — no extra dependency, no WASM. The password derives
// an AES-GCM key via PBKDF2; the password itself is never stored, never sent to
// the server, and is not part of the identity (it only protects the local copy
// of the private key). The identity's source of truth is the BIP39 seed.
// PBKDF2 work factor. 210k SHA-256 iterations is the OWASP 2023 floor for
// PBKDF2-HMAC-SHA256; stored alongside the blob so a future bump stays readable.
const PBKDF2_ITERS = 210_000;
// EncryptedBlob is the at-rest form of a secret: AES-256-GCM ciphertext plus the
// public KDF parameters needed to re-derive the key from the password. None of
// these fields is secret on its own — only the password (never stored) unlocks it.
export interface EncryptedBlob {
kdf: "PBKDF2-SHA256";
iters: number;
salt: string; // hex, 16 random bytes (PBKDF2 salt)
iv: string; // hex, 12 random bytes (AES-GCM nonce)
ciphertext: string; // hex (includes the GCM auth tag)
}
function toHex(b: Uint8Array): string {
let s = "";
for (const x of b) s += x.toString(16).padStart(2, "0");
return s;
}
function fromHex(h: string): Uint8Array {
const out = new Uint8Array(h.length / 2);
for (let i = 0; i < out.length; i++) {
out[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16);
}
return out;
}
async function deriveAesKey(
password: string,
salt: Uint8Array,
iters: number,
): Promise<CryptoKey> {
const enc = new TextEncoder();
const baseKey = await crypto.subtle.importKey(
"raw",
enc.encode(password),
"PBKDF2",
false,
["deriveKey"],
);
return crypto.subtle.deriveKey(
{ name: "PBKDF2", salt: salt as BufferSource, iterations: iters, hash: "SHA-256" },
baseKey,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
}
// encryptSecret seals `plaintext` under `password` with a fresh random salt+iv.
export async function encryptSecret(
plaintext: Uint8Array,
password: string,
): Promise<EncryptedBlob> {
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await deriveAesKey(password, salt, PBKDF2_ITERS);
const ct = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: iv as BufferSource },
key,
plaintext as BufferSource,
);
return {
kdf: "PBKDF2-SHA256",
iters: PBKDF2_ITERS,
salt: toHex(salt),
iv: toHex(iv),
ciphertext: toHex(new Uint8Array(ct)),
};
}
// WrongPasswordError is thrown when GCM authentication fails on decrypt — almost
// always a wrong password (or a corrupted blob). Callers map it to a friendly
// "contraseña incorrecta" message.
export class WrongPasswordError extends Error {
constructor() {
super("wrong password");
this.name = "WrongPasswordError";
}
}
// decryptSecret re-derives the key from `password` and opens the blob. A wrong
// password makes GCM verification fail, surfaced as WrongPasswordError.
export async function decryptSecret(
blob: EncryptedBlob,
password: string,
): Promise<Uint8Array> {
const key = await deriveAesKey(password, fromHex(blob.salt), blob.iters);
try {
const pt = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: fromHex(blob.iv) as BufferSource },
key,
fromHex(blob.ciphertext) as BufferSource,
);
return new Uint8Array(pt);
} catch {
throw new WrongPasswordError();
}
}
// JSON convenience: encrypt/decrypt a JS value as UTF-8 JSON. We use this to seal
// the whole WalletIdentity object (the private halves) under the password.
export async function encryptJSON(
value: unknown,
password: string,
): Promise<EncryptedBlob> {
return encryptSecret(new TextEncoder().encode(JSON.stringify(value)), password);
}
export async function decryptJSON<T>(
blob: EncryptedBlob,
password: string,
): Promise<T> {
const bytes = await decryptSecret(blob, password);
return JSON.parse(new TextDecoder().decode(bytes)) as T;
}
+69
View File
@@ -0,0 +1,69 @@
// Deterministic identity derivation from a BIP39 mnemonic.
//
// The identity is NOT a loose random keypair: it is derived deterministically
// and reproducibly from a 12-word BIP39 mnemonic (128 bits of entropy). The
// SAME mnemonic always yields the SAME keypair (same sign_pub), which is what
// lets a user recover their account on a new device — or after forgetting the
// local password — without admin intervention: the re-derived identity is byte
// for byte the one already in the bus allowlist.
//
// SCHEME (must be identical at create time and at recovery time):
//
// 1. mnemonic 12 BIP39 words (128-bit entropy + 4-bit checksum)
// 2. seed = BIP39_seed(mnemonic)
// = PBKDF2(HMAC-SHA512, password = NFKD(mnemonic),
// salt = "mnemonic", iterations = 2048, dkLen = 64)
// (the standard BIP39 seed; no extra passphrase)
// 3. signSeed = HKDF-SHA256(ikm = seed, salt = "", info = "unibus-sign-v1", L = 32)
// 4. Ed25519 signing key from signSeed:
// sign_pub = Ed25519.publicKey(signSeed) (32 bytes)
// sign_priv = signSeed || sign_pub (64 bytes; Go's
// ed25519.PrivateKey layout = seed||pub, what the gateway expects)
// 5. kexSeed = HKDF-SHA256(ikm = seed, salt = "", info = "unibus-kex-v1", L = 32)
// 6. X25519 key-exchange key from kexSeed:
// kex_priv = kexSeed (32 bytes; X25519 clamps internally)
// kex_pub = X25519.publicKey(kexSeed) (32 bytes)
//
// The two distinct HKDF `info` labels domain-separate the signing key from the
// key-exchange key so they can never collide. All four halves match cs.Identity
// on the Go side exactly (sign_pub 32, sign_priv 64, kex_pub 32, kex_priv 32),
// so the gateway can act as the user's peer with the derived keys.
import { ed25519, x25519 } from "@noble/curves/ed25519.js";
import { hkdf } from "@noble/hashes/hkdf.js";
import { sha256 } from "@noble/hashes/sha2.js";
import { bytesToHex, concatBytes } from "@noble/hashes/utils.js";
import { mnemonicToSeedSync } from "@scure/bip39";
export const INFO_SIGN = "unibus-sign-v1";
export const INFO_KEX = "unibus-kex-v1";
// WalletIdentity holds the four keypair halves, each lowercase hex. This is the
// shape the gateway's POST /api/session consumes (and a subset — the two public
// halves — is what POST /api/register sends to the bus).
export interface WalletIdentity {
signPub: string; // 64 hex (32-byte Ed25519 public key)
signPriv: string; // 128 hex (64-byte Ed25519 private key, seed||pub)
kexPub: string; // 64 hex (32-byte X25519 public key)
kexPriv: string; // 64 hex (32-byte X25519 private key)
}
// deriveIdentity turns a validated BIP39 mnemonic into the deterministic
// keypair. Pure: the same mnemonic in always produces the same identity out.
export function deriveIdentity(mnemonic: string): WalletIdentity {
const seed = mnemonicToSeedSync(mnemonic.normalize("NFKD")); // 64 bytes
const info = new TextEncoder();
const signSeed = hkdf(sha256, seed, undefined, info.encode(INFO_SIGN), 32);
const kexSeed = hkdf(sha256, seed, undefined, info.encode(INFO_KEX), 32);
const signPub = ed25519.getPublicKey(signSeed);
const signPriv = concatBytes(signSeed, signPub); // Go ed25519.PrivateKey = seed||pub
const kexPub = x25519.getPublicKey(kexSeed);
return {
signPub: bytesToHex(signPub),
signPriv: bytesToHex(signPriv),
kexPub: bytesToHex(kexPub),
kexPriv: bytesToHex(kexSeed),
};
}
+95
View File
@@ -0,0 +1,95 @@
// IndexedDB persistence of the device-local wallet. Only the encrypted private
// key plus the public halves and the display handle are stored — never the
// password, never the BIP39 seed. The private key never leaves the device except
// over TLS to the gateway to open a session (see api.session).
//
// MVP: one active identity per device (keyed by a fixed id). Multi-account on a
// single device is a documented gap.
import type { EncryptedBlob } from "./crypto";
const DB_NAME = "unibus-wallet";
const DB_VERSION = 1;
const STORE = "identity";
const ACTIVE_ID = "active";
// StoredIdentity is one row in IndexedDB. `enc` is the encrypted WalletIdentity
// (all four hex halves); signPub/kexPub are kept in the clear for display and so
// the UI can show who you are without unlocking.
export interface StoredIdentity {
id: string; // always ACTIVE_ID for the single-identity MVP
handle: string;
signPub: string; // 64 hex (public, safe to store in the clear)
kexPub: string; // 64 hex (public)
enc: EncryptedBlob; // encrypted private identity (the secret material)
createdAt: number;
}
function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(STORE)) {
db.createObjectStore(STORE, { keyPath: "id" });
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
function tx<T>(
db: IDBDatabase,
mode: IDBTransactionMode,
fn: (store: IDBObjectStore) => IDBRequest<T>,
): Promise<T> {
return new Promise((resolve, reject) => {
const t = db.transaction(STORE, mode);
const req = fn(t.objectStore(STORE));
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
// getIdentity returns the device's active identity, or null if this device has
// no wallet yet (first visit, or a fresh device awaiting recovery/invite).
export async function getIdentity(): Promise<StoredIdentity | null> {
const db = await openDB();
try {
const row = await tx<StoredIdentity | undefined>(db, "readonly", (s) =>
s.get(ACTIVE_ID),
);
return row ?? null;
} finally {
db.close();
}
}
// hasIdentity is a cheap check for the router: does this device hold a wallet?
export async function hasIdentity(): Promise<boolean> {
return (await getIdentity()) !== null;
}
// putIdentity stores (or replaces) the active identity. Used by both join (new)
// and recover (re-derived): both end with an encrypted private key on the device.
export async function putIdentity(
rec: Omit<StoredIdentity, "id">,
): Promise<void> {
const db = await openDB();
try {
await tx(db, "readwrite", (s) => s.put({ id: ACTIVE_ID, ...rec }));
} finally {
db.close();
}
}
// clearIdentity removes the wallet from this device (e.g. "forget this device").
export async function clearIdentity(): Promise<void> {
const db = await openDB();
try {
await tx(db, "readwrite", (s) => s.delete(ACTIVE_ID));
} finally {
db.close();
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ export default defineConfig({
// través de él. En producción el gateway sirve el dist embebido y no hay proxy.
server: {
host: true,
port: 5181,
port: 5183,
proxy: { "/api": "http://127.0.0.1:8481" },
},
});