package client import ( "crypto/rand" "encoding/base64" "encoding/json" "errors" mrand "math/rand" "os" "path/filepath" "strings" "sync" "time" "forge.redroom.link/yves/meowlib" "github.com/ProtonMail/gopenpgp/v2/helper" "github.com/google/uuid" doubleratchet "github.com/status-im/doubleratchet" ) const maxHiddenCount = 30 // Package-level random number generator with mutex for thread-safe access var ( rngMu sync.Mutex rng = mrand.New(mrand.NewSource(time.Now().UnixNano())) ) type Identity struct { Nickname string `json:"nickname,omitempty"` DefaultAvatar string `json:"default_avatar,omitempty"` Avatars []Avatar `json:"avatars,omitempty"` RootKp *meowlib.KeyPair `json:"id_kp,omitempty"` Status string `json:"status,omitempty"` Peers PeerStorage `json:"peers,omitempty"` HiddenPeers [][]byte `json:"hidden_peers,omitempty"` Personae PeerList `json:"faces,omitempty"` Device *meowlib.KeyPair `json:"device,omitempty"` KnownServers ServerList `json:"known_servers,omitempty"` MessageServers ServerStorage `json:"message_servers,omitempty"` DefaultDbPassword string `json:"default_db_password,omitempty"` DbPasswordStore bool `json:"db_password_store,omitempty"` OwnedDevices PeerList `json:"owned_devices,omitempty"` StaticMtkServerPaths []ServerList `json:"static_mtk_server_paths,omitempty"` DynamicMtkServeRules []string `json:"dynamic_mtk_serve_rules,omitempty"` InvitationTimeout int `json:"invitation_timeout,omitempty"` Uuid string `json:"uuid,omitempty"` unlockedHiddenPeers PeerList } func CreateIdentity(nickname string) (*Identity, error) { var id Identity var err error id.Nickname = nickname id.Uuid = uuid.New().String() id.RootKp, err = meowlib.NewKeyPair() GetConfig().me = &id id.MessageServers = ServerStorage{DbFile: uuid.NewString()} id.generateRandomHiddenStuff() err = id.CreateFolder() if err != nil { return nil, err } return &id, nil } func (id *Identity) CreateFolder() error { err := os.MkdirAll(filepath.Join(GetConfig().StoragePath, id.Uuid), 0700) if err != nil { return err } return nil } func (id *Identity) WipeFolder() error { err := os.RemoveAll(filepath.Join(GetConfig().StoragePath, id.Uuid)) if err != nil { return err } return nil } // InvitationStep1 creates a minimal pending peer with only a temporary keypair and returns // the InvitationInitPayload to be transmitted to the invitee (via file, QR code, or server). // Full keypairs are only generated in InvitationStep3, after the invitee's answer is received. func (id *Identity) InvitationStep1(MyName string, ContactName string, MessageServerUids []string, InvitationMessage string) (*meowlib.InvitationInitPayload, *Peer, error) { var peer Peer var err error peer.Uid = uuid.New().String() peer.Name = ContactName peer.MyName = MyName peer.InvitationId = uuid.New().String() peer.InvitationMessage = InvitationMessage peer.MyPullServers = MessageServerUids // Temporary keypair: public key is sent to invitee for step-2 encryption and as // the server-side lookup key where the invitee will post their answer. peer.InvitationKp, err = meowlib.NewKeyPair() if err != nil { return nil, nil, err } id.Peers.StorePeer(&peer) payload := &meowlib.InvitationInitPayload{ Uuid: peer.InvitationId, Name: MyName, PublicKey: peer.InvitationKp.Public, InvitationMessage: InvitationMessage, } return payload, &peer, nil } // InvitationStep2 creates the invitee's peer entry from the received InvitationInitPayload // and returns the peer. The invitee generates their full keypairs here. // The initiator's temporary public key (payload.PublicKey) is used both as the encryption // target for the step-2 answer and as the server-side lookup address. func (id *Identity) InvitationStep2(MyName string, ContactName string, MessageServerUids []string, payload *meowlib.InvitationInitPayload) (*Peer, error) { var peer Peer var err error peer.Uid = uuid.New().String() peer.MyIdentity, err = meowlib.NewKeyPair() if err != nil { return nil, err } peer.MyEncryptionKp, err = meowlib.NewKeyPair() if err != nil { return nil, err } peer.MyLookupKp, err = meowlib.NewKeyPair() if err != nil { return nil, err } if ContactName != "" { peer.Name = ContactName } else { peer.Name = payload.Name } // The initiator's temp key is used for both encrypting the answer and as destination. peer.ContactEncryption = payload.PublicKey peer.ContactLookupKey = payload.PublicKey peer.InvitationId = payload.Uuid peer.InvitationMessage = payload.InvitationMessage peer.MyPullServers = MessageServerUids peer.MyName = MyName id.Peers.StorePeer(&peer) return &peer, nil } // InvitationStep3 is called by the initiator after receiving and decrypting the invitee's // ContactCard (step-2 answer). It generates the initiator's full keypairs and DR material, // updates the pending peer with the invitee's contact info, and returns the initiator's // full ContactCard to be sent to the invitee (STEP_3_SEND). func (id *Identity) InvitationStep3(inviteeContact *meowlib.ContactCard) (*meowlib.ContactCard, *Peer, error) { var err error peer := id.Peers.GetFromInvitationId(inviteeContact.InvitationId) if peer == nil { return nil, nil, errors.New("no pending peer found for invitation id " + inviteeContact.InvitationId) } // Generate full keypairs now that the invitee's identity is known. peer.MyIdentity, err = meowlib.NewKeyPair() if err != nil { return nil, nil, err } peer.MyEncryptionKp, err = meowlib.NewKeyPair() if err != nil { return nil, nil, err } peer.MyLookupKp, err = meowlib.NewKeyPair() if err != nil { return nil, nil, err } symKeyBytes := make([]byte, 32) if _, err = rand.Read(symKeyBytes); err != nil { return nil, nil, err } peer.MySymKey = base64.StdEncoding.EncodeToString(symKeyBytes) // Generate DR keypair and root key. drKp, err := doubleratchet.DefaultCrypto{}.GenerateDH() if err != nil { return nil, nil, err } peer.DrKpPrivate = base64.StdEncoding.EncodeToString(drKp.PrivateKey()) peer.DrKpPublic = base64.StdEncoding.EncodeToString(drKp.PublicKey()) drRootKey := make([]byte, 32) if _, err = rand.Read(drRootKey); err != nil { return nil, nil, err } peer.DrRootKey = base64.StdEncoding.EncodeToString(drRootKey) peer.DrInitiator = true // Store invitee contact info. peer.ContactPublicKey = inviteeContact.ContactPublicKey peer.ContactEncryption = inviteeContact.EncryptionPublicKey peer.ContactLookupKey = inviteeContact.LookupPublicKey for _, srv := range inviteeContact.PullServers { peer.ContactPullServers = append(peer.ContactPullServers, srv.GetUid()) newsrv, err := CreateServerFromUid(srv.GetUid()) if err == nil { id.MessageServers.StoreServerIfNotExists(newsrv) } } // Drop the temporary invitation keypair — no longer needed. peer.InvitationKp = nil id.Peers.StorePeer(peer) return peer.GetMyContact(), peer, nil } // InvitationStep4 is called by the invitee upon receiving the initiator's full ContactCard // (carried as a regular UserMessage with invitation.step=3). It finalizes the peer entry. func (id *Identity) InvitationStep4(initiatorContact *meowlib.ContactCard) error { var err error var newsrv *Server for _, srv := range initiatorContact.PullServers { newsrv, err = CreateServerFromUid(srv.GetUid()) if err != nil { return err } id.MessageServers.StoreServerIfNotExists(newsrv) } peer := id.Peers.GetFromInvitationId(initiatorContact.InvitationId) if peer == nil { return errors.New("no pending peer found for invitation id " + initiatorContact.InvitationId) } peer.ContactPublicKey = initiatorContact.ContactPublicKey peer.ContactEncryption = initiatorContact.EncryptionPublicKey peer.ContactLookupKey = initiatorContact.LookupPublicKey peer.MySymKey = initiatorContact.SymetricKey peer.DrRootKey = initiatorContact.DrRootKey peer.ContactDrPublicKey = initiatorContact.DrPublicKey peer.DrInitiator = false for _, srv := range initiatorContact.PullServers { peer.ContactPullServers = append(peer.ContactPullServers, srv.GetUid()) } id.Peers.StorePeer(peer) return nil } // CheckInvitation checks if the received ContactCard is an answer to one of our pending // invitations. Returns true when it is, with the proposed and received nicknames. func (id *Identity) CheckInvitation(ReceivedContact *meowlib.ContactCard) (isAnswer bool, proposedNick string, receivedNick string, invitationMessage string) { return id.Peers.CheckInvitation(ReceivedContact) } // LoadIdentity loads an identity from an encrypted file func LoadIdentity(filename string, password string) (*Identity, error) { var id Identity err := GetConfig().SetMemPass(password) if err != nil { return nil, err } GetConfig().IdentityFile = filename indata, err := os.ReadFile(filename) if err != nil { return nil, err } pass, err := helper.DecryptMessageWithPassword([]byte(password), string(indata)) if err != nil { return nil, err } err = json.Unmarshal([]byte(pass), &id) if err != nil { return nil, err } GetConfig().me = &id if id.Peers.DbFile != "" { id.Peers.LoadPeers(password) } return &id, err } func (id *Identity) Save() error { if GetConfig().IdentityFile == "" { return errors.New("identity filename empty") } b, _ := json.Marshal(id) password, err := GetConfig().GetMemPass() if err != nil { return err } armor, err := helper.EncryptMessageWithPassword([]byte(password), string(b)) if err != nil { return err } err = os.WriteFile(GetConfig().IdentityFile, []byte(armor), 0600) return err } func (id *Identity) TryUnlockHidden(password string) error { found := false for _, encPeer := range id.HiddenPeers { p := Peer{} jsonPeer, err := meowlib.SymDecrypt(password, encPeer) if err == nil { err = json.Unmarshal(jsonPeer, &p) if err != nil { return err } p.dbPassword = password id.unlockedHiddenPeers = append(id.unlockedHiddenPeers, &p) found = true } } if found { return nil } return errors.New("no peer found") } /* func (id *Identity) HidePeer(peerIdx int, password string) error { serializedPeer, err := json.Marshal(id.Peers[peerIdx]) if err != nil { return err } encrypted, err := meowlib.SymEncrypt(password, serializedPeer) if err != nil { return err } // add encrypted peer data id.HiddenPeers = append(id.HiddenPeers, encrypted) // remove clear text peer id.Peers = append(id.Peers[:peerIdx], id.Peers[peerIdx+1:]...) return nil }*/ func (id *Identity) generateRandomHiddenStuff() error { var err error rngMu.Lock() count := rng.Intn(maxHiddenCount) + 1 rngMu.Unlock() for i := 1; i < count; i++ { var p Peer p.Uid = uuid.New().String() p.Name = randomLenString(4, 20) p.MyEncryptionKp, err = meowlib.NewKeyPair() if err != nil { return err } p.MyIdentity, err = meowlib.NewKeyPair() if err != nil { return err } p.MyLookupKp, err = meowlib.NewKeyPair() if err != nil { return err } p.Name = randomLenString(4, 20) p.ContactPublicKey = p.MyLookupKp.Public p.ContactEncryption = p.MyIdentity.Public p.ContactLookupKey = p.MyEncryptionKp.Public p.dbPassword = randomLenString(8, 14) // p.Contact.AddUrls([]string{randomLenString(14, 60), randomLenString(14, 60)}) // todo add servers id.Peers.StorePeer(&p) //id.HidePeer(0, randomLenString(8, 14)) // TODO Add random conversations } return nil } type BackgroundJob struct { RootPublic string `json:"root_public,omitempty"` Device *meowlib.KeyPair `json:"device,omitempty"` Jobs []RequestsJob `json:"jobs,omitempty"` } type RequestsJob struct { Server *Server `json:"server,omitempty"` LookupKeys []*meowlib.KeyPair `json:"lookup_keys,omitempty"` } func (id *Identity) GetRequestJobs() []RequestsJob { var list []RequestsJob srvs := map[string]*RequestsJob{} // get all servers servers, err := id.MessageServers.LoadAllServers() if err == nil { // build a server map for _, server := range servers { var rj RequestsJob rj.Server = server srvs[server.GetServerCard().GetUid()] = &rj } // add ids to the map peers, err := id.Peers.GetPeers() if err != nil { return nil } for _, peer := range peers { for _, server := range peer.MyPullServers { if srvs[server] == nil { continue } if peer.MyLookupKp != nil { // Active peer — use the permanent lookup key. srvs[server].LookupKeys = append(srvs[server].LookupKeys, peer.MyLookupKp) } else if peer.InvitationKp != nil { // Step-1 pending peer — poll using the temp invitation keypair so the // server-stored step-2 answer can be retrieved. srvs[server].LookupKeys = append(srvs[server].LookupKeys, peer.InvitationKp) } } } // add hidden peers for _, peer := range id.unlockedHiddenPeers { for _, server := range peer.MyPullServers { srvs[server].LookupKeys = append(srvs[server].LookupKeys, peer.MyLookupKp) } } // todo add garbage // todo random reorder // build list for _, srv := range srvs { if len(srv.LookupKeys) > 0 { list = append(list, *srv) } } } return list } func (id *Identity) SaveBackgroundJob() error { var bj BackgroundJob bj.Jobs = id.GetRequestJobs() bj.RootPublic = id.RootKp.Public bj.Device = id.Device jsonjobs, err := json.Marshal(bj) if err != nil { return err } id.CreateFolder() err = os.WriteFile(filepath.Join(GetConfig().StoragePath, id.Uuid, ".jobs"), jsonjobs, 0600) if err != nil { return err } return nil } func randomLenString(min int, max int) string { rngMu.Lock() defer rngMu.Unlock() length := rng.Intn(max-min+1) + min return randomStringLocked(length) } func randomString(n int) string { rngMu.Lock() defer rngMu.Unlock() return randomStringLocked(n) } // randomStringLocked generates a random string of length n. // Must be called with rngMu already locked. func randomStringLocked(n int) string { const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" var b strings.Builder b.Grow(n) for i := 0; i < n; i++ { b.WriteByte(letterBytes[rng.Intn(len(letterBytes))]) } return b.String() }