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>
This commit is contained in:
agent
2026-06-18 23:38:14 +02:00
parent 4dea99a524
commit 53f8a4a3d6
5 changed files with 375 additions and 1 deletions
+2 -1
View File
@@ -10,6 +10,8 @@ require (
github.com/nats-io/nats.go v1.49.0 github.com/nats-io/nats.go v1.49.0
github.com/nats-io/nkeys v0.4.15 github.com/nats-io/nkeys v0.4.15
github.com/oklog/ulid/v2 v2.1.0 github.com/oklog/ulid/v2 v2.1.0
github.com/tyler-smith/go-bip39 v1.1.0
golang.org/x/crypto v0.51.0
golang.org/x/time v0.15.0 golang.org/x/time v0.15.0
modernc.org/sqlite v1.47.0 modernc.org/sqlite v1.47.0
) )
@@ -26,7 +28,6 @@ require (
github.com/nats-io/nuid v1.0.1 // indirect github.com/nats-io/nuid v1.0.1 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/mobile v0.0.0-20260602190626-68735029466e // indirect golang.org/x/mobile v0.0.0-20260602190626-68735029466e // indirect
golang.org/x/mod v0.36.0 // indirect golang.org/x/mod v0.36.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.20.0 // indirect
+8
View File
@@ -35,18 +35,26 @@ github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNs
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8=
github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/mobile v0.0.0-20260602190626-68735029466e h1:YxPXu/HWDTcSSrzSX+sCltsfcNCa/ZYVG43oslMouNU= golang.org/x/mobile v0.0.0-20260602190626-68735029466e h1:YxPXu/HWDTcSSrzSX+sCltsfcNCa/ZYVG43oslMouNU=
golang.org/x/mobile v0.0.0-20260602190626-68735029466e/go.mod h1:ltIbhcRzKgwHa4ZxKJeiv0nyzcXUUYCqMyO0Y+vPmXw= golang.org/x/mobile v0.0.0-20260602190626-68735029466e/go.mod h1:ltIbhcRzKgwHa4ZxKJeiv0nyzcXUUYCqMyO0Y+vPmXw=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
+49
View File
@@ -0,0 +1,49 @@
package mobile
import (
"encoding/hex"
"testing"
)
// TestDeriveParityWithWeb pins the Go wallet derivation to the TypeScript one
// (web/src/wallet/derive.ts). The canonical BIP39 test mnemonic must derive to this
// exact Ed25519 sign_pub — the same value the uniweb client showed for this phrase.
// If this fails, web and mobile would derive different identities from the same seed
// and the "same account on both devices" guarantee breaks.
func TestDeriveParityWithWeb(t *testing.T) {
const mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
const wantSignPub = "34302746268e7370d35940e1bcef8c0b1c13a857ea6209e6ecc6e9b3af06b3c8"
id, err := deriveIdentity(mnemonic)
if err != nil {
t.Fatalf("deriveIdentity: %v", err)
}
if got := hex.EncodeToString(id.SignPub); got != wantSignPub {
t.Fatalf("sign_pub mismatch:\n got %s\n want %s", got, wantSignPub)
}
if len(id.SignPriv) != 64 || len(id.KexPub) != 32 || len(id.KexPriv) != 32 {
t.Fatalf("bad key lengths: signPriv=%d kexPub=%d kexPriv=%d",
len(id.SignPriv), len(id.KexPub), len(id.KexPriv))
}
// sign_priv is Go's ed25519 layout: seed || pub, so its tail must equal sign_pub.
if got := hex.EncodeToString(id.SignPriv[32:]); got != wantSignPub {
t.Fatalf("sign_priv tail != sign_pub:\n got %s\n want %s", got, wantSignPub)
}
}
// TestMnemonicRoundTrip checks a freshly generated phrase validates and derives.
func TestMnemonicRoundTrip(t *testing.T) {
m, err := NewMnemonic()
if err != nil {
t.Fatalf("NewMnemonic: %v", err)
}
if !ValidateMnemonic(m) {
t.Fatalf("generated mnemonic failed validation: %q", m)
}
if _, err := deriveIdentity(m); err != nil {
t.Fatalf("deriveIdentity(fresh): %v", err)
}
if ValidateMnemonic("not a real mnemonic at all please") {
t.Fatal("garbage phrase validated as a mnemonic")
}
}
+266
View File
@@ -0,0 +1,266 @@
// 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
}
+50
View File
@@ -0,0 +1,50 @@
package client
// This file exposes two read helpers the mobile binding needs but that the core
// client did not surface yet: the peer's own endpoint id, and the cluster member
// directory (endpoint id -> human handle). Both are additive and read-only.
// EndpointID returns this peer's stable endpoint id, base64url(sha256(signPub)),
// the value the bus stamps as the sender of every frame this peer publishes.
func (c *Client) EndpointID() string { return c.endpoint }
// DirectoryEntry maps a member's stable endpoint id to a human handle, as served
// by the control plane's GET /directory. A client uses it to render readable names
// instead of raw endpoint ids on incoming frames.
type DirectoryEntry struct {
SignPub string // 64-hex Ed25519 public key
Endpoint string // base64url-nopad, == EndpointID(signPub)
Handle string
Role string
}
type directoryMemberWire struct {
SignPub string `json:"sign_pub"`
Endpoint string `json:"endpoint"`
Handle string `json:"handle"`
Role string `json:"role"`
}
type directoryResp struct {
Members []directoryMemberWire `json:"members"`
}
// Directory fetches the cluster-wide member directory (endpoint id -> handle). Any
// active user may read it; the request is signed like every other control-plane
// call. Returns the active members only (the server filters to status=active).
func (c *Client) Directory() ([]DirectoryEntry, error) {
var resp directoryResp
if err := c.doJSON("GET", "/directory", nil, &resp); err != nil {
return nil, err
}
out := make([]DirectoryEntry, 0, len(resp.Members))
for _, m := range resp.Members {
out = append(out, DirectoryEntry{
SignPub: m.SignPub,
Endpoint: m.Endpoint,
Handle: m.Handle,
Role: m.Role,
})
}
return out, nil
}