// 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 }