diff --git a/app/plugins/contactRetry_test.go b/app/plugins/contactRetry_test.go index ab79c13..0543c99 100644 --- a/app/plugins/contactRetry_test.go +++ b/app/plugins/contactRetry_test.go @@ -44,6 +44,7 @@ func TestContactRetryQueue(t *testing.T) { } } } + time.Sleep(time.Second) } // We should very quickly become connecting... diff --git a/functionality/filesharing/image_previews.go b/functionality/filesharing/image_previews.go index 6bbc794..c07634b 100644 --- a/functionality/filesharing/image_previews.go +++ b/functionality/filesharing/image_previews.go @@ -38,14 +38,14 @@ func (i *ImagePreviewsFunctionality) OnEvent(ev event.Event, profile peer.CwtchP case event.NewMessageFromPeer: ci, err := profile.FetchConversationInfo(ev.Data["RemotePeer"]) if err == nil { - if ci.Accepted { + if ci.GetPeerAC().RenderImages { i.handleImagePreviews(profile, &ev, ci.ID, ci.ID) } } case event.NewMessageFromGroup: ci, err := profile.FetchConversationInfo(ev.Data["RemotePeer"]) if err == nil { - if ci.Accepted { + if ci.GetPeerAC().RenderImages { i.handleImagePreviews(profile, &ev, ci.ID, ci.ID) } } @@ -90,7 +90,7 @@ func (i *ImagePreviewsFunctionality) OnContactReceiveValue(profile peer.CwtchPee _, zone, path := path.GetScopeZonePath() if exists && zone == attr.ProfileZone && path == constants.CustomProfileImageKey { // We only download from accepted conversations - if conversation.Accepted { + if conversation.GetPeerAC().RenderImages { fileKey := value basepath := i.downloadFolder fsf := FunctionalityGate() @@ -123,6 +123,16 @@ func (i *ImagePreviewsFunctionality) OnContactReceiveValue(profile peer.CwtchPee // handleImagePreviews checks settings and, if appropriate, auto-downloads any images func (i *ImagePreviewsFunctionality) handleImagePreviews(profile peer.CwtchPeer, ev *event.Event, conversationID, senderID int) { if profile.IsFeatureEnabled(constants.FileSharingExperiment) && profile.IsFeatureEnabled(constants.ImagePreviewsExperiment) { + ci, err := profile.GetConversationInfo(senderID) + if err != nil { + log.Errorf("attempted to call handleImagePreviews with unknown conversation: %v", senderID) + return + } + + if !ci.GetPeerAC().ShareFiles || !ci.GetPeerAC().RenderImages { + log.Infof("refusing to autodownload files from sender: %v. conversation AC does not permit image rendering", senderID) + return + } // Short-circuit failures // Don't auto-download images if the download path does not exist. @@ -142,7 +152,7 @@ func (i *ImagePreviewsFunctionality) handleImagePreviews(profile peer.CwtchPeer, // Now look at the image preview experiment var cm model.MessageWrapper - err := json.Unmarshal([]byte(ev.Data[event.Data]), &cm) + err = json.Unmarshal([]byte(ev.Data[event.Data]), &cm) if err == nil && cm.Overlay == model.OverlayFileSharing { log.Debugf("Received File Sharing Message") var fm OverlayMessage diff --git a/model/constants/attributes.go b/model/constants/attributes.go index 782ecd3..0856f36 100644 --- a/model/constants/attributes.go +++ b/model/constants/attributes.go @@ -67,3 +67,7 @@ const ProfileAttribute3 = "profile-attribute-3" // Description is used on server contacts, const Description = "description" + +// Used to store the status of acl migrations +const ACLVersion = "acl-version" +const ACLVersionOne = "acl-v1" diff --git a/model/conversation.go b/model/conversation.go index 02137e6..324fdb4 100644 --- a/model/conversation.go +++ b/model/conversation.go @@ -4,19 +4,31 @@ import ( "cwtch.im/cwtch/model/attr" "cwtch.im/cwtch/model/constants" "encoding/json" + "git.openprivacy.ca/openprivacy/log" "time" ) // AccessControl is a type determining client assigned authorization to a peer +// for a given conversation type AccessControl struct { - Blocked bool // Any attempts from this handle to connect are blocked - Read bool // Allows a handle to access the conversation - Append bool // Allows a handle to append new messages to the conversation + Blocked bool // Any attempts from this handle to connect are blocked overrides all other settings + + // Basic Conversation Rights + Read bool // Allows a handle to access the conversation + Append bool // Allows a handle to append new messages to the conversation + + AutoConnect bool // Profile should automatically try to connect with peer + ExchangeAttributes bool // Profile should automatically exchange attributes like Name, Profile Image, etc. + + // Extension Related Permissions + ShareFiles bool // Allows a handle to share files to a conversation + RenderImages bool // Indicates that certain filetypes should be autodownloaded and rendered when shared by this contact } -// DefaultP2PAccessControl - because in the year 2021, go does not support constant structs... +// DefaultP2PAccessControl defaults to a semi-trusted peer with no access to special extensions. func DefaultP2PAccessControl() AccessControl { - return AccessControl{Read: true, Append: true, Blocked: false} + return AccessControl{Read: true, Append: true, ExchangeAttributes: true, Blocked: false, + AutoConnect: true, ShareFiles: false, RenderImages: false} } // AccessControlList represents an access control list for a conversation. Mapping handles to conversation @@ -30,10 +42,10 @@ func (acl *AccessControlList) Serialize() []byte { } // DeserializeAccessControlList takes in JSON and returns an AccessControlList -func DeserializeAccessControlList(data []byte) AccessControlList { +func DeserializeAccessControlList(data []byte) (AccessControlList, error) { var acl AccessControlList - json.Unmarshal(data, &acl) - return acl + err := json.Unmarshal(data, &acl) + return acl, err } // Attributes a type-driven encapsulation of an Attribute map. @@ -60,7 +72,9 @@ type Conversation struct { Handle string Attributes Attributes ACL AccessControlList - Accepted bool + + // Deprecated, please use ACL for permissions related functions + Accepted bool } // GetAttribute is a helper function that fetches a conversation attribute by scope, zone and key @@ -71,6 +85,16 @@ func (ci *Conversation) GetAttribute(scope attr.Scope, zone attr.Zone, key strin return "", false } +// GetPeerAC returns a suitable Access Control object for a the given peer conversation +// If this is called for a group conversation, this method will error and return a safe default AC. +func (ci *Conversation) GetPeerAC() AccessControl { + if acl, exists := ci.ACL[ci.Handle]; exists { + return acl + } + log.Errorf("attempted to access a Peer Access Control object from %v but peer ACL is undefined. This is likely a programming error", ci.Handle) + return DefaultP2PAccessControl() +} + // IsGroup is a helper attribute that identifies whether a conversation is a legacy group func (ci *Conversation) IsGroup() bool { if _, exists := ci.Attributes[attr.LocalScope.ConstructScopedZonedPath(attr.LegacyGroupZone.ConstructZonedPath(constants.GroupID)).ToString()]; exists { diff --git a/peer/cwtch_peer.go b/peer/cwtch_peer.go index 7b9aa1d..d28734a 100644 --- a/peer/cwtch_peer.go +++ b/peer/cwtch_peer.go @@ -310,7 +310,30 @@ func (cp *cwtchPeer) GenerateProtocolEngine(acn connectivity.ACN, bus event.Mana authorizations := make(map[string]model.Authorization) for _, conversation := range conversations { + if tor.IsValidHostname(conversation.Handle) { + + if _, exists := conversation.GetAttribute(attr.LocalScope, attr.ProfileZone, constants.ACLVersion); !exists { + if conversation.Accepted { + // migrate the old accepted AC to a new fine-grained one + // we only do this for previously trusted connections + // NOTE: this does not supercede global cwthch experiments settings + // if share files is turned off globally then acl.ShareFiles will be ignored. + if ac, exists := conversation.ACL[conversation.Handle]; exists { + ac.ShareFiles = true + ac.RenderImages = true + ac.AutoConnect = true + ac.ExchangeAttributes = true + conversation.ACL[conversation.Handle] = ac + } + + // Update the ACL Version + cp.storage.SetConversationAttribute(conversation.ID, attr.LocalScope.ConstructScopedZonedPath(attr.ProfileZone.ConstructZonedPath(constants.ACLVersion)), constants.ACLVersionOne) + // Store the updated ACL + cp.storage.SetConversationACL(conversation.ID, conversation.ACL) + } + } + if conversation.ACL[conversation.Handle].Blocked { authorizations[conversation.Handle] = model.AuthBlocked } else { @@ -682,6 +705,7 @@ func (cp *cwtchPeer) NewContactConversation(handle string, acl model.AccessContr conversationInfo, _ := cp.storage.GetConversationByHandle(handle) if conversationInfo == nil { conversationID, err := cp.storage.NewConversation(handle, model.Attributes{event.SaveHistoryKey: event.DeleteHistoryDefault}, model.AccessControlList{handle: acl}, accepted) + cp.SetConversationAttribute(conversationID, attr.LocalScope.ConstructScopedZonedPath(attr.ProfileZone.ConstructZonedPath(constants.ACLVersion)), constants.ACLVersionOne) cp.SetConversationAttribute(conversationID, attr.LocalScope.ConstructScopedZonedPath(attr.ProfileZone.ConstructZonedPath(constants.AttrLastConnectionTime)), time.Now().Format(time.RFC3339Nano)) cp.eventBus.Publish(event.NewEvent(event.ContactCreated, map[event.Field]string{event.ConversationID: strconv.Itoa(conversationID), event.RemotePeer: handle})) return conversationID, err @@ -689,6 +713,33 @@ func (cp *cwtchPeer) NewContactConversation(handle string, acl model.AccessContr return -1, fmt.Errorf("contact conversation already exists") } +// UpdateConversationAccessControlList is a genric ACL update method +func (cp *cwtchPeer) UpdateConversationAccessControlList(id int, acl model.AccessControlList) error { + return cp.storage.SetConversationACL(id, acl) +} + +// EnhancedGetConversationAccessControlList serialzies the access control list associated with the conversation +func (cp *cwtchPeer) EnhancedGetConversationAccessControlList(id int) (string, error) { + ci, err := cp.GetConversationInfo(id) + if err == nil { + return string(ci.ACL.Serialize()), nil + } + return "", err +} + +// UpdateConversationAccessControlListJSON wraps UpdateConversationAccessControlList and allows updating via a serialized JSON struct +func (cp *cwtchPeer) UpdateConversationAccessControlListJSON(id int, json string) error { + _, err := cp.GetConversationInfo(id) + if err == nil { + acl, err := model.DeserializeAccessControlList([]byte(json)) + if err == nil { + return cp.UpdateConversationAccessControlList(id, acl) + } + return err + } + return err +} + // AcceptConversation looks up a conversation by `handle` and sets the Accepted status to `true` // This will cause Cwtch to auto connect to this conversation on start up func (cp *cwtchPeer) AcceptConversation(id int) error { @@ -701,6 +752,21 @@ func (cp *cwtchPeer) AcceptConversation(id int) error { log.Errorf("Could not get conversation for %v: %v", id, err) return err } + + if ac, exists := ci.ACL[ci.Handle]; exists { + ac.ShareFiles = true + ac.AutoConnect = true + ac.RenderImages = true + ac.ExchangeAttributes = true + ci.ACL[ci.Handle] = ac + } + err = cp.storage.SetConversationACL(id, ci.ACL) + + if err != nil { + log.Errorf("Could not set conversation acl for %v: %v", id, err) + return err + } + if !ci.IsGroup() && !ci.IsServer() { cp.sendUpdateAuth(id, ci.Handle, ci.Accepted, ci.ACL[ci.Handle].Blocked) cp.PeerWithOnion(ci.Handle) @@ -748,7 +814,7 @@ func (cp *cwtchPeer) UnblockConversation(id int) error { // TODO at some point in the future engine needs to understand ACLs not just legacy auth status cp.sendUpdateAuth(id, ci.Handle, ci.Accepted, ci.ACL[ci.Handle].Blocked) - if !ci.IsGroup() && !ci.IsServer() && ci.Accepted { + if !ci.IsGroup() && !ci.IsServer() && ci.GetPeerAC().AutoConnect { cp.PeerWithOnion(ci.Handle) } @@ -1056,7 +1122,7 @@ func (cp *cwtchPeer) QueuePeeringWithOnion(handle string) { ci, err := cp.FetchConversationInfo(handle) if err == nil { lastSeen := cp.GetConversationLastSeenTime(ci.ID) - if !ci.ACL[ci.Handle].Blocked && ci.Accepted { + if !ci.ACL[ci.Handle].Blocked { cp.eventBus.Publish(event.NewEvent(event.QueuePeerRequest, map[event.Field]string{event.RemotePeer: handle, event.LastSeen: lastSeen.Format(time.RFC3339Nano)})) } } @@ -1316,7 +1382,7 @@ func (cp *cwtchPeer) getConnectionsSortedByLastSeen(doPeers, doServers bool) []* continue } } else { - if !doPeers || !conversation.Accepted { + if !doPeers { continue } } @@ -1339,7 +1405,9 @@ func (cp *cwtchPeer) StartConnections(doPeers, doServers bool) { cp.QueueJoinServer(conversation.model.Handle) } else { log.Debugf(" QueuePeerWithOnion(%v)", conversation.model.Handle) - cp.QueuePeeringWithOnion(conversation.model.Handle) + if conversation.model.GetPeerAC().AutoConnect { + cp.QueuePeeringWithOnion(conversation.model.Handle) + } } time.Sleep(50 * time.Millisecond) } @@ -1508,7 +1576,7 @@ func (cp *cwtchPeer) eventHandler() { log.Debugf("confo info lookup newgetval %v %v %v", onion, conversationInfo, err) // only accepted contacts can look up information - if conversationInfo != nil && conversationInfo.Accepted { + if conversationInfo != nil && conversationInfo.GetPeerAC().ExchangeAttributes { // Type Safe Scoped/Zoned Path zscope := attr.IntoScope(scope) zone, zpath := attr.ParseZone(zpath) @@ -1540,7 +1608,7 @@ func (cp *cwtchPeer) eventHandler() { conversationInfo, _ := cp.FetchConversationInfo(handle) // only accepted contacts can look up information - if conversationInfo != nil && conversationInfo.Accepted { + if conversationInfo != nil && conversationInfo.GetPeerAC().ExchangeAttributes { // Type Safe Scoped/Zoned Path zscope := attr.IntoScope(scope) zone, zpath := attr.ParseZone(zpath) diff --git a/peer/cwtchprofilestorage.go b/peer/cwtchprofilestorage.go index 37330e9..270df24 100644 --- a/peer/cwtchprofilestorage.go +++ b/peer/cwtchprofilestorage.go @@ -376,7 +376,12 @@ func (cps *CwtchProfileStorage) GetConversationByHandle(handle string) (*model.C } rows.Close() - return &model.Conversation{ID: id, Handle: handle, ACL: model.DeserializeAccessControlList(acl), Attributes: model.DeserializeAttributes(attributes), Accepted: accepted}, nil + cacl, err := model.DeserializeAccessControlList(acl) + if err != nil { + log.Errorf("error deserializing ACL from database, database maybe corrupted: %v", err) + return nil, err + } + return &model.Conversation{ID: id, Handle: handle, ACL: cacl, Attributes: model.DeserializeAttributes(attributes), Accepted: accepted}, nil } // FetchConversations returns *all* active conversations. This method should only be called @@ -412,7 +417,13 @@ func (cps *CwtchProfileStorage) FetchConversations() ([]*model.Conversation, err rows.Close() return nil, err } - conversations = append(conversations, &model.Conversation{ID: id, Handle: handle, ACL: model.DeserializeAccessControlList(acl), Attributes: model.DeserializeAttributes(attributes), Accepted: accepted}) + + cacl, err := model.DeserializeAccessControlList(acl) + if err != nil { + log.Errorf("error deserializing ACL from database, database maybe corrupted: %v", err) + return nil, err + } + conversations = append(conversations, &model.Conversation{ID: id, Handle: handle, ACL: cacl, Attributes: model.DeserializeAttributes(attributes), Accepted: accepted}) } } @@ -445,7 +456,12 @@ func (cps *CwtchProfileStorage) GetConversation(id int) (*model.Conversation, er } rows.Close() - return &model.Conversation{ID: id, Handle: handle, ACL: model.DeserializeAccessControlList(acl), Attributes: model.DeserializeAttributes(attributes), Accepted: accepted}, nil + cacl, err := model.DeserializeAccessControlList(acl) + if err != nil { + log.Errorf("error deserializing ACL from database, database maybe corrupted: %v", err) + return nil, err + } + return &model.Conversation{ID: id, Handle: handle, ACL: cacl, Attributes: model.DeserializeAttributes(attributes), Accepted: accepted}, nil } // AcceptConversation sets the accepted status of a conversation to true in the backing datastore diff --git a/peer/profile_interface.go b/peer/profile_interface.go index 08591b7..444ceb6 100644 --- a/peer/profile_interface.go +++ b/peer/profile_interface.go @@ -120,9 +120,17 @@ type CwtchPeer interface { ArchiveConversation(conversation int) GetConversationInfo(conversation int) (*model.Conversation, error) FetchConversationInfo(handle string) (*model.Conversation, error) + + // API-level management of conversation access control + UpdateConversationAccessControlList(id int, acl model.AccessControlList) error + EnhancedGetConversationAccessControlList(conversation int) (string, error) + UpdateConversationAccessControlListJSON(conversation int, acjson string) error + + // Convieniance Functions for ACL Management AcceptConversation(conversation int) error BlockConversation(conversation int) error UnblockConversation(conversation int) error + SetConversationAttribute(conversation int, path attr.ScopedZonedPath, value string) error GetConversationAttribute(conversation int, path attr.ScopedZonedPath) (string, error) DeleteConversation(conversation int) error diff --git a/testing/autodownload/file_sharing_integration_test.go b/testing/autodownload/file_sharing_integration_test.go index f12f80d..bc48d5c 100644 --- a/testing/autodownload/file_sharing_integration_test.go +++ b/testing/autodownload/file_sharing_integration_test.go @@ -145,10 +145,23 @@ func TestFileSharing(t *testing.T) { alice.NewContactConversation(bob.GetOnion(), model.DefaultP2PAccessControl(), true) alice.PeerWithOnion(bob.GetOnion()) + json, err := alice.EnhancedGetConversationAccessControlList(1) + if err != nil { + t.Fatalf("Error!: %v", err) + } + t.Logf("alice<->bob ACL: %s", json) + t.Logf("Waiting for alice and Bob to peer...") waitForPeerPeerConnection(t, alice, bob) - alice.AcceptConversation(1) - bob.AcceptConversation(1) + err = alice.AcceptConversation(1) + if err != nil { + t.Fatalf("Error!: %v", err) + } + err = bob.AcceptConversation(1) + if err != nil { + t.Fatalf("Error!: %v", err) + } + t.Logf("Alice and Bob are Connected!!") filesharingFunctionality := filesharing.FunctionalityGate()