Files
unibus/mobile/unibus.go
T
agent 53f8a4a3d6 feat(mobile): reconstruir+ampliar binding gomobile para paridad con la web
El wrapper mobile/unibus.go se habia perdido del repo (solo quedaba compilado
en el .aar del 5 jun). Se reconstruye y amplia con:

- Wallet BIP39 determinista: NewMnemonic, ValidateMnemonic, DeriveAndSaveIdentity.
  Deriva la MISMA identidad que uniweb (web/src/wallet/derive.ts): PBKDF2-BIP39 ->
  HKDF-SHA256(info unibus-sign-v1 / unibus-kex-v1) -> Ed25519 + X25519. Test de
  paridad contra el vector de oro (mnemonica abandon...about -> sign_pub
  34302746...b3c8) garantiza misma cuenta web<->movil byte a byte.
- Selector de salas: Session.ListMyRooms() -> JSON [{id,subject,mode,role}].
- Nombres legibles: Session.Directory() + Client.Directory()/EndpointID() nuevos
  en pkg/client (GET /directory firmado).
- HasIdentity/SignPubAt para el onboarding.

Aditivo; build/vet/test del modulo verdes (incluido TestDeriveParityWithWeb).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 23:38:14 +02:00

267 lines
9.4 KiB
Go

// Package mobile is the gomobile binding surface for the unibus client. It wraps
// pkg/client behind a flat API (strings, []byte, JSON, a small interface) that
// gobind can export to Kotlin/Swift, so an Android or iOS app speaks the real bus
// protocol — NATS data plane, signed HTTP control plane and end-to-end crypto —
// with no reimplementation of either the protocol or the cryptography.
//
// The wrapper is intentionally thin: every method delegates to pkg/client and only
// reshapes types into something gobind understands (gomobile cannot export slices
// of structs, so list results come back as JSON the caller decodes).
package mobile
import (
"crypto/ed25519"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
bip39 "github.com/tyler-smith/go-bip39"
"golang.org/x/crypto/curve25519"
"golang.org/x/crypto/hkdf"
cs "fn-registry/functions/cybersecurity"
"github.com/enmanuel/unibus/pkg/client"
"github.com/enmanuel/unibus/pkg/frame"
"github.com/enmanuel/unibus/pkg/room"
)
// HKDF domain-separation labels. These MUST stay identical to the uniweb wallet
// derivation (web/src/wallet/derive.ts) so the same BIP39 mnemonic yields the same
// identity (same sign_pub) on web and mobile — that shared identity is what lets a
// user reach the same rooms from either device. Changing a label forks the wallet.
const (
infoSign = "unibus-sign-v1"
infoKex = "unibus-kex-v1"
)
// FrameListener receives decrypted messages from a subscribed room. OnFrame is
// invoked on a NATS delivery goroutine; the app must hop to its UI thread before
// touching UI state.
type FrameListener interface {
OnFrame(roomID, sender, msgID, text string)
}
// NewMnemonic returns a fresh 12-word BIP39 recovery phrase (128 bits of entropy
// from the system CSPRNG). Show it to the user exactly once and never persist it:
// it is the only way to recover the identity on another device.
func NewMnemonic() (string, error) {
ent, err := bip39.NewEntropy(128)
if err != nil {
return "", err
}
return bip39.NewMnemonic(ent)
}
// ValidateMnemonic reports whether m is a valid 12-word BIP39 phrase (every word in
// the wordlist and a correct checksum). A phrase that fails this must not be used
// to derive an identity.
func ValidateMnemonic(m string) bool {
return bip39.IsMnemonicValid(m)
}
// hkdfExpand derives 32 bytes from ikm under an info label with an empty salt,
// matching the web's hkdf(sha256, seed, undefined, info, 32).
func hkdfExpand(ikm []byte, info string) ([]byte, error) {
r := hkdf.New(sha256.New, ikm, nil, []byte(info))
out := make([]byte, 32)
if _, err := io.ReadFull(r, out); err != nil {
return nil, err
}
return out, nil
}
// deriveIdentity reproduces the uniweb wallet derivation byte for byte:
//
// seed = BIP39_seed(mnemonic) (PBKDF2, salt "mnemonic", 64 bytes)
// signSeed = HKDF-SHA256(seed, salt="", info=sign, 32)
// kexSeed = HKDF-SHA256(seed, salt="", info=kex, 32)
// Ed25519 signing key from signSeed (priv = seed||pub, Go's layout)
// X25519 key-exchange key from kexSeed
//
// The result is exactly a cs.Identity (sign_pub 32, sign_priv 64, kex_pub 32,
// kex_priv 32), so the bus accepts the derived keys as a first-class peer.
func deriveIdentity(mnemonic string) (cs.Identity, error) {
if !bip39.IsMnemonicValid(mnemonic) {
return cs.Identity{}, fmt.Errorf("invalid mnemonic")
}
seed := bip39.NewSeed(mnemonic, "") // 64-byte BIP39 seed (salt "mnemonic", no passphrase)
signSeed, err := hkdfExpand(seed, infoSign)
if err != nil {
return cs.Identity{}, err
}
kexSeed, err := hkdfExpand(seed, infoKex)
if err != nil {
return cs.Identity{}, err
}
signPriv := ed25519.NewKeyFromSeed(signSeed) // 64 bytes = signSeed || sign_pub
kexPub, err := curve25519.X25519(kexSeed, curve25519.Basepoint)
if err != nil {
return cs.Identity{}, err
}
return cs.Identity{
SignPub: append([]byte(nil), signPriv[32:]...),
SignPriv: append([]byte(nil), signPriv...),
KexPub: kexPub,
KexPriv: append([]byte(nil), kexSeed...),
}, nil
}
// DeriveAndSaveIdentity derives the deterministic identity from a BIP39 mnemonic
// and writes it to path, overwriting any previous identity on this device — which
// is exactly what recovering on a new device (or after a reset) needs. It returns
// the sign_pub hex so the UI can show which identity the phrase reconstructs. The
// mnemonic itself is never stored; only the derived keypair is persisted (0600).
func DeriveAndSaveIdentity(path, mnemonic string) (string, error) {
id, err := deriveIdentity(mnemonic)
if err != nil {
return "", err
}
// WriteNewIdentity refuses to overwrite an existing file; recover/re-create must
// replace the device's identity, so drop the old one first.
_ = os.Remove(path)
if err := client.WriteNewIdentity(path, id); err != nil {
return "", err
}
return hex.EncodeToString(id.SignPub), nil
}
// HasIdentity reports whether a saved identity already exists at path, so the UI can
// decide between the onboarding screen and going straight to connect.
func HasIdentity(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// SignPubAt returns the sign_pub hex of the identity saved at path (for showing the
// current account), or an error if none is saved.
func SignPubAt(path string) (string, error) {
id, err := client.LoadIdentity(path)
if err != nil {
return "", err
}
return hex.EncodeToString(id.SignPub), nil
}
// GenerateIdentity creates a fresh random identity at path if none exists yet.
// Retained for callers that want a throwaway local identity instead of a
// recoverable BIP39 wallet.
func GenerateIdentity(path string) error {
if _, err := os.Stat(path); err == nil {
return nil
}
id, err := cs.GenerateIdentity()
if err != nil {
return err
}
return client.WriteNewIdentity(path, id)
}
// Session is a connected unibus peer. Create it with NewSession and release it with
// Close when the app stops.
type Session struct {
c *client.Client
endpointID string
}
// NewSession loads the identity at idPath and connects to the bus. natsURL is the
// data plane (e.g. nats://host:4250) and ctrlURL is the control-plane HTTP endpoint
// (e.g. http://host:8470).
func NewSession(idPath, natsURL, ctrlURL string) (*Session, error) {
id, err := client.LoadIdentity(idPath)
if err != nil {
return nil, err
}
c, err := client.New(natsURL, ctrlURL, id)
if err != nil {
return nil, err
}
return &Session{c: c, endpointID: frame.EndpointID(id.SignPub)}, nil
}
// Close disconnects the peer from the bus.
func (s *Session) Close() error { return s.c.Close() }
// EndpointID returns this peer's stable endpoint id (the sender stamped on frames).
func (s *Session) EndpointID() string { return s.endpointID }
// CreateRoom opens a room on the given subject. mode is "matrix" for the encrypted,
// persisted and signed policy, or "nats" for plain cleartext. Returns the room id
// used by Join, Publish and Subscribe.
func (s *Session) CreateRoom(subject, mode string) (string, error) {
p := room.ModeNATS
if mode == "matrix" {
p = room.ModeMatrix
}
return s.c.CreateRoom(subject, p)
}
// Join prepares the session to publish to and receive from a room, fetching the
// room key when the room is encrypted.
func (s *Session) Join(roomID string) error { return s.c.Join(roomID) }
// Publish sends a UTF-8 text message to the room.
func (s *Session) Publish(roomID, text string) error { return s.c.Publish(roomID, []byte(text)) }
// Subscribe streams decrypted messages of the room to the listener until the
// session is closed. For persisted (matrix) rooms this also replays the room's
// history (JetStream DeliverAll) before live messages, so opening a room shows past
// messages — the same behaviour as the web client.
func (s *Session) Subscribe(roomID string, l FrameListener) error {
_, err := s.c.Subscribe(roomID, func(f frame.Frame, plaintext []byte) {
l.OnFrame(roomID, f.Sender, f.MsgID, string(plaintext))
})
return err
}
// ListMyRooms returns every room this peer is a member of, as a JSON array of
// objects {id, subject, mode, role} where mode is "matrix" or "nats". Returned as
// JSON because gomobile cannot export a slice of structs.
func (s *Session) ListMyRooms() (string, error) {
rooms, err := s.c.ListMyRooms()
if err != nil {
return "", err
}
type roomJSON struct {
ID string `json:"id"`
Subject string `json:"subject"`
Mode string `json:"mode"`
Role string `json:"role"`
}
out := make([]roomJSON, 0, len(rooms))
for _, r := range rooms {
mode := "nats"
if r.Policy.Encrypt {
mode = "matrix"
}
out = append(out, roomJSON{ID: r.RoomID, Subject: r.Subject, Mode: mode, Role: r.Role})
}
b, err := json.Marshal(out)
return string(b), err
}
// Directory returns the cluster member directory as a JSON array of objects
// {sign_pub, endpoint, handle, role}, so the UI can map a frame's endpoint id to a
// readable handle. Returns "[]" semantics via JSON; callers degrade to short ids if
// a handle is missing.
func (s *Session) Directory() (string, error) {
entries, err := s.c.Directory()
if err != nil {
return "", err
}
type dirJSON struct {
SignPub string `json:"sign_pub"`
Endpoint string `json:"endpoint"`
Handle string `json:"handle"`
Role string `json:"role"`
}
out := make([]dirJSON, 0, len(entries))
for _, e := range entries {
out = append(out, dirJSON{SignPub: e.SignPub, Endpoint: e.Endpoint, Handle: e.Handle, Role: e.Role})
}
b, err := json.Marshal(out)
return string(b), err
}