Documentation
¶
Overview ¶
Package client is Freizone's protocol client: the state, persistence and decisions that sit between pkg/{ratchet,group,wire,...} and whatever is driving them (SRV-23, see docs/design/23-shared-client-core.md).
It exists because that layer was written twice -- once in Go for cmd/devclient, once in Dart for freizone-app -- on top of identical primitives, so the cryptography could not diverge but every decision around it could. Measured against the shared vectors in pkg/conformance, the app satisfies all nine and devclient four, so the app's behaviour is the specification this package reproduces.
Shape ¶
This is an ordinary Go library and must stay one: Go types, Go errors, context where something blocks. No JSON envelopes, no integer handles, no trace of FFI -- freizone-app's native/ wrapper owns all of that and exists precisely to absorb it. Encoding the boundary's shape here would hand every other consumer an awkward API for the convenience of one.
One *Client per account, no package-level state. The app runs a single account per process; a bot bridge may run many, concurrently, and neither should have to know about the other. Each account is a separate directory, which is what keeps "many identities" from meaning "every read carries a discriminator".
Storage is plain files -- see store.go for what that costs and buys, and layout.go for the shape and the rule it keeps.
Index ¶
- Constants
- Variables
- func IsDeliveredStatus(status string) bool
- func IsGroupID(id string) bool
- func IsStaleDevice(err error) bool
- func IsStaleRecipientStatus(status string) bool
- func IsUnreachable(err error) bool
- func ProfileRenamedMarker(name string) string
- func ValidReportCategory(c ReportCategory) bool
- type APIError
- type Attachment
- type Bundle
- type Client
- func (c *Client) AcceptGroupInvitation(ctx context.Context, groupID string) error
- func (c *Client) AckMessage(ctx context.Context, messageID string) error
- func (c *Client) AppendMessage(chatID string, m Message) error
- func (c *Client) ApplyGroupControl(content Content, senderAccountID string, now time.Time) (GroupOutcome, error)
- func (c *Client) AskForGroupFacts(ctx context.Context, groupID, peerAccountID, peerServer string) error
- func (c *Client) AttachmentFile(chatID, messageID string) ([]byte, error)
- func (c *Client) AttachmentPath(chatID, messageID string) string
- func (c *Client) AttachmentThumb(chatID, messageID string) ([]byte, error)
- func (c *Client) AttachmentThumbPath(chatID, messageID string) string
- func (c *Client) BlockPeer(peer, server string) error
- func (c *Client) BroadcastGroupEvents(ctx context.Context, groupID string, events []*group.Event, skip ...string) error
- func (c *Client) ClaimBundle(ctx context.Context, peer PeerEndpoint) (Bundle, error)
- func (c *Client) ClaimServer(ctx context.Context, server, setupToken string) (Identity, error)
- func (c *Client) ClearDesyncEvidence(peer string) error
- func (c *Client) ClearTranscript(chatID string) error
- func (c *Client) Close() error
- func (c *Client) ConsumeOneTimePrekey(id uint32) error
- func (c *Client) Conversation(peer string) (*Conversation, error)
- func (c *Client) Conversations() ([]Conversation, error)
- func (c *Client) CountOneTimePrekeys() (int, error)
- func (c *Client) CountProcessedMessages() (int, error)
- func (c *Client) CreateGroup(ctx context.Context, name string) (string, error)
- func (c *Client) DeleteBlob(ctx context.Context, blobID string) error
- func (c *Client) DeleteChatMedia(chatID string) error
- func (c *Client) DeleteConversation(peer string) error
- func (c *Client) DeleteMessage(chatID, messageID string) error
- func (c *Client) DeleteSession(peer string, kind SessionKind) error
- func (c *Client) DissolveGroup(ctx context.Context, groupID string) error
- func (c *Client) DownloadAttachment(ctx context.Context, server string, att Attachment) ([]byte, error)
- func (c *Client) Drain(ctx context.Context, opts ReceiveOptions) (DrainReport, error)
- func (c *Client) Endpoint(ctx context.Context, peer string) (PeerEndpoint, error)
- func (c *Client) EnsureAttachment(ctx context.Context, chatID, messageID, server string, att Attachment) ([]byte, error)
- func (c *Client) FetchMessages(ctx context.Context) ([]IncomingMessage, error)
- func (c *Client) ForgetGroup(groupID string) error
- func (c *Client) ForgetPeerDevice(peer string) error
- func (c *Client) GroupChat(groupID string) (*GroupChat, error)
- func (c *Client) GroupMembership(groupID string) (*group.Resolved, error)
- func (c *Client) GroupPeerStateHashes(groupID string) (map[string]string, error)
- func (c *Client) GroupState(groupID string) (*group.State, error)
- func (c *Client) Groups() ([]string, error)
- func (c *Client) HandleAndAck(ctx context.Context, msg IncomingMessage, opts ReceiveOptions) (ReceiveResult, error)
- func (c *Client) HandleIncoming(msg IncomingMessage, opts ReceiveOptions) (ReceiveResult, error)
- func (c *Client) Identity() (Identity, error)
- func (c *Client) InviteToGroup(ctx context.Context, groupID, addressOrPrefix, server string) error
- func (c *Client) IsPeerBlocked(peer string) (bool, error)
- func (c *Client) IsPeerKnown(peer string) (bool, error)
- func (c *Client) LastMessage(chatID string) (*Message, error)
- func (c *Client) LeaveGroup(ctx context.Context, groupID string) error
- func (c *Client) Maintain(ctx context.Context) MaintenanceReport
- func (c *Client) MarkMessageProcessed(id string) error
- func (c *Client) MarkPeerKnown(peer string) error
- func (c *Client) MessageExists(chatID, messageID string) (bool, error)
- func (c *Client) Messages(chatID string) ([]Message, error)
- func (c *Client) OneTimePrekey(id uint32) (*OneTimePrekey, error)
- func (c *Client) Path() string
- func (c *Client) PayGroupSnapshotDebts(ctx context.Context) (paid int, gone []string, err error)
- func (c *Client) PeerProfile(peer string) (*PeerProfile, error)
- func (c *Client) PeerProfileNames() (map[string]string, error)
- func (c *Client) PeerSessionHealth(peer string) (*PeerSessionHealth, error)
- func (c *Client) PinMessage(chatID, messageID string) error
- func (c *Client) PinnedMessageIDs(chatID string) ([]string, error)
- func (c *Client) ProfileName() (string, *time.Time, error)
- func (c *Client) PurgeAndReplaceOneTimePrekeys(ctx context.Context) error
- func (c *Client) PutConversation(convo Conversation) error
- func (c *Client) PutGroupChat(chat GroupChat) error
- func (c *Client) PutGroupState(groupID string, st *group.State) error
- func (c *Client) PutOneTimePrekeys(keys []OneTimePrekey) error
- func (c *Client) ReceiptsEnabled() (bool, error)
- func (c *Client) ReconcileGroup(ctx context.Context, outcome GroupOutcome, peerAccountID string) error
- func (c *Client) RecordAutoRekey(peer string, at time.Time) error
- func (c *Client) RecordDecryptFailure(id string) (giveUp bool, err error)
- func (c *Client) RecordDesyncEvidence(peer string, at time.Time) (recorded bool, err error)
- func (c *Client) RecordGroupPeerStateHash(groupID, accountID, stateHash string) error
- func (c *Client) RecoverDesyncedSessions(ctx context.Context) ([]string, error)
- func (c *Client) Register(ctx context.Context, server string, opts RegisterOptions) (Identity, error)
- func (c *Client) RemainingOneTimePrekeys(ctx context.Context) (int, error)
- func (c *Client) RemoveFromGroup(ctx context.Context, groupID, accountID string) error
- func (c *Client) Report(ctx context.Context, peer string, category ReportCategory, opts ReportOptions) error
- func (c *Client) RequestGroupSync(ctx context.Context, groupID string) error
- func (c *Client) ResendPendingReceipts(ctx context.Context) (int, error)
- func (c *Client) ResetSession(ctx context.Context, peerAccountID string, reason RekeyReason) error
- func (c *Client) ResolvePeer(ctx context.Context, addressOrPrefix, server string) (PeerEndpoint, error)
- func (c *Client) RetryGroupMessage(ctx context.Context, groupID, messageID string) (SendResult, error)
- func (c *Client) RetryMessage(ctx context.Context, peerAccountID, messageID string) (SendResult, error)
- func (c *Client) RotatePrekeys(ctx context.Context) error
- func (c *Client) SendGroupReceipt(ctx context.Context, groupID, toAccountID string, status ReceiptStatus, ...) error
- func (c *Client) SendGroupSnapshot(ctx context.Context, groupID, toAccountID string) error
- func (c *Client) SendGroupText(ctx context.Context, groupID, text string, opts SendOptions) (SendResult, error)
- func (c *Client) SendMessage(ctx context.Context, recipientAccountID, recipientDeviceID, messageID string, ...) error
- func (c *Client) SendReceipt(ctx context.Context, peerAccountID string, status ReceiptStatus, ...) error
- func (c *Client) SendText(ctx context.Context, peerAccountID, text string, opts SendOptions) (SendResult, error)
- func (c *Client) ServerStatus(ctx context.Context, server string) (ServerStatus, error)
- func (c *Client) Session(peer string, kind SessionKind) (*ratchet.Session, error)
- func (c *Client) SetGroupDelivery(chatID, messageID string, delivery GroupDelivery) error
- func (c *Client) SetGroupMeta(ctx context.Context, groupID, name, topic string) error
- func (c *Client) SetGroupRole(ctx context.Context, groupID, accountID string, role group.Role, grant bool) error
- func (c *Client) SetIdentity(id Identity) error
- func (c *Client) SetMessageAttachments(chatID, messageID string, attachments []Attachment) error
- func (c *Client) SetMessageSendState(chatID, messageID string, state SendState) error
- func (c *Client) SetProfileName(name string) error
- func (c *Client) SetReceiptsEnabled(enabled bool) error
- func (c *Client) SetSession(peer string, kind SessionKind, s *ratchet.Session) error
- func (c *Client) ShouldAutoRekey(peer string, now time.Time) (bool, error)
- func (c *Client) StartConversation(ctx context.Context, addressOrPrefix, server string) (Conversation, error)
- func (c *Client) Stream(ctx context.Context, policy StreamPolicy) <-chan StreamEvent
- func (c *Client) TopUpOneTimePrekeys(ctx context.Context) error
- func (c *Client) UnblockPeer(peer string) error
- func (c *Client) UnpinMessage(chatID, messageID string) error
- func (c *Client) UploadAttachment(ctx context.Context, recipients []PeerEndpoint, media OutgoingMedia) (Attachment, error)
- func (c *Client) WasMessageProcessed(id string) (bool, error)
- func (c *Client) WithdrawReport(ctx context.Context, peer string) error
- func (c *Client) WriteAttachmentFile(chatID, messageID string, data []byte) error
- func (c *Client) WriteAttachmentThumb(chatID, messageID string, thumb []byte) error
- type Content
- type ContentKind
- type Conversation
- type DecryptError
- type DrainFailure
- type DrainReport
- type ErrAccountInUse
- type ErrBlobUnavailable
- type GroupChat
- type GroupControlKind
- type GroupDelivery
- type GroupOutcome
- type Identity
- type IncomingMessage
- type MaintenanceReport
- type MemberReceipt
- type Message
- type MessageKind
- type NotFreizoneServerError
- type OneTimePrekey
- type Options
- type OutgoingMedia
- type PeerEndpoint
- type PeerProfile
- type PeerSessionHealth
- type ReceiptStatus
- type ReceiveOptions
- type ReceiveResult
- type RegisterOptions
- type RekeyReason
- type ReportCategory
- type ReportOptions
- type SendOptions
- type SendResult
- type SendState
- type ServerStatus
- type SessionKind
- type StreamEvent
- type StreamEventKind
- type StreamPolicy
Constants ¶
const ( // MaxProcessedMessageIDs bounds how many handled ids are remembered. // Redelivery happens within a queue's lifetime, so remembering the recent // past is enough; the server caps a device's queue well below this. MaxProcessedMessageIDs = 500 // MaxDecryptAttempts is how often one envelope may fail before it is given // up on and dropped from the server queue. A single failure can just be a // message that raced a session change; a repeated one never is, and must // not block the queue forever. MaxDecryptAttempts = 3 )
const ( // OneTimePrekeyBatch is how many keys a top-up brings the pool back to. OneTimePrekeyBatch = 10 // OneTimePrekeyLowWaterMark is how low the pool is allowed to get first. // Comfortably above the server's own threshold, which exists only as a // fallback wake for a device that is not checking on its own: a device in // regular use should top up before the server ever has to ask. OneTimePrekeyLowWaterMark = 3 )
const ( SessionResetMarker = "Secure session was reset" AutomaticRekeyMarker = "Secure session was re-established automatically" )
The transcript markers for a session that was re-established. Both sides show one: the side that reset writes it when it does so, this side when it accepts the peer's re-key. Worded apart on purpose -- the user pressed something for one of them and nothing at all for the other, and being told "you reset this" when you did not is worse than not being told.
const ( // MinDesyncEvidence is how many distinct envelopes from one peer must be // given up on before the session is presumed desynced. // // One, deliberately. An envelope only counts here after failing // [MaxDecryptAttempts] times with a code that means diverged keys, and // decrypting the same ciphertext against the same session is // deterministic -- so one is already proof rather than suspicion. Waiting // for a second would strand every conversation where the peer sent one // message and then reasonably waited for an answer. MinDesyncEvidence = 1 // AutoRekeyResponderGrace is how long the higher-id side waits before // re-keying on its own initiative. // // Both sides can detect the same desync, and if both re-key at once each // adopts a fresh responder session built from the other's prekey block // while discarding the session the other just adopted -- leaving both // broken again, symmetrically, round after round. So the order is fixed by // comparing account ids: the lower id re-keys immediately, the higher only // if that has not already fixed things. // // Five minutes because the lower-id side's re-key lands within seconds when // it is online at all, and any successful decrypt -- including adopting // that very re-key -- clears the evidence and cancels this side's attempt. // Long enough to make an overlap rare, short enough that a peer who is // simply offline does not leave this side waiting indefinitely. AutoRekeyResponderGrace = 5 * time.Minute // MinAutoRekeyInterval is the minimum spacing between two automatic // re-keys with the same peer: the backstop for everything the ordering rule // does not catch. If a re-key somehow does not fix the conversation, this // bounds the damage to one attempt per interval rather than a tight loop of // X3DH establishments, each of which burns one of the peer's one-time // prekeys. MinAutoRekeyInterval = 15 * time.Minute )
const GroupSyncRequestCooldown = 5 * time.Minute
GroupSyncRequestCooldown is how long to wait before asking the same group for its facts again. Asking is cheap but not free, and a group nobody can answer for would otherwise be asked about on every reconnect.
const MaxHeldGroupEvents = 64
MaxHeldGroupEvents bounds how many not-yet-admissible events one group may hold.
Holding is for events that overtook the ones they depend on -- delivery is unordered, so a membership change easily arrives before the snapshot carrying the genesis it rests on. That is a handful in practice. An unbounded buffer would mostly be somewhere for a hostile peer to put things.
const PeerAccountGoneMarker = "This account no longer exists on their server, so nothing sent here will ever arrive"
PeerAccountGoneMarker is the one-to-one counterpart to a group's own line (recordMemberGone) -- worded without a name, since a one-to-one chat has exactly one other party and repeating who they are would be noise.
const ProfileNameClearedMarker = "This contact no longer gives a name"
ProfileNameClearedMarker is the withdrawal's line.
Variables ¶
var ErrAlreadyRegistered = errors.New("client: this account directory already holds an identity")
ErrAlreadyRegistered reports an attempt to register into an account directory that already holds a finished identity. Registering again would abandon the account it already has, which is never what a caller meant.
var ErrAttachmentNotResendable = errors.New("client: the attachment for this message is no longer available to re-send")
ErrAttachmentNotResendable reports a retry of a message whose attachment never reached the server and is no longer on this device either. Re-sending the caption alone would quietly deliver something other than what was composed, so it refuses instead.
var ErrFederationDisabled = errors.New("client: this server has federation switched off, so contacts on other servers cannot be reached")
ErrFederationDisabled reports a send to another server from an account whose own server has federation switched off. Blocking it here rather than letting the POST fail is the honest thing: replies are blocked inbound too, so the conversation is a dead end in both directions and saying so early is better than a message that sits failed with a network error.
var ErrNoIdentity = errors.New("client: no identity in this account")
ErrNoIdentity reports an account directory that exists but has not been given an identity. A normal state, not a fault: setup opens the store first and generates keys second.
var ErrNoSessionMaterial = errors.New("client: no session with this peer and no prekey block to start one")
ErrNoSessionMaterial reports an envelope there is nothing to decrypt with: no session with this sender, and no prekey block to build one from.
It is the one desync shape that produces no cryptographic error at all -- this side's session is simply gone while the peer keeps sending into the one they still hold -- so it counts as evidence of a desync even though nothing failed to verify. Without that, the very case automatic recovery exists for would be the one case it never sees.
var ErrPeerBlocked = errors.New("client: this contact is blocked, so nothing is sent to them")
ErrPeerBlocked reports an attempt to send to a peer the user has blocked.
Refused in the core rather than left to whatever is driving it. The app happens to disable its composer, but the rule is not a property of a screen: a background retry, a queued receipt, a bot with no interface at all would each otherwise go on talking to somebody the user has cut off. The peer is never told either way -- blocking is silent in both directions.
var ErrPeerGone = errors.New("client: this contact's account no longer exists, so nothing can be sent to them")
ErrPeerGone reports a send to a peer whose account was confirmed gone -- asked of their server, not guessed from a failed send (see accountIsGone). Permanent in practice: an account id is derived from the key material behind it, so a deleted account never returns under the same id.
ErrReportsUnavailable reports that the server in question does not accept reports -- switched off by its operator, or too old to know about them. The caller's job is to not offer the action in the first place (ask Client.ServerStatus first); this is the backstop for the race where it was switched off between asking and sending.
var ErrUnreachable = errors.New("client: server unreachable")
ErrUnreachable marks a failure where the request never reached a working server, so nothing at all was learned -- not about the account, not about its state, not about whether the thing we asked for would have succeeded.
Most transport failures identify themselves (*url.Error, net.Error) and need no marking. This is for the ones that do not: a connect this package gave up on itself, where the timeout is ours rather than the socket's.
Functions ¶
func IsDeliveredStatus ¶
IsDeliveredStatus reports whether one copy's per-item status means the recipient's server took it.
Two of them do. `queued` is the ordinary answer -- the server holds the envelope until that device fetches it, which is what delivery *is* here, since nothing waits for the recipient to be online. `duplicate` means it already had this message id, which is the answer a retry is built to get: posting under the id a previous attempt used is how a second attempt avoids delivering the same message twice.
Everything else -- `invalid`, `unknown_recipient`, `queue_full`, `internal_error` -- is a copy that did not arrive. A status this build does not know counts as failure too: inventing success for an answer we cannot read would record a message as delivered on the strength of not understanding the reply.
func IsGroupID ¶
IsGroupID reports whether a chat id names a group rather than a peer.
Account and group ids share every line of their encoding and differ only in a version marker, which is what makes one call able to take a "chat id" and dispatch on it -- and what makes confusing the two impossible rather than merely unlikely.
func IsStaleDevice ¶
IsStaleDevice reports whether err is a server saying the device id it was given is dead -- PROTOCOL.md §4's stale-device rule.
Servers carrying the distinct codes answer `unknown_device`, `no_prekey_bundle` or `unknown_recipient`; servers from before the rule answer all of those with the catch-all `not_found`, so any other 404 counts too. The one explicit exception is `federation_disabled`, which is about the server and never about the device.
Both directions of getting this wrong cost something real. Too narrow, and a peer who re-created their account stays undeliverable forever: every send claims a bundle for a device id their server has long forgotten, 404s, and nothing heals. Too wide, and a 404 that says nothing about the device costs a perfectly good cached device and its ratchet session, forcing a needless re-key.
func IsStaleRecipientStatus ¶
IsStaleRecipientStatus is the batch form of the same discovery: whether one copy's per-item status (PROTOCOL.md §7) says its recipient device is gone. Only `unknown_recipient` does -- `queue_full`, `invalid` and `internal_error` all describe conditions a retry against the same device can outlive.
func IsUnreachable ¶
IsUnreachable reports whether err means the server was not there, as opposed to a server that answered and said no (APIError) or a host that answered as something else entirely (NotFreizoneServerError).
Worth its own predicate because the two call for opposite treatment. In a federation nobody operates every server, so unreachable is an ordinary event: it is retried on its own, it says nothing that a later attempt will not say better, and putting it in front of somebody trains them to ignore the notices that do need reading. A refusal is a fact about the account, and retrying it changes nothing.
Deliberately positive: a JSON that would not parse, a file that would not open, a caller's own mistake -- none of those are the server being away, and classifying by exclusion would quietly silence all of them.
func ProfileRenamedMarker ¶ added in v0.27.0
ProfileRenamedMarker formats the line that says a peer changed the name they assert about themselves (SRV-32), and ProfileNameClearedMarker the one for a withdrawal.
Written here rather than left to the app, for the same reason markPeerGone and the re-key markers are: this happens on receipt, which includes a background push wake with no UI running at all, and a line the wake does not write is a line nobody ever sees. Frozen into the transcript when written, on APP-18's precedent -- re-rendering it later from current state would let a rename rewrite the history of the rename.
The line appears **even when this device has its own name for them**, and that is the case it matters most in: somebody the user called "Dad" starting to call themselves something else is exactly what they should be told once.
func ValidReportCategory ¶ added in v0.27.0
func ValidReportCategory(c ReportCategory) bool
ValidReportCategory reports whether c is one a server will accept.
Types ¶
type APIError ¶
type APIError struct {
StatusCode int
// Code is the server's machine-readable classification, empty when the
// body carried none. Empty means undiagnosed, never harmless.
Code string
Message string
}
APIError is a Freizone server refusing a request in its own JSON error format (`{"error": {"code": ..., "message": ...}}`).
type Attachment ¶
type Attachment struct {
// Kind is "image" today. An unknown kind must render as a placeholder
// rather than break the message around it.
Kind string `json:"kind"`
// Algorithm names how the blob is encrypted, as a string so changing
// ciphers stays a data question rather than a format break.
Algorithm string `json:"algorithm,omitempty"`
BlobID string `json:"blob_id"`
// Key is this one blob's symmetric key, generated per attachment and
// deliberately not derived from the ratchet: the blob outlives the message
// on the server, so resetting a secure session must not make pictures
// already received undownloadable.
Key []byte `json:"key"`
MimeType string `json:"mime_type,omitempty"`
ByteSize int `json:"byte_size,omitempty"`
// Width and Height let a bubble reserve the right aspect ratio before the
// download finishes, so the transcript does not jump.
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
// Thumb is a tiny JPEG shown blurred while the real file downloads, nil
// when the sender included none.
Thumb []byte `json:"thumb,omitempty"`
}
Attachment is what describes a blob, never the blob itself: the bytes are fetched from the server encrypted and cached as a file.
type Bundle ¶
type Bundle struct {
ratchet.RemoteBundle
// ClaimedUnauthenticated: the server refused our credentials and answered
// anyway, withholding the one-time prekey.
//
// Never expected, because every claim this package makes is signed. So it
// means our own credentials were rejected -- a clock far out of skew, a
// revoked device, a stale certificate -- and the session about to be built
// is quietly starting weaker than it should. Reported rather than refused:
// a working conversation is worth more than one message's forward secrecy,
// but a permanent silent downgrade is worth neither.
ClaimedUnauthenticated bool
}
Bundle is verified X3DH material for one peer device.
type Client ¶
type Client struct {
// contains filtered or unexported fields
}
Client is one account's state.
Safe for concurrent use. A single mutex serialises everything, which is the right trade here: the work behind it is a few small file operations, and one account is driven by one app or one bot identity rather than by contending writers. Several accounts run several Clients over several directories and never meet.
func Open ¶
Open opens the account directory at path, creating it if it does not exist. A fresh account has no identity yet -- Client.Identity reports ErrNoIdentity until Client.SetIdentity is called.
Opening also settles anything the previous process left mid-send: a message still marked pending belonged to a send that cannot still be running, so it becomes a failure to retry rather than a spinner nobody will ever resolve.
The account is owned for as long as it is open. A second process gets ErrAccountInUse; a second opener *in this process* gets the same Client back, and the account is released when the last of them closes it. Both halves are explained in lock.go.
func (*Client) AcceptGroupInvitation ¶
AcceptGroupInvitation joins a group this account was invited to.
An acceptance is signed by its own subject and nobody else, which is what makes "was invited" and "is in the group" two different facts: until this event exists, nothing is sent into the group and the address is not shared with it.
func (*Client) AckMessage ¶
AckMessage deletes one envelope from the server queue, which is the only thing that drains it.
Best effort by design: a lost acknowledgement means redelivery, which the duplicate check upstream is there to absorb. A 404 is success -- something already deleted is deleted.
func (*Client) AppendMessage ¶
AppendMessage adds a line to the end of a chat's transcript.
Appended, never inserted by time: the transcript's order is arrival order, which is what the app does and what a message decrypted late depends on -- sorting by timestamp would quietly rearrange exactly those transcripts.
func (*Client) ApplyGroupControl ¶
func (c *Client) ApplyGroupControl(content Content, senderAccountID string, now time.Time) (GroupOutcome, error)
ApplyGroupControl folds a control envelope into this account's facts.
The envelope's own claim about which group it belongs to is not trusted for a snapshot: a snapshot carries the genesis, and the id follows from the key inside it rather than from whatever the sender wrote on the outside.
func (*Client) AskForGroupFacts ¶
func (c *Client) AskForGroupFacts(ctx context.Context, groupID, peerAccountID, peerServer string) error
AskForGroupFacts sends a sync request to one member.
Rate limited per group rather than per member: the point is to get the facts once, and a group whose every member writes would otherwise produce a request per message.
func (*Client) AttachmentFile ¶
AttachmentFile returns the stored bytes for a message's attachment, or nil when there are none. A missing file is not an error: it is the normal state of a picture nobody has looked at yet.
func (*Client) AttachmentPath ¶
AttachmentPath and AttachmentThumbPath say where an attachment's bytes belong, whether or not anything is there yet.
Exported because the bytes are better fetched than carried: a caller with a file reader -- an image widget, a share sheet -- wants a path, and moving megabytes through whatever boundary sits between it and this package would cost more than the download did. An empty string means the ids were unusable.
func (*Client) AttachmentThumb ¶
AttachmentThumb returns the stored preview, or nil when there is none.
func (*Client) AttachmentThumbPath ¶
AttachmentThumbPath is Client.AttachmentPath for the inline preview, which is present from the moment the message arrived.
func (*Client) BlockPeer ¶
BlockPeer blocks peer locally, snapshotting their server for the blocked list in case the conversation is later deleted. Purely local: the server is never told, and the peer cannot tell they were blocked.
func (*Client) BroadcastGroupEvents ¶
func (c *Client) BroadcastGroupEvents(ctx context.Context, groupID string, events []*group.Event, skip ...string) error
BroadcastGroupEvents tells every member about new facts.
skip names members who already have them -- an invitee who was just sent the whole set, typically. A member who cannot be reached is owed a snapshot rather than forgotten: the fact must not be lost with one failed attempt.
func (*Client) ClaimBundle ¶
ClaimBundle fetches the X3DH material needed to start a session with peer, verified against that device's own signing key.
Always authenticated, in whichever of the two forms applies: an ordinary signed request for a peer on our own server, and a self-certifying claim for a foreign one, which has no local row to look a device id up in. Without either the server still answers -- but withholds the one-time prekey, silently costing this session forward secrecy on its first message. Which is exactly why an unauthenticated answer is reported rather than accepted quietly; see Bundle.ClaimedUnauthenticated.
func (*Client) ClaimServer ¶ added in v0.23.0
ClaimServer registers this account *and* makes it the server's first administrator, using the setup token an unclaimed server prints at startup.
Deliberately its own call rather than an option on Client.Register: the two differ in what they do to the *server*, not in how they are configured, and a caller should not be able to claim a server by filling in one more field. Whether a server is still unclaimed is on Client.ServerStatus.
func (*Client) ClearDesyncEvidence ¶
ClearDesyncEvidence forgets everything recorded about peer's session going wrong. Called whenever a message from them decrypts, which is the only proof the session works. Drops the re-key spacing along with it, deliberately: a healthy session needs no protection against re-keying too often.
func (*Client) ClearTranscript ¶
ClearTranscript empties a chat's history, leaving the conversation, its session and everything else about the peer in place.
The one case where removing the file outright is right: nothing is being kept, so there is nothing to rewrite.
func (*Client) Close ¶
Close gives up this caller's hold on the account. Nothing is buffered -- every write was synced by the time the call that made it returned -- so what this releases is the ownership, and only once every opener in this process has let go.
Safe to call more than once, and safe to call on a Client another opener is still using: that is the whole point of counting them.
func (*Client) ConsumeOneTimePrekey ¶
ConsumeOneTimePrekey drops a prekey from the pool. Call it only once a session built from it has decrypted -- see Client.OneTimePrekey.
func (*Client) Conversation ¶
func (c *Client) Conversation(peer string) (*Conversation, error)
Conversation returns the conversation with peer, or nil if there is none.
func (*Client) Conversations ¶
func (c *Client) Conversations() ([]Conversation, error)
Conversations returns every conversation, most recently active first, so the chat list can be drawn without touching a single transcript.
One small file read per chat rather than one query. That is a handful of reads for a handful of chats and, unlike the old single-file store, it does not grow with the number of messages behind them -- which is the cost that actually mattered.
func (*Client) CountOneTimePrekeys ¶
CountOneTimePrekeys reports how many unclaimed prekeys remain, which is what decides whether the pool needs topping up.
func (*Client) CountProcessedMessages ¶
CountProcessedMessages reports how many ids are currently remembered. For tests and diagnostics; nothing in the protocol depends on it.
func (*Client) CreateGroup ¶
CreateGroup founds a group and returns its id.
The group's root key is *derived* from this account's own recovery seed and a nonce stored in the genesis event, never generated and stored. That is what lets a founder who has lost every device re-derive it from their recovery phrase alone -- a group whose key lived only on a phone would die with the phone.
func (*Client) DeleteBlob ¶
DeleteBlob gives up this device's claim on an attachment it received, which is what frees the ciphertext once every recipient has it (see docs/PROTOCOL.md §10). Not a sender operation: only a named recipient may touch a blob at all, so a sender cannot remove what it uploaded.
With several recipients only the caller's own claim goes; the file follows the last one. Best effort by nature -- the server expires blobs on its own, so a failure here costs storage until the retention sweep, nothing more -- and a 404 counts as success, since "no claim of mine" is the state being asked for.
func (*Client) DeleteChatMedia ¶
DeleteChatMedia removes everything stored for one chat. Called when a transcript is cleared or a conversation deleted: the messages are gone, and leaving their pictures behind means storage nobody can see or reach.
func (*Client) DeleteConversation ¶
DeleteConversation removes a conversation's metadata. The ratchet session, the known-peer mark and any block deliberately survive: clearing a chat must not silently unblock someone or turn a known contact back into a stranger.
func (*Client) DeleteMessage ¶
DeleteMessage removes one line, along with its attachments, its group deliveries, its pin -- and its stored media, because once the line naming those files is gone, nothing can reach them and nothing else ever cleans them up. Local only -- the peer keeps their copy.
func (*Client) DeleteSession ¶
func (c *Client) DeleteSession(peer string, kind SessionKind) error
DeleteSession removes a session. Deleting one that is not there is not an error -- callers reach for this to guarantee absence, not to assert presence.
func (*Client) DissolveGroup ¶
DissolveGroup ends a group for everyone. Signed with the group root key, so only the founder can do it.
func (*Client) DownloadAttachment ¶
func (c *Client) DownloadAttachment(ctx context.Context, server string, att Attachment) ([]byte, error)
DownloadAttachment fetches and decrypts one attachment.
server names a server other than this account's own only for the one legitimate case: an attachment on a group whose blob was uploaded (federated) to a *different* member's server rather than this account's. For an ordinary one-to-one attachment it is always empty -- a blob is uploaded to the *recipient's* own server (see UploadAttachment), precisely so the recipient never has to reach the sender's server to read something the sender sent. Passing the sender's server here for a one-to-one attachment sends the fetch, federated, to a server that was never handed the blob at all.
func (*Client) Drain ¶ added in v0.23.0
func (c *Client) Drain(ctx context.Context, opts ReceiveOptions) (DrainReport, error)
Drain fetches whatever the server is holding for this device and runs each envelope through Client.HandleAndAck.
This is what a caller does on a fresh connection, and the reason Client.Stream does not do it: a stream carries what arrives from now on, and everything queued while nothing was listening is still sitting there. Safe to run while the stream is live -- an envelope delivered both ways is recognised as the duplicate it is, by the id it was already marked with.
A failure to reach the server at all is an error, since nothing was learned. A failure to read one envelope is not: it is in the report.
func (*Client) Endpoint ¶
Endpoint returns where to reach peer, using what the conversation already remembers and resolving only when it has to.
The device id is cached because resolving costs a round trip on every single message otherwise. Nothing invalidates that cache on its own -- no server propagates a device revocation or an account being re-created (PROTOCOL.md §9) -- so the send path heals it instead: the send that trips over a dead id is the one that forgets it. See Client.ForgetPeerDevice.
func (*Client) EnsureAttachment ¶
func (c *Client) EnsureAttachment(ctx context.Context, chatID, messageID, server string, att Attachment) ([]byte, error)
EnsureAttachment returns an attachment's bytes, downloading them only if they are not already here.
Idempotent on purpose: a screen asking again a moment later finds the file rather than starting a second download, which is what lets a caller fetch eagerly when a message arrives *and* lazily when a bubble is first drawn without the two fighting.
func (*Client) FetchMessages ¶
func (c *Client) FetchMessages(ctx context.Context) ([]IncomingMessage, error)
FetchMessages returns everything queued for this device.
The queue is drained by acknowledging each envelope, never by reading it -- see Client.AckMessage. That is what makes delivery at-least-once and why the same envelope legitimately arrives twice.
func (*Client) ForgetGroup ¶ added in v0.20.0
ForgetGroup discards everything this account holds *about* a group: its facts, the events waiting on facts that never arrived, what each member last said their view was, and the chat state a list reads. The transcript and the media are not here and are the caller's to clear -- a group chat lives under dirChats keyed by group id like any other (see layout.go).
Only ever right for a group this account is no longer in. While still a member the others keep sending, and an arriving message rebuilds a chat whose facts are gone: no name, no member list, and a send that fails with "no group". So the caller has to have left, been removed, or seen it dissolved first -- this makes no such check, because the fold cannot distinguish "left" from "never joined" for a group whose facts are already gone.
Client.Groups is a directory listing, so this is what actually makes a group disappear from the chat list. Leaving alone does not: a member who left is still a member the fold knows about, deliberately, so a message that arrives afterwards is recognised rather than treated as a stranger's.
func (*Client) ForgetPeerDevice ¶
ForgetPeerDevice drops the cached device for peer, along with the session bound to it.
Both, and that is the point: a session is keyed to the device that established it, so keeping one for a device that no longer exists means the next send re-resolves and then encrypts to a stranger's ratchet. The pair is discarded together or not at all.
func (*Client) GroupMembership ¶
GroupMembership folds a group's facts into its current membership, or returns nil for a group this account knows nothing about.
func (*Client) GroupPeerStateHashes ¶
GroupPeerStateHashes is every member's last stated view of a group.
func (*Client) GroupState ¶
GroupState returns the fact set held for a group, or nil for one this account knows nothing about.
func (*Client) HandleAndAck ¶ added in v0.23.0
func (c *Client) HandleAndAck(ctx context.Context, msg IncomingMessage, opts ReceiveOptions) (ReceiveResult, error)
HandleAndAck runs one envelope through the receive path and does everything that follows from it: acknowledges it when the rule says to, confirms it to its sender, and answers a group peer whose view has drifted.
The returned error is the receive failure, if there was one -- by then the envelope has already been acknowledged or left in place, per the rule. The follow-up requests are best-effort and never returned: a lost confirmation goes again from Client.ResendPendingReceipts, and a group divergence unanswered here is answered on the next envelope that carries the hash.
func (*Client) HandleIncoming ¶
func (c *Client) HandleIncoming(msg IncomingMessage, opts ReceiveOptions) (ReceiveResult, error)
HandleIncoming decrypts one envelope and applies everything that follows from it: session adoption, the processed-id record, desync accounting, the transcript, and whether the user should be told.
Deliberately does no network I/O. The envelope is not acknowledged and nothing is fetched -- a caller processing a batch acknowledges each one itself, and one that has just been woken by a push does the same work with nothing to send from. That also keeps this callable while offline, which is exactly when a queue is longest.
Group envelopes are folded in here too, not handed up. The ratchet has advanced and the id is marked by the time the payload is even read, so an envelope nobody folds takes its facts with it -- which is why this has to work with nothing else attached, including on a background wake.
func (*Client) Identity ¶
Identity reads the account's key material, or ErrNoIdentity if setup has not run yet.
func (*Client) InviteToGroup ¶
InviteToGroup adds somebody, resolving the address first.
Resolving is not only about finding their device: the event records their *full* account id, and the lookup is what proves the account exists at all. An invitation to an address nobody holds would otherwise become a permanent member row nobody can remove.
func (*Client) IsPeerBlocked ¶
IsPeerBlocked reports whether peer is locally blocked.
func (*Client) IsPeerKnown ¶
IsPeerKnown reports whether peer has ever been accepted or reached out to.
func (*Client) LastMessage ¶
LastMessage returns a chat's most recent line, or nil for an empty chat.
This is the query the chat list is drawn from: one preview per conversation, and it must not depend on how much history sits behind it. Replaying the log is what that costs today, which is bounded by compaction rather than by the number of messages ever sent -- and unlike the old store, nothing here is *written* per message beyond a single line.
func (*Client) LeaveGroup ¶
LeaveGroup leaves, telling everyone.
func (*Client) Maintain ¶ added in v0.23.0
func (c *Client) Maintain(ctx context.Context) MaintenanceReport
Maintain is everything that should happen on a fresh connection.
One call rather than four, because the order is not the caller's to know and four separate calls means getting it right four times. Top up the prekey pool so somebody can start a conversation while this device is away; settle any group facts owed; re-establish the sessions the evidence says are broken; and re-send confirmations that never got out.
Worth running on a timer as well as on connect, for anything long-lived. A phone reconnects constantly and needs no timer; a process holding one stream open for weeks would otherwise never do any of this again, and the prekey pool would drain toward empty with nothing to notice.
Never returns an error: everything it does is best-effort, and whatever did not work is in the report.
func (*Client) MarkMessageProcessed ¶
MarkMessageProcessed records an envelope as handled and clears any failure history for it -- a message that finally succeeded has none worth keeping.
Costs one appended line, whatever the history behind it. The failure history is touched only when there actually is one, which on the success path is almost never: the common case must not pay for the rare one.
func (*Client) MarkPeerKnown ¶
MarkPeerKnown records that this peer is not a stranger -- accepted from a message request, or reached out to first. Outlives the conversation.
func (*Client) MessageExists ¶
MessageExists reports whether a chat already holds a line with this id.
The envelope-level check (see WasMessageProcessed) catches the same envelope arriving twice, which is the common case. This catches the same *message* arriving in two envelopes -- a group retry that could not reuse the recipient's wire id, an older build's copy -- where the id in the content is the only thing that says they are one message.
func (*Client) OneTimePrekey ¶
func (c *Client) OneTimePrekey(id uint32) (*OneTimePrekey, error)
OneTimePrekey looks a prekey up **without consuming it**, returning nil when the pool has no such entry -- an initial referencing one already used, or never held, is a routine case rather than a fault.
Looking up and consuming are deliberately separate calls, because the order matters and getting it wrong is invisible: the prekey may only be marked used once a session built from it has actually decrypted something. Consuming up front means every stale, damaged or redelivered initial drains the pool, and an empty pool silently downgrades later first contacts to the weaker no-one-time-prekey path. cmd/devclient does exactly that today; see pkg/conformance's failed-responder-attempt-must-not-burn-prekey.
func (*Client) PayGroupSnapshotDebts ¶
PayGroupSnapshotDebts sends the fact set to everyone owed one. Returns how many debts were settled. gone names the members dropped because their account no longer exists, so a caller can say so once rather than leaving the user with a member row that never settles.
func (*Client) PeerProfile ¶ added in v0.27.0
func (c *Client) PeerProfile(peer string) (*PeerProfile, error)
PeerProfile returns the stored claims for peer, or nil when there are none.
func (*Client) PeerProfileNames ¶ added in v0.27.0
PeerProfileNames is the asserted name of every peer this account holds one for, keyed by account id. Peers with no claim, or whose claim was withdrawn, are absent rather than present-and-empty.
One call rather than one per peer, because the callers are lists: a chat list, a group's member list, a contacts screen. Reading a small file per peer is what the layout is built for (see layout.go), but doing it from a widget rebuild would not be.
func (*Client) PeerSessionHealth ¶
func (c *Client) PeerSessionHealth(peer string) (*PeerSessionHealth, error)
PeerSessionHealth returns what is recorded about peer, or nil when nothing has gone wrong -- the normal case, and the reason this is its own small file per peer rather than a map everyone shares: most peers never have one.
func (*Client) PinMessage ¶
PinMessage pins a message locally. Pinning one already pinned leaves its place in the order alone rather than moving it to the end.
func (*Client) PinnedMessageIDs ¶
PinnedMessageIDs lists a chat's pins, oldest-pinned first.
func (*Client) ProfileName ¶ added in v0.27.0
ProfileName is the name this account asserts about itself, and when it was last changed. Empty with a non-nil time means it was set and then withdrawn.
func (*Client) PurgeAndReplaceOneTimePrekeys ¶
PurgeAndReplaceOneTimePrekeys discards this device's entire published one-time-prekey pool and republishes a fresh batch, replacing rather than adding to it.
For the one situation Client.TopUpOneTimePrekeys cannot fix on its own: the published pool already contains an id this device's store has no private half for (SRV-23's Dart-side and core-side minting once running side by side left exactly this on every account that talked to anyone before the cut finished). Topping up only ever adds more on top -- the server hands out the *oldest* unclaimed key first (see store.ClaimOneTimePrekey), so a poisoned entry would still be claimed before any fresh addition ever is, no matter how many more get added. Only an actual replace fixes it.
Safe to call at any time, whether or not the pool is actually poisoned: every id being discarded is, by definition, unclaimed (a claim deletes atomically the moment it happens), so nothing has ever been built against any of them.
Clears first and mints only once the clearing is confirmed, in two round trips rather than one. That order exists for a server that predates replace_one_time_prekeys: encoding/json ignores an unknown field, so such a server silently *appends* the batch instead of replacing, leaving the poisoned ids in front of it -- and since it hands out the oldest first, nothing improves. Minting first would then add ten keys per attempt, forever, to both sides. Confirming the clear before minting turns that into one wasted request and an error saying exactly what is wrong.
func (*Client) PutConversation ¶
func (c *Client) PutConversation(convo Conversation) error
PutConversation inserts or replaces a conversation.
func (*Client) PutGroupChat ¶
PutGroupChat replaces a group's chat state.
func (*Client) PutGroupState ¶
PutGroupState replaces the fact set held for a group.
func (*Client) PutOneTimePrekeys ¶
func (c *Client) PutOneTimePrekeys(keys []OneTimePrekey) error
PutOneTimePrekeys stores a freshly generated batch, one file each.
Not one file for the pool: consuming a single prekey then rewrites the whole pool, and a top-up writes every key that was already there. One file per key makes both operations cost exactly what they touch.
func (*Client) ReceiptsEnabled ¶
ReceiptsEnabled reports whether this account confirms anything to anybody.
Reciprocal by design, and that is the whole of the setting: with it off this account neither tells a peer their message arrived or was read, nor records what a peer says about its own. Both statuses, not only "read" -- a delivery tick still says somebody's device is awake and holding their messages, which is the thing being declined.
Watermarks already recorded are left alone. Turning the setting off stops what happens next; it does not rewrite what was true before, and a peer whose ticks are already showing has already been told.
func (*Client) ReconcileGroup ¶
func (c *Client) ReconcileGroup(ctx context.Context, outcome GroupOutcome, peerAccountID string) error
ReconcileGroup acts on what a group envelope said about the sender's view.
Three cases, and the first is the one that is easy to miss: we hold no facts at all. Nothing tells the sender that -- our own hash only travels on messages we cannot send without a member list -- so the state would end only if some member happened to send a snapshot unprompted. Asking outright is the only way out, and the sender is the one member we know exists, because they just wrote to us.
func (*Client) RecordAutoRekey ¶
RecordAutoRekey notes that an automatic re-key with peer has just been sent: the evidence that triggered it is spent, but the timestamp stays to space out any further attempt.
func (*Client) RecordDecryptFailure ¶
RecordDecryptFailure counts one failed decrypt and reports whether this envelope should now be given up on -- dropped from the server queue rather than retried forever.
Persisted rather than counted in memory alone because a background push wake is torn down between deliveries: a counter living only in RAM restarts at zero every time and never reaches the limit, so an envelope that can never be decrypted would be refetched and refail on every single wake.
Reaching the limit clears the counter as it reports true, so a caller that re-queues the envelope anyway starts counting afresh instead of giving up instantly forever after.
func (*Client) RecordDesyncEvidence ¶
RecordDesyncEvidence counts one envelope from peer given up on for a reason that implies diverged keys, and reports whether it was counted.
Call once per envelope, and only when the failure was both exhausted and classified as a desync: counting every attempt reaches any threshold three times over, and counting a redelivery or an undiagnosed error recovers sessions that were never broken.
Ignored, returning false, for a peer with no conversation. That bounds this to conversations that exist -- recovery has nowhere to send without one anyway, and without the guard a stranger sending undecryptable envelopes could grow the store by one directory per account id they invent.
func (*Client) RecordGroupPeerStateHash ¶
RecordGroupPeerStateHash remembers what a member said their own view was.
Written on every group envelope, control or message, by whoever handles it. The send path reads it to decide whether that member needs the whole fact set before their next copy. An empty hash is not recorded: it means the sender did not say, which is not the same as having nothing.
func (*Client) RecoverDesyncedSessions ¶
RecoverDesyncedSessions re-establishes every session the evidence says is broken. Returns the peers it re-keyed.
The eligibility rules on top of Client.ShouldAutoRekey are all about whether sending to this peer makes sense at all: a blocked peer, an unaccepted request, or a conversation on another server while federation is off. The crypto question and the "is this worth a message" question are kept apart because only one of them is about the ratchet.
func (*Client) Register ¶ added in v0.23.0
func (c *Client) Register(ctx context.Context, server string, opts RegisterOptions) (Identity, error)
Register creates this account on server and stores the identity it produced.
Safe to call again after a failure: an interrupted attempt is resumed rather than restarted, so a crash between the request and its answer cannot leave a second, orphaned account behind. Reports ErrAlreadyRegistered once there is a finished identity here, since that is a caller mistake rather than a state to recover from.
Registering does not publish prekeys. That is Client.RotatePrekeys and Client.TopUpOneTimePrekeys, kept separate because they are also what a long-lived account does periodically, and folding them in here would make the first run silently different from every run after it.
func (*Client) RemainingOneTimePrekeys ¶
RemainingOneTimePrekeys is how many keys the server still holds for this device. Reading it consumes nothing, unlike claiming a bundle.
func (*Client) RemoveFromGroup ¶
RemoveFromGroup removes a member.
They are told, which is the point: they hold the facts either way, and a member who is removed silently goes on trying to send into a group nobody will accept from them.
func (*Client) Report ¶ added in v0.27.0
func (c *Client) Report(ctx context.Context, peer string, category ReportCategory, opts ReportOptions) error
Report tells an operator that peer is a problem.
**The report is named.** The reporter's address is stored and shown to that server's staff, so they can come back and ask what happened -- which is the main thing an operator does with a report, and the reason this is not anonymous. A caller must say so before calling this, not after.
The evidence sent is the profile claims that peer has asserted about *itself* (SRV-32), signed, exactly as they arrived. Never a local name the user gave them: that is a private note, often an unkind one, and it is stored somewhere else entirely so that it cannot be picked up here by accident.
Nothing acts on a report by itself. It raises a counter and files a case for a person to look at, which is all a server can honestly do about a claim it has no way to verify -- the content it concerns is end-to-end encrypted.
func (*Client) RequestGroupSync ¶
RequestGroupSync asks one member of a group we already hold facts about to send us the whole set.
The active half of reconciliation, and the one easily left out: a state hash says "we differ", never who is behind, so a device that missed a fact finds out only when somebody next sends into the group -- and if that somebody is *us*, and we are the one behind, nothing happens at all. This asks outright. Client.ReconcileGroup covers only the case where there are no facts here whatsoever, which is a different and much louder failure.
One member rather than all: the fact set is grow-only, so any member holds the whole of it and one answer is as good as ten -- where ten would put a snapshot in every member's queue each time somebody opens a group screen. The founder first because they are the one member who cannot have left, then anybody who has joined; a pending invitee may hold nothing yet.
Asked only where there is reason to think we are behind, which is what keeps this from being pure traffic: a group everybody agrees about is left alone. See groupSyncWorthAsking -- the first version fired on every open, and on a device holding a dozen accounts that were members of the same group it turned each chat switch into a burst of envelopes to accounts on the same phone, every one of them costing the recipient a push wake.
Best-effort by design: no debt is recorded and nothing is owed to anyone, because this asks for something we lack rather than sending something somebody else needs. The per-group cooldown is [AskForGroupFacts]'s.
func (*Client) ResendPendingReceipts ¶
ResendPendingReceipts re-fires every confirmation that never went out.
A receipt is recorded as sent only once it actually has (see Client.SendReceipt), so one lost to a failed send is not marked done and goes again -- but only the next time there is something new to confirm, and a quiet conversation may not offer that for days. The peer's ticks stay a step behind for no reason either side can see. This is the sweep that closes it, hung off a fresh connection, where whatever broke the last attempt is most likely to have passed.
Nothing here decides what to send: it names the newest thing each peer said and lets SendReceipt and SendGroupReceipt apply their own rule, which already skips anything that would say nothing new. A converged account therefore sends nothing at all, and the "already told them" watermarks stay the single place that decides.
A read receipt only for a chat whose unread flag is clear. Re-firing one for a chat the user never opened would claim they had read it, and a sweep must not tell a peer something the user did not do.
func (*Client) ResetSession ¶
ResetSession discards the local session with peer and re-establishes it.
The signal that follows is almost empty on purpose: its whole job is to exist, because sending anything at all after discarding a session re-runs X3DH and puts a fresh prekey block on the wire, which is the only thing that can re-point the peer at a session this side can read.
Waiting instead does not work, and the reason is that a desync is asymmetric. What breaks is *receiving* -- the peer goes on sending happily into a session that no longer opens here, and nothing they do fixes it. Waiting for the user to happen to type something leaves the conversation broken for as long as they stay quiet.
func (*Client) ResolvePeer ¶
func (c *Client) ResolvePeer(ctx context.Context, addressOrPrefix, server string) (PeerEndpoint, error)
ResolvePeer looks an account up and picks a device to talk to.
addressOrPrefix is whatever the user typed: a full address, or the prefix a server will complete. server names where to ask, empty for our own.
Two checks, and neither is optional. The account id must be derivable from the root key it came with, or the server handed us somebody else's account under the name we asked for. Each device's certificate must verify against that root key, or the server added a device the account holder never signed -- which is exactly how a server would read its users' messages if it could.
func (*Client) RetryGroupMessage ¶
func (c *Client) RetryGroupMessage(ctx context.Context, groupID, messageID string) (SendResult, error)
RetryGroupMessage re-sends a group message whose send failed, addressing only the members whose copy never arrived.
Mirrors Client.RetryMessage's shape and for the same reason: an attachment that never made it to the server is re-uploaded, one that did is simply named again, and the message must already be recorded SendFailed. It deliberately does not go back to everyone -- a member whose delivery is already SendSent is not revisited, or a retry would duplicate the text for whoever already has it. That is also why fanOut's own de-duplication (an advance is only persisted once the copy has actually gone out) is enough here too: a second attempt re-encrypts under the same message number, and a recipient who did receive the first copy recognises the retry as the duplicate it is.
A member who joined after the original attempt is not retroactively sent it: they have no delivery record for this message at all, so they are never in the retried set -- joining grants no access to what was said before, and a retry is not an exception to that.
func (*Client) RetryMessage ¶
func (c *Client) RetryMessage(ctx context.Context, peerAccountID, messageID string) (SendResult, error)
RetryMessage re-sends a message whose send failed, including one composed in an earlier run.
Safe to call after a send that may in fact have arrived. The failed attempt rolled the ratchet back, so a retry re-encrypts under the same message number -- and if the first copy did land, the peer's ratchet recognises the second as a duplicate and refuses it. The de-duplication that makes this safe is therefore the rollback in [Client.deliver], not the wire id, which is fresh on every attempt.
func (*Client) RotatePrekeys ¶
RotatePrekeys mints a new signed prekey and a full batch of one-time prekeys, and publishes them.
This is the deliberate rotation: registration, restoring an account onto a new device, or a user asking for fresh key material. It is *not* what runs periodically -- see Client.TopUpOneTimePrekeys, which re-asserts the existing signed prekey instead. Rotating on every top-up would replace the key peers are mid-establishment against, several times a day, for nothing.
A DH identity key is minted only if this account has none, and never replaced: it is the long-term half of X3DH, and every session ever established with this device is bound to it.
func (*Client) SendGroupReceipt ¶
func (c *Client) SendGroupReceipt(ctx context.Context, groupID, toAccountID string, status ReceiptStatus, upTo time.Time) error
SendGroupReceipt tells one member how far we have got with *their* messages in a group.
Sent to the author alone and never re-broadcast: who has read what stays between reader and author. That is also why the receipt carries the group id -- without it the author would file it against their one-to-one chat with us and confirm messages we never mentioned.
upTo is a cumulative watermark in the *author's* clock, so confirming their newest message confirms every earlier one they wrote: one marker per member answers for the whole transcript, and nothing has to be tracked per message. One that would say nothing new is skipped entirely, since opening a group twice must not cost a second round of receipts -- see Client.SendReceipt, which follows the same rule for a one-to-one conversation.
func (*Client) SendGroupSnapshot ¶
SendGroupSnapshot hands one member the whole fact set.
The answer to every kind of divergence, because the fold is order independent and a fact already known is simply known -- so reconciling two views never needs a delta protocol, a version vector or a sequencer.
func (*Client) SendGroupText ¶
func (c *Client) SendGroupText(ctx context.Context, groupID, text string, opts SendOptions) (SendResult, error)
SendGroupText encrypts a message once per member and delivers it.
The transcript line is written first and carries one delivery record per recipient, so a partial failure is visible as exactly that: the message is sent, to these members, not yet to those.
func (*Client) SendMessage ¶
func (c *Client) SendMessage(ctx context.Context, recipientAccountID, recipientDeviceID, messageID string, payload json.RawMessage) error
SendMessage delivers one encrypted envelope to a device on this account's own server.
A 409 counts as delivered, not as a failure: the server de-duplicates by message id, so the second copy of a retry being refused is the retry working exactly as intended. Treating it as an error is how a client ends up either sending twice or reporting a delivered message as failed.
func (*Client) SendReceipt ¶
func (c *Client) SendReceipt(ctx context.Context, peerAccountID string, status ReceiptStatus, upTo time.Time) error
SendReceipt confirms to peer how far we have got with their messages.
upTo is a cumulative watermark in the *sender's* clock -- their own sent_at, echoed back -- which is what lets one arriving late be harmless: it moves a single monotonic value and touches no message. Sending one that would not move our own record of it is skipped entirely, since a receipt that says nothing new is pure traffic.
func (*Client) SendText ¶
func (c *Client) SendText(ctx context.Context, peerAccountID, text string, opts SendOptions) (SendResult, error)
SendText records a message in the transcript and delivers it.
The line is written *before* the network is touched and marked SendPending, which is what lets a composer clear the instant the user hits send rather than freezing on a slow connection. A failure leaves it SendFailed and returns the error: the line is the durable feedback, and Client.RetryMessage is what acts on it.
func (*Client) ServerStatus ¶
ServerStatus fetches a server's capabilities. Pass an empty server for this account's own; pass another origin to discover a peer's before sending something it may not support.
Unauthenticated on purpose: a sender has to be able to size an attachment for a server it has no account on.
func (*Client) Session ¶
Session returns the stored session with peer, or nil if there is none. A missing session is an ordinary state -- first contact -- not an error.
func (*Client) SetGroupDelivery ¶
func (c *Client) SetGroupDelivery(chatID, messageID string, delivery GroupDelivery) error
SetGroupDeliveryState moves one recipient.s copy of a group message, so a retry can address only the copies that failed.
The whole record replaces the previous one rather than being merged into it, which is what lets an attempt that finally succeeded drop the reason the earlier one failed. GroupDelivery.WireMessageID is part of that on purpose: it is the id the recipient's server de-duplicates by, so a retry that cannot read it back delivers the message to them a second time.
func (*Client) SetGroupMeta ¶
SetGroupMeta renames a group or changes its topic.
Both together, as one last-writer-wins record: two independently merged fields would produce a conflict case that buys nothing.
func (*Client) SetGroupRole ¶
func (c *Client) SetGroupRole(ctx context.Context, groupID, accountID string, role group.Role, grant bool) error
SetGroupRole grants or revokes a role.
func (*Client) SetIdentity ¶
SetIdentity writes the account's key material, replacing whatever was there. Used both by first-time setup and by a restore from a recovery phrase, which keeps the root key and mints a fresh device key.
func (*Client) SetMessageAttachments ¶
func (c *Client) SetMessageAttachments(chatID, messageID string, attachments []Attachment) error
SetMessageAttachments replaces what a message says about its attachments.
Exists because a picture is shown before it is uploaded: the transcript line appears with a local preview the moment the user picks the file, and only once the blob is stored does it gain the id and key a recipient needs. Doing it the other way round -- upload first, then show -- means a composer that sits frozen for the length of an upload.
func (*Client) SetMessageSendState ¶
SetMessageSendState moves one of our own messages between pending, sent and failed as the send progresses or gives up.
func (*Client) SetProfileName ¶ added in v0.27.0
SetProfileName records the name and stamps it. Passing an empty name is the withdrawal, and it keeps a timestamp for the reason settingsFile explains.
Nothing is sent from here: the claim rides on the next envelope to each peer (SRV-32), so a rename costs no delivery of its own and reaches the people it is relevant to at the moment it becomes relevant.
func (*Client) SetReceiptsEnabled ¶
SetReceiptsEnabled records the account's answer, for every consumer of this directory and every process that opens it.
func (*Client) SetSession ¶
SetSession stores a session, replacing any previous one of the same kind.
One file per peer and kind, which is the point: a session advances on every message, and writing it must cost that session and nothing else. A single file holding every peer's sessions would rewrite them all for one message -- the same defect, one level up.
func (*Client) ShouldAutoRekey ¶
ShouldAutoRekey reports whether the evidence recorded about peer justifies discarding the local session and re-establishing it.
Answers only the crypto-state question. A caller applies its own eligibility rules on top -- a blocked peer, an unaccepted message request, or a conversation whose server has federation switched off is never worth sending to, whatever the ratchet says.
func (*Client) StartConversation ¶
func (c *Client) StartConversation(ctx context.Context, addressOrPrefix, server string) (Conversation, error)
StartConversation resolves an address the user has entered and creates the local conversation for it.
The peer is marked known by this, which is what separates a conversation the user opened from one that opened itself: a stranger's first message creates a conversation awaiting approval, and reaching out to somebody is that approval already given. Without it, answering a message you initiated would arrive as a request from a stranger.
Nothing is sent. Establishing the session happens on the first message, because that is when there is something to put a prekey block on -- and a conversation opened and then abandoned should not have claimed one of the peer's one-time prekeys.
func (*Client) Stream ¶
func (c *Client) Stream(ctx context.Context, policy StreamPolicy) <-chan StreamEvent
Stream opens the message stream and keeps it open, reconnecting on its own until ctx is cancelled. The returned channel is closed once the loop has stopped, so a caller can range over it.
Events are dropped rather than blocking the stream if the caller stops reading: a consumer that has gone away must not be able to stall the connection, and a stalled connection would look to the server like a client that is still there.
func (*Client) TopUpOneTimePrekeys ¶
TopUpOneTimePrekeys refills the pool if the server says it has run low.
Deliberately asks the server rather than counting locally: the server is the one handing keys out, and a local count only knows about the ones this device has consumed by receiving. A device that is claimed from while offline learns nothing until it asks.
The signed prekey is re-signed with the *same* key material every time. The endpoint requires one and replaces whatever is on file, so leaving it out is not an option -- but re-asserting is not rotating, and the difference is the whole reason this is a separate call.
func (*Client) UnblockPeer ¶
UnblockPeer lifts a local block.
func (*Client) UnpinMessage ¶
UnpinMessage removes a pin.
func (*Client) UploadAttachment ¶
func (c *Client) UploadAttachment(ctx context.Context, recipients []PeerEndpoint, media OutgoingMedia) (Attachment, error)
UploadAttachment encrypts media and uploads it for the given recipient devices, returning what a message needs to carry to describe it.
The recipient set is part of the upload rather than of the message: the server grants access per device, so a blob nobody was named for cannot be fetched by anybody. That is also why a group picture is uploaded once with every member's device listed instead of once per member.
func (*Client) WasMessageProcessed ¶
WasMessageProcessed reports whether this envelope has already been handled.
The check that has to happen before anything else touches the ratchet. Delivery is at-least-once and the server keeps a message queued until the client deletes it -- a delete that is lost when the app is killed, goes offline, or when a push wake races the live stream. Processing an envelope twice advances the ratchet twice for one message, and a redelivered X3DH initial rebuilds the responder session over the one that has since advanced.
Answered from memory: it runs for every incoming envelope, and a disk read per envelope would be a needless cost on the hottest path there is.
func (*Client) WithdrawReport ¶ added in v0.27.0
WithdrawReport takes back a report about peer.
Somebody who bears responsibility for an accusation has to be able to change their mind, so this is always available. It withdraws from both servers a report may have gone to; a server that has none simply answers 404, which is not an error to report to anybody -- the outcome the user asked for is that no report of theirs stands, and it does not.
func (*Client) WriteAttachmentFile ¶
WriteAttachmentFile stores the bytes for one message's attachment.
Called on both sides and for the same reason each time: the sender writes its own copy *before* the message is even sent, which is what makes an unsent picture durable enough to retry after the app is closed; the receiver writes what it downloaded, so a picture is fetched once rather than on every scroll past it.
func (*Client) WriteAttachmentThumb ¶
WriteAttachmentThumb stores the inline preview a message carried.
Written the moment the message arrives, including on a background wake with no screen to draw on: it is a kilobyte, so it costs nothing, and it means a picture shows *something* immediately rather than an empty bubble that reads as a message with nothing in it.
type Content ¶
type Content struct {
Kind ContentKind
// ID is the sender's own id for the message, which is what a reply and a
// delete refer to. Empty for a legacy envelope, which had none -- the
// receive path mints one so a line always has an id locally.
ID string
Text string
// SentAt is the sender's clock, absent for legacy envelopes. Receipts are
// anchored to it rather than to arrival time, so the two sides can agree
// on what "up to here" means without agreeing on the time.
SentAt *time.Time
// SenderServer travels with every message rather than only the first, so a
// peer stays reachable even if local state about them is lost.
SenderServer string
Attachments []Attachment
ReplyToID string
ReplyPreviewText string
ReplyPreviewMine *bool
ReplyPreviewAuthorID string
// Receipt fields.
ReceiptStatus ReceiptStatus
ReceiptUpTo time.Time
ReceiptGroupID string
// RekeyReason is set for ContentRekey.
RekeyReason RekeyReason
// Group fields. StateHash is the sender.s view of the group.s fact set,
// which is what tells the receiver whether they are behind.
GroupID string
StateHash string
// ControlKind and Events are the payload of a group control envelope: what
// the sender is doing, and the facts they are passing on.
ControlKind GroupControlKind
Events []*group.Event
// Profile is the name the sender asserts about itself, if this envelope
// carried one (SRV-32). Present here exactly as it arrived and **not yet
// verified**: checking it needs the sender's device certificate, which
// decoding has no access to. [Client.applyProfileClaim] is what turns one
// into something worth showing.
Profile *profileclaim.Claim
// Raw is the undecoded plaintext, kept so a caller that owns a part of the
// protocol this package does not yet handle -- group control, today -- can
// act on it without decoding twice.
Raw []byte
}
Content is one decrypted payload, interpreted. Which fields carry anything depends on Kind; the rest are zero.
func DecodeContent ¶
DecodeContent interprets decrypted plaintext.
It never fails. Every unrecognised shape has a defined reading -- legacy raw text, or the "newer feature" placeholder -- because the alternative is dropping an envelope whose ratchet step has already been taken, and a message that cannot be shown is still better evidence of itself than silence. A caller wanting to know whether the content was understood should look at Content.Kind and, for text, whether the version was known.
type ContentKind ¶
type ContentKind string
ContentKind is what a decrypted payload turned out to be.
const ( ContentText ContentKind = "text" ContentReceipt ContentKind = "receipt" ContentRekey ContentKind = "rekey" ContentGroupText ContentKind = "group_text" ContentGroupControl ContentKind = "group_control" )
type Conversation ¶
type Conversation struct {
PeerAccountID string
PeerServer string
LastActivityAt *time.Time
HasUnread bool
// Blocked mirrors the block on this conversation; the blocked list is the
// copy that survives the conversation being deleted.
Blocked bool
// PendingApproval marks a conversation opened by a stranger, awaiting the
// user's accept.
PendingApproval bool
// PeerGone records that the peer's *account*, not merely a device, was
// confirmed gone by asking their server (see [Client.markPeerGone]) --
// set once so every later send refuses at no network cost instead of
// asking the same question again. Permanent in practice: an account id is
// derived from the key material behind it, so a deleted account never
// returns under the same id for someone new to inherit.
PeerGone bool
// Receipts are cumulative watermarks, not per-message marks: a receipt says
// "everything up to this instant". That is what makes one arriving late
// harmless -- it moves a monotonic value and touches no message, so newer
// lines appended in between simply fall under it, which is correct.
PeerDeliveredUpTo *time.Time
PeerReadUpTo *time.Time
SentDeliveredReceiptUpTo *time.Time
SentReadReceiptUpTo *time.Time
}
Conversation is the per-peer metadata around a transcript.
type DecryptError ¶
type DecryptError struct {
MessageID string
PeerAccountID string
Err error
// DesyncEvidence: this failure means diverged keys, so it counts towards
// re-establishing the session. False for an undiagnosed failure -- damaged
// JSON, a storage error -- which says nothing about the ratchet, and
// discarding a working session over one would lose messages for nothing.
DesyncEvidence bool
// GaveUp: this envelope has now failed often enough to stop retrying it.
// A decrypt failure is deterministic -- the same ciphertext against the
// same session fails identically -- so a poison envelope would otherwise
// block the queue behind it forever. The caller acknowledges it away.
GaveUp bool
// RekeyNeeded: the evidence now justifies discarding the local session and
// re-establishing it (see [ShouldAutoRekey]). Reported rather than acted
// on, because acting means sending.
RekeyNeeded bool
}
DecryptError is a failed attempt at one envelope, carrying the decision that goes with it. It wraps the underlying error rather than describing it, so ratchet.FailureCode and ratchet.SuggestsDesync still work on it -- losing that classification is what makes a harmless redelivery indistinguishable from a session that has genuinely diverged.
func (*DecryptError) Error ¶
func (e *DecryptError) Error() string
func (*DecryptError) Unwrap ¶
func (e *DecryptError) Unwrap() error
type DrainFailure ¶ added in v0.23.0
type DrainFailure struct {
MessageID string
SenderAccountID string
Err error
// Acknowledged reports that the envelope was given up on and taken off the
// server's queue. False means it is still there and will come again --
// which is the point, for a failure a later attempt can still resolve.
Acknowledged bool
}
DrainFailure is one envelope that could not be read.
func (DrainFailure) Error ¶ added in v0.23.0
func (f DrainFailure) Error() string
type DrainReport ¶ added in v0.23.0
type DrainReport struct {
// Results are the envelopes that were read, in the order they arrived.
// Duplicates are included and marked as such, since a caller counting
// arrivals and a caller drawing them want different things from that.
Results []ReceiveResult
// Failures are the envelopes that were not.
Failures []DrainFailure
}
DrainReport is what one pass over the queue produced.
Both halves are returned rather than the first error: a queue is a batch, and one unreadable envelope must not hide the twenty behind it that were fine.
type ErrAccountInUse ¶ added in v0.23.0
type ErrAccountInUse struct {
Path string
}
ErrAccountInUse reports an account directory held by a different process.
Recoverable only by that process letting go, so a caller should say which directory and stop rather than retry: the holder is a live daemon, and waiting for it would mean waiting for it to exit.
func (*ErrAccountInUse) Error ¶ added in v0.23.0
func (e *ErrAccountInUse) Error() string
type ErrBlobUnavailable ¶
type ErrBlobUnavailable struct {
}
ErrBlobUnavailable reports a blob the server will not serve: expired, deleted, or belonging to an account that no longer exists. Distinguished from a transport failure because it is permanent -- retrying is pointless, and the caller should show a placeholder rather than a spinner.
func (*ErrBlobUnavailable) Error ¶
func (e *ErrBlobUnavailable) Error() string
func (*ErrBlobUnavailable) Unwrap ¶
func (e *ErrBlobUnavailable) Unwrap() error
type GroupChat ¶
type GroupChat struct {
GroupID string
LastActivityAt *time.Time
HasUnread bool
// MemberReceipts is how far each member has got with *our* messages in
// this group -- filed per member and never passed on. Who has read what
// stays between reader and author, which is why a group receipt is sent to
// the author alone rather than into the group.
MemberReceipts map[string]MemberReceipt
}
GroupChat is a group's transcript-adjacent state: what a chat list needs that the fact set does not answer.
Separate from Conversation rather than reusing it, because the two share only the fields around the transcript. A group has no peer server, no approval to give, and no single correspondent to hold receipt watermarks for; a one-to-one chat has no membership. Name and topic are not here either -- they are facts, and live in the fold.
type GroupControlKind ¶
type GroupControlKind string
GroupControlKind is what a control envelope is doing.
const ( // GroupSnapshot carries the sender's whole fact set. The answer to // everything: two members reconcile by one sending all of it, because the // fold is order-independent and a fact already known is simply known. GroupSnapshot GroupControlKind = "snapshot" // GroupEvents carries a few new facts, the ordinary case. GroupEvents GroupControlKind = "events" // GroupSyncRequest asks for a snapshot and carries none. GroupSyncRequest GroupControlKind = "sync_request" )
type GroupDelivery ¶
type GroupDelivery struct {
AccountID string `json:"account_id"`
// WireMessageID is what the recipient's server de-duplicates by, so a retry
// cannot deliver a second copy. Random and per recipient: sharing the
// message's own id would make two members on the same server collide.
WireMessageID string `json:"wire_message_id"`
State SendState `json:"state"`
// Error is why this copy failed, in a sentence meant for the person who
// sent it, and empty for one that did not fail. Persisted rather than kept
// for the run that produced it: a fan-out that failed overnight is looked
// at in the morning, and "failed" with no reason is a dead end for whoever
// has to act on it. Local -- it never goes on the wire.
//
// A server being unreachable is ordinary here rather than exceptional: it
// may be down for an hour, switched off for good, or reused for something
// else entirely. So this says that, in those words, and leaves the
// endpoint and the syscall to [Detail].
Error string `json:"error,omitempty"`
// Detail is the same failure in the words of whatever refused it. Never
// shown: it exists so that making Error readable costs nothing in
// diagnosis, since for a failure nobody watched this is the only record
// there will ever be.
Detail string `json:"detail,omitempty"`
// AttachmentSkipped: they got the caption but not the picture, because
// their server does not store attachments or would not take this one. Not a
// delivery failure, and permanent -- a retry cannot fix it, since a
// delivery that already counts as sent is never revisited.
AttachmentSkipped bool `json:"attachment_skipped,omitempty"`
}
GroupDelivery is one recipient's copy of a group message.
type GroupOutcome ¶
type GroupOutcome struct {
GroupID string
// PeerStateHash is the sender's own view, so a divergence is visible
// without anyone having to ask about it.
PeerStateHash string
// StateHash is ours after applying, empty for a group we hold no facts
// about.
StateHash string
// WantsSnapshot: they asked for our fact set outright.
WantsSnapshot bool
// Invited: this envelope was an invitation *to us*, into a membership we
// did not hold before. The one group event worth waking someone for.
Invited bool
// Lines are the transcript lines the change produced, already appended.
// Returned so a caller can show them without re-reading the transcript.
Lines []string
// DeliveredUpTo is the watermark to confirm back for a group *message*,
// in the sender.s clock. Its own field rather than the one-to-one one,
// because a receipt travels over a conversation: reporting a group anchor
// through that field would confirm the member.s unrelated direct messages.
DeliveredUpTo *time.Time
// ReadUpTo is the same watermark again, set only when the message landed
// in the chat the user is looking at -- which makes it read on arrival,
// with nobody left to open anything.
//
// Stated here rather than left for the caller to work out from
// ReceiveOptions.OpenChatID, because this side has already acted on that
// same fact by not marking the chat unread. Two places deciding it
// separately is exactly how the two halves came apart: the flag was
// skipped, no receipt was ever sent, and opening the chat later found no
// unread flag to act on, so the author was never told.
ReadUpTo *time.Time
}
GroupOutcome is what applying one control envelope changed.
type Identity ¶
type Identity struct {
AccountID string
Server string
RootPub []byte
RootPriv []byte
DeviceID string
DevicePub []byte
DevicePriv []byte
DHIdentityPub []byte
DHIdentityPriv []byte
SignedPrekeyID uint32
SignedPrekeyPub []byte
SignedPrekeyPriv []byte
// NextSignedPrekeyID and NextOneTimePrekeyID are allocation counters, kept
// so a reissued key never reuses an id a peer may still be referencing.
NextSignedPrekeyID uint32
NextOneTimePrekeyID uint32
// RecoveryBackupDone is set once the user has written down (or explicitly
// dismissed) the recovery phrase. True from the start for an account that
// was itself restored from one.
RecoveryBackupDone bool
// PushRegisteredAt and PushMechanism record when this account last told its
// own server where to send wakes, and how ("fcm", or "unifiedpush:<pkg>").
// Per account rather than per install: one account's server can be
// unreachable while another's is fine.
PushRegisteredAt *time.Time
PushMechanism string
}
Identity is an account's own key material and per-account settings.
func NewIdentity ¶ added in v0.23.0
NewIdentity generates the keys and ids one account is, without registering anything: a root key, a device key, a device id, and the account id derived from the root public key.
Exported because an account id is a derivation rather than an assignment, so a caller can know its own address before any server has heard of it.
func (Identity) Address ¶ added in v0.23.0
Address is this identity's portable address -- the id together with the server it lives on, which is what a person copies out of one client and pastes into another.
Here rather than at each call site because the alternative is what actually happened: consumers concatenated `AccountID + "*" + Server` in half a dozen places, each keeping whatever spelling of the server it happened to be configured with, so one account had several written forms. The server is normalized for the same reason -- a trailing slash or a missing scheme in configuration should not travel out into an address somebody else will type.
type IncomingMessage ¶
type IncomingMessage struct {
MessageID string
SenderAccountID string
SenderDeviceID string
SentAt time.Time
Payload json.RawMessage
}
IncomingMessage is one envelope as the server hands it over, from the queue or from the stream. The payload is opaque here: only the crypto layer can open it.
type MaintenanceReport ¶ added in v0.23.0
type MaintenanceReport struct {
PrekeysToppedUp bool
DebtsPaid int
Recovered []string
ReceiptsResent int
// GoneMembers are group members whose account no longer exists. Reported
// because this is the only moment anything finds out: their row stays in
// the group until a moderator removes it, so nothing else will ever
// mention them again.
GoneMembers []string
// Problems are the parts that failed. Upkeep is best-effort by nature --
// one failing part must not stop the others -- so they are collected here
// rather than returned as an error.
Problems []error
}
MaintenanceReport is what one round of upkeep did, and what it could not do.
type MemberReceipt ¶
type MemberReceipt struct {
DeliveredUpTo *time.Time
ReadUpTo *time.Time
SentDeliveredReceiptUpTo *time.Time
SentReadReceiptUpTo *time.Time
}
MemberReceipt is one member.s pair of watermarks. Cumulative, so one arriving late is harmless: it moves a monotonic value and touches no message. The Sent* pair is the mirror image: how far we have already told *them* about *their* messages. Kept for the same reason Conversation keeps it, so an identical receipt is not re-sent every time the group is opened.
type Message ¶
type Message struct {
ID string
Text string
// Mine distinguishes our own line from the peer's.
Mine bool
// Timestamp is when this device recorded the line. SenderSentAt is the
// sender's own clock from inside the envelope -- absent for our own
// messages and for senders predating the field.
Timestamp time.Time
SenderSentAt *time.Time
// SenderAccountID is empty for our own messages and in a one-to-one chat,
// where the peer is the conversation. A group transcript needs it.
SenderAccountID string
// A reply carries a snapshot of what it answers rather than a live lookup:
// the quoted message may since have been deleted locally, and a quote that
// vanishes with the original is worse than a stale one.
ReplyToID string
ReplyPreviewText string
ReplyPreviewMine *bool
ReplyPreviewAuthorID string
Kind MessageKind
SendState SendState
Attachments []Attachment
Deliveries []GroupDelivery
}
Message is one line of a transcript. The server never stores plaintext or keeps history, so this is the only copy there is.
type MessageKind ¶
type MessageKind string
MessageKind distinguishes a chat line from a local notice.
const ( // MessageNormal is an ordinary chat message, rendered as a bubble. MessageNormal MessageKind = "normal" // MessageSystemInfo is a local, never-transmitted line rendered centred -- // "Secure session was reset" and the like. It has no sender and no clock // but its own. MessageSystemInfo MessageKind = "system_info" )
type NotFreizoneServerError ¶
NotFreizoneServerError reports a host that answered, but not in the JSON every Freizone server speaks -- almost always an address that resolved to something else entirely: a parked page, a plain web server's HTML 404, a reverse proxy.
Deliberately distinct from APIError, which is a real Freizone server refusing a request. This one means "wrong server", so a caller can point the user at the address they typed instead of showing them a raw HTML page.
func (*NotFreizoneServerError) Error ¶
func (e *NotFreizoneServerError) Error() string
type OneTimePrekey ¶
OneTimePrekey is one uploaded prekey pair, held until a peer claims it. The server never says which one it handed out.
type Options ¶
type Options struct {
// MediaPath is where attachment bytes are stored, defaulting to a "media"
// directory inside the account. Separable because pictures are the one
// thing here that is large, disposable and platform-opinionated: a phone
// may want them in storage the system can reclaim, a server on another
// disk, a bot nowhere in particular. See media.go.
MediaPath string
}
Options configure an account beyond where it lives. The zero value is what Open uses.
type OutgoingMedia ¶
type OutgoingMedia struct {
Bytes []byte
MimeType string
// Width and Height let a bubble reserve the right shape before the
// download finishes, so a transcript does not jump as pictures land.
Width, Height int
// Thumb is a tiny preview carried *inside* the message rather than as a
// blob, so something is visible the moment it arrives -- before, and even
// without, the download. A kilobyte or so; anything larger belongs in the
// blob.
Thumb []byte
// Kind is "image" today. Left open because an unknown kind must render as
// a placeholder rather than break the message around it.
Kind string
}
OutgoingMedia is a file on its way out, before it has a blob id.
type PeerEndpoint ¶
type PeerEndpoint struct {
AccountID string
// Server is the peer's home server, empty when it is our own. That
// emptiness is load-bearing rather than a convenience: it selects
// federated delivery and federated authentication throughout the send
// path, so a peer on our own server never takes the federation route.
Server string
DeviceID string
// DevicePub is the Ed25519 key every certificate from this device is
// checked against -- the prekey bundle's, and by extension the identity
// behind every message it sends.
DevicePub []byte
}
PeerEndpoint is one device to deliver to, with everything needed to verify what it sends back.
func (PeerEndpoint) Federated ¶
func (p PeerEndpoint) Federated() bool
Federated reports whether reaching this peer leaves our own server.
type PeerProfile ¶ added in v0.27.0
type PeerProfile struct {
Claims []profileclaim.Claim
// SentAt is when the claim this peer last received from us was issued.
SentAt *time.Time
}
PeerProfile is what a peer has asserted about its own name, newest first.
Every claim here has been verified: applyProfileClaim refuses to store one it could not check, so a caller never has to ask whether these are trustworthy statements *by that account* -- only whether the account is telling the truth, which is a question no signature can answer.
func (*PeerProfile) Current ¶ added in v0.27.0
func (p *PeerProfile) Current() *profileclaim.Claim
Current is the claim in force, or nil when the peer has never sent one.
func (*PeerProfile) Name ¶ added in v0.27.0
func (p *PeerProfile) Name() string
Name is the asserted name, empty when there is none or it was withdrawn.
type PeerSessionHealth ¶
type PeerSessionHealth struct {
// DesyncEvidence counts distinct envelopes given up on since the last
// successful decrypt, and only those whose failure code implies diverged
// keys. A redelivery or an undiagnosed error is evidence of nothing.
DesyncEvidence int
// FirstFailureAt anchors the grace period before this side re-keys. Nil
// exactly when DesyncEvidence is 0.
FirstFailureAt *time.Time
// LastRekeyAt outlives the evidence that triggered it, because it exists to
// space out *future* attempts rather than to justify the last one.
LastRekeyAt *time.Time
}
PeerSessionHealth is the evidence that a session with one peer has gone wrong. Present only for peers something has actually happened to.
type ReceiptStatus ¶
type ReceiptStatus string
ReceiptStatus is how far a peer has got with our messages.
const ( ReceiptDelivered ReceiptStatus = "delivered" ReceiptRead ReceiptStatus = "read" )
type ReceiveOptions ¶
type ReceiveOptions struct {
// OpenChatID is the chat the user is currently looking at, which is the
// only thing that suppresses a notification. A caller with no screen -- a
// background push wake, a bot -- leaves it empty, which is always right for
// them.
OpenChatID string
// Now overrides the clock, for tests. Zero means time.Now().
Now time.Time
}
ReceiveOptions are the caller's context for one envelope. The zero value is meaningful: no chat on screen, receipts recorded, clock is now.
type ReceiveResult ¶
type ReceiveResult struct {
PeerAccountID string
// Content is the decoded payload. Zero for a duplicate, which is not
// decrypted again: the point of recognising one is that the ratchet must
// not advance a second time.
Content Content
// Duplicate marks an envelope that had already been processed. Nothing is
// wrong -- delivery is at-least-once -- so it must neither be retried nor
// counted as evidence of anything.
Duplicate bool
// StoredMessageID is the transcript line this produced, empty for the
// control envelopes -- receipt, re-key, group membership -- that are never
// stored, and for a blocked peer.s message, which is decrypted and dropped.
StoredMessageID string
ShouldNotify bool
// DeliveredUpTo is the watermark to confirm back to the sender, in *their*
// clock domain (see Content.SentAt): a receipt says "everything up to this
// instant", and the two sides can only agree on that if it is stated in the
// clock the sender used. Nil when there is nothing to confirm.
DeliveredUpTo *time.Time
// ReadUpTo is that same watermark again, set only when the message landed
// in the chat on screen, which makes it read on arrival. See
// GroupOutcome.ReadUpTo for why this side states it rather than leaving
// the caller to infer it from ReceiveOptions.OpenChatID.
ReadUpTo *time.Time
// ProfileRenamed: this envelope carried a verified profile claim (SRV-32)
// that changed the name this peer asserts.
//
// The transcript line saying so is already written (see
// markRenameInTranscript) -- this is reported for a caller that wants to
// refresh a label, not as an instruction to write anything. Writing it here
// rather than leaving it to the caller is what makes it survive a
// background push wake, where there is no UI to do it.
ProfileRenamed bool
// AdoptedPeerSession: the peer re-keyed, or won the tie-break, and their
// session is now the one this side sends on.
AdoptedPeerSession bool
// InboundSessionKept: this side won the tie-break and keeps the peer's
// session for reading only, so their in-flight messages are not stranded.
InboundSessionKept bool
// Group is set for every group envelope: what folding it changed, or -- for
// a group message -- which group it belongs to and how far the sender has
// got. Nil for one-to-one traffic.
Group *GroupOutcome
// Blocked: the sender is blocked. The envelope was still decrypted (the
// ratchet must stay in step, and the server queue must still drain) and
// then dropped.
Blocked bool
}
ReceiveResult is what one envelope turned out to be and what was done about it. A caller acknowledges the envelope to the server on any result -- and on a DecryptError with GaveUp set -- but never otherwise.
type RegisterOptions ¶ added in v0.23.0
type RegisterOptions struct {
// InviteCode is required when the policy is `invite`, and ignored when it
// is `open`. A caller that does not know the policy can read it from
// [Client.ServerStatus] rather than discovering it as a refusal.
InviteCode string
}
RegisterOptions carry whatever the server's registration policy requires. The zero value is a registration on a server whose policy is `open`.
type RekeyReason ¶
type RekeyReason string
RekeyReason is why a peer threw their session away. Carried for the transcript marker and for diagnostics only -- never used to decide anything security relevant, so an unknown value is simply RekeyUnspecified rather than a reason to reject the signal.
const ( RekeyDecryptFailures RekeyReason = "decrypt_failures" RekeyUserRequested RekeyReason = "user_requested" RekeyUnspecified RekeyReason = "unspecified" )
type ReportCategory ¶ added in v0.27.0
type ReportCategory string
ReportCategory is why an account is being reported. A fixed set, matching the server's: there is deliberately no free-text field anywhere in this feature -- free text on a server is a store of personal allegations, and a channel for writing at an operator that nothing moderates.
const ( ReportSpam ReportCategory = "spam" ReportHarassment ReportCategory = "harassment" ReportFraud ReportCategory = "fraud" ReportOther ReportCategory = "other" )
type ReportOptions ¶ added in v0.27.0
type ReportOptions struct {
// AlsoTellTheirServer additionally files the report with the reported
// account's own home server, for a peer that lives on another one.
//
// Off by default, and a deliberate choice rather than something that
// happens quietly: it hands the reporter's address to an operator they do
// not know and have no relationship with. Filing with one's *own* server
// always happens either way -- that operator knows the reporter, and is
// the one who can act on a federated account at all (by blocklisting it).
//
// Ignored for a peer on this account's own server, where there is only one
// operator to tell.
AlsoTellTheirServer bool
}
ReportOptions carries the choices a report screen collects beyond the category.
type SendOptions ¶
type SendOptions struct {
ReplyToID string
ReplyPreviewText string
ReplyPreviewMine *bool
// Media is a file to attach, uploaded as part of the send. The transcript
// line appears before the upload starts, carrying the inline preview, so a
// picture is visible immediately rather than after however long the upload
// takes.
Media *OutgoingMedia
// Attachments describes blobs that are already uploaded. Used by a caller
// that manages its own upload -- a group fan-out, which uploads once for
// every member -- and mutually exclusive with Media.
Attachments []Attachment
// Now overrides the clock, for tests. Zero means time.Now().
Now time.Time
}
SendOptions carries the parts of a message that are not its text. The zero value is an ordinary message sent now.
type SendResult ¶
type SendResult struct {
Message Message
// EstablishedSession: this send ran X3DH rather than using a session that
// already existed.
EstablishedSession bool
// WithoutOneTimePrekey: the session was established without a one-time
// prekey, so its first message has no forward secrecy. Either the peer's
// pool ran dry, or -- worth noticing -- our own credentials were refused
// and the server withheld one. Never an error: a working conversation is
// worth more than one message's forward secrecy. Worth reporting, because
// the alternative is that it degrades silently forever.
WithoutOneTimePrekey bool
}
SendResult is the transcript line a send produced, plus what establishing the session cost.
type SendState ¶
type SendState string
SendState is how far one of our own outgoing messages has got. Anything received is always SendSent.
const ( // SendPending: the line is already in the transcript while the upload // and/or the encrypted POST are still in flight. That is the point -- the // composer clears the instant the user hits send rather than looking frozen // on a slow connection. SendPending SendState = "pending" SendSent SendState = "sent" SendFailed SendState = "failed" )
type ServerStatus ¶
type ServerStatus struct {
// Claimed reports whether the server has an owner yet; a fresh one is
// waiting to be bootstrapped.
Claimed bool
// RegistrationPolicy is "open", "invite" or "closed". A fourth value,
// "community" (SRV-15), is planned but not shipped yet.
RegistrationPolicy string
// FederationEnabled defaults to TRUE when absent -- a server predating the
// switch federates, and treating silence as "off" would strand every
// conversation with one.
FederationEnabled bool
// BlobsEnabled and MaxBlobBytes describe attachment transport, so a sender
// can size a picture to the *recipient* server's limit before uploading.
// Absent means an older server without blob support.
BlobsEnabled bool
MaxBlobBytes int64
// MaxBlobRecipients defaults to 1 when absent, NOT to unlimited. An older
// server ignores the extra recipients, stores the blob for the first one
// and still answers 201 -- so a sender assuming otherwise would silently
// deliver a picture to exactly one member of a group and believe it had
// reached everyone.
MaxBlobRecipients int
// BatchMessages and MaxBatchMessages describe batch delivery. Absent means
// an older server and the documented fallback is posting each message on
// its own, which is why groups already work against servers in the field.
BatchMessages bool
MaxBatchMessages int
// Attestation is an opaque pkg/attest token, served exactly as configured.
// Empty means none. Verifying it is the caller's job and must be done
// against the host actually being talked to, never the domain the token
// names.
Attestation string
// ReportsEnabled says whether this server accepts abuse reports (SRV-33).
// Absent means FALSE, unlike FederationEnabled: a server that does not say
// predates the endpoints and has none, so offering the action would produce
// a 404 the user has to interpret.
//
// Two separate questions in practice, and a caller has to ask both: its own
// server decides whether reporting is offered at all, the *reported*
// account's server decides whether it can additionally be told.
ReportsEnabled bool
}
ServerStatus is what a server says about itself on `GET /v1/server-status`.
This is where the project's standing compatibility rule lives in practice: availability is *discovered, never assumed*, because any combination of app and server version is in the field permanently. Every field here therefore has a documented meaning when the server did not send it -- and several of those defaults are not Go's zero value, which is the trap this type exists to close. Decoding straight into a plain struct would answer "does this server federate?" with false for every server old enough not to say.
type SessionKind ¶
type SessionKind string
SessionKind distinguishes the session this device sends on from one kept only for reading.
const ( // Sending is the session outgoing messages are encrypted with. Sending SessionKind = "sending" // Inbound is a session kept for READING only. When two sides establish at // the same moment, each holds its own initiator session and neither can // read the other's; the lower account id's session wins for sending // (PROTOCOL.md §5) and the loser is kept here. Discarding it instead // strands every message already in flight on it, and those look exactly // like a desync. Inbound SessionKind = "inbound" )
type StreamEvent ¶
type StreamEvent struct {
Kind StreamEventKind
Message IncomingMessage
Err error
}
StreamEvent is one thing that happened, delivered on the channel Client.Stream returns.
Deliberately one channel of a tagged union rather than several channels. A caller in Go can switch on Kind, and freizone-app's FFI wrapper -- which can only offer a blocking "give me the next event" call across the boundary -- needs exactly this shape. Several channels would have to be multiplexed back into one there anyway, and the multiplexing would live on the wrong side.
type StreamEventKind ¶
type StreamEventKind int
StreamEventKind is what happened on the message stream.
const ( // StreamConnected fires once per successful (re)connect, not just the // first. A caller uses it to redo whatever only holds while connected -- // re-checking the one-time-prekey pool, for instance, which otherwise only // ever happens at launch. StreamConnected StreamEventKind = iota // StreamMessage carries one envelope. StreamMessage // StreamDisconnected reports a stream that had come up and then ended. Not // an error on its own: a resume from background or a brief blip looks // exactly like this, and the reconnect is already under way. StreamDisconnected // StreamFailed reports a connect attempt that never came up. Err says why. // Also not fatal -- the next attempt is already scheduled, with backoff. StreamFailed )
type StreamPolicy ¶
type StreamPolicy struct {
// ConnectTimeout bounds a single connect attempt, so a host that is routed
// but dead surfaces as unreachable instead of hanging on the OS-level TCP
// timeout. Default 20s.
//
// Not the 10s the app's Dart client used, deliberately. There the platform
// stack had already resolved the name and holds the system trust store, so
// ten seconds bounded little more than the TCP and TLS handshake. This
// client does its own DNS and its own TLS, cold, on the first connect after
// the process starts -- a tighter bound here does not mean the same thing.
//
// There is a real cost to setting it too low, beyond retrying early: this
// cancellation *replaces* whatever the network stack was about to report.
// A name that does not resolve, a refused connection, an untrusted
// certificate -- all of them arrive as "context canceled" if this fires
// first, and the actual cause is lost. A bound generous enough for the
// underlying error to surface on its own is worth more than a fast one that
// hides it.
ConnectTimeout time.Duration
// IdleTimeout is how long an established stream may stay completely silent
// before it is treated as dead and reconnected. Default 60s.
//
// This is what catches a connection that dies without saying so -- a
// half-open socket, a network handover, a proxy dropping the connection
// mid-flight. Nothing else does: the connect deadline is over by then, and
// a read that will never return does not fail on its own. The symptom of
// not having it is the worst kind, "messages sometimes just don't arrive",
// with nothing in any log to attach it to.
//
// A silence timeout is only safe because the server sends a heartbeat
// comment every 25 seconds (sseHeartbeatInterval), so a healthy stream is
// never quiet for longer than that however idle the account is. The default
// is a little over twice that: long enough to survive one missed heartbeat
// and a slow link, short enough that a dead connection is noticed in about
// a minute rather than never.
//
// Set it above the server's heartbeat interval or a healthy stream will be
// torn down on a schedule.
IdleTimeout time.Duration
// InitialRetryDelay and MaxRetryDelay bound the exponential backoff used
// when an attempt never comes up, so an offline home server is probed ever
// less aggressively rather than hammered -- yet still recovers within
// MaxRetryDelay once it returns. Defaults 3s and 30s.
InitialRetryDelay time.Duration
MaxRetryDelay time.Duration
// QuickRetryDelay is used instead when the stream *had* come up and then
// dropped, with the backoff reset. A healthy link then recovers in well
// under a second rather than after a full retry delay -- which is the
// difference between a resume from background feeling instant and feeling
// broken. Default 500ms.
QuickRetryDelay time.Duration
}
StreamPolicy tunes reconnection. The zero value means the defaults, which are the ones freizone-app arrived at against real servers -- see the field comments for why each is what it is.
Source Files
¶
- blobs.go
- client.go
- content.go
- dedup.go
- diagnose.go
- drain.go
- errors.go
- groupactions.go
- groupnarration.go
- groups.go
- groupsend.go
- layout.go
- lock.go
- lock_unix.go
- media.go
- messages.go
- messages_api.go
- peers.go
- peers_resolve.go
- prekeys_api.go
- profile.go
- receive.go
- recovery.go
- register.go
- reports.go
- send.go
- serverstatus.go
- sessions.go
- settings.go
- store.go
- stream.go
- transport.go