From 53f8a4a3d65da1ce5fc72d83708401e42a97a89a Mon Sep 17 00:00:00 2001 From: agent Date: Thu, 18 Jun 2026 23:38:14 +0200 Subject: [PATCH] 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) --- go.mod | 3 +- go.sum | 8 ++ mobile/derive_test.go | 49 ++++++++ mobile/unibus.go | 266 ++++++++++++++++++++++++++++++++++++++++ pkg/client/directory.go | 50 ++++++++ 5 files changed, 375 insertions(+), 1 deletion(-) create mode 100644 mobile/derive_test.go create mode 100644 mobile/unibus.go create mode 100644 pkg/client/directory.go diff --git a/go.mod b/go.mod index 235a01db..bb9eaeb8 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,8 @@ require ( github.com/nats-io/nats.go v1.49.0 github.com/nats-io/nkeys v0.4.15 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 modernc.org/sqlite v1.47.0 ) @@ -26,7 +28,6 @@ require ( github.com/nats-io/nuid v1.0.1 // indirect github.com/ncruces/go-strftime v1.0.0 // 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/mod v0.36.0 // indirect golang.org/x/sync v0.20.0 // indirect diff --git a/go.sum b/go.sum index 25fc23ff..62f3223a 100644 --- a/go.sum +++ b/go.sum @@ -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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 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/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/go.mod h1:ltIbhcRzKgwHa4ZxKJeiv0nyzcXUUYCqMyO0Y+vPmXw= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= 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/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.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/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/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= diff --git a/mobile/derive_test.go b/mobile/derive_test.go new file mode 100644 index 00000000..34b70124 --- /dev/null +++ b/mobile/derive_test.go @@ -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") + } +} diff --git a/mobile/unibus.go b/mobile/unibus.go new file mode 100644 index 00000000..70840169 --- /dev/null +++ b/mobile/unibus.go @@ -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 +} diff --git a/pkg/client/directory.go b/pkg/client/directory.go new file mode 100644 index 00000000..e1293a4a --- /dev/null +++ b/pkg/client/directory.go @@ -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 +}