client

package
v0.27.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 30, 2026 License: AGPL-3.0 Imports: 36 Imported by: 0

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

View Source
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
)
View Source
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
)
View Source
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.

View Source
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
)
View Source
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.

View Source
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.

View Source
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.

View Source
const ProfileNameClearedMarker = "This contact no longer gives a name"

ProfileNameClearedMarker is the withdrawal's line.

Variables

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
var ErrReportsUnavailable = errors.New("client: this server does not accept reports")

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.

View Source
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

func IsDeliveredStatus(status string) bool

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

func IsGroupID(id string) bool

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

func IsStaleDevice(err error) bool

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

func IsStaleRecipientStatus(status string) bool

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

func IsUnreachable(err error) bool

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

func ProfileRenamedMarker(name string) string

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": ...}}`).

func (*APIError) Error

func (e *APIError) Error() string

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

func Open(path string) (*Client, error)

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 OpenWith

func OpenWith(path string, opts Options) (*Client, error)

OpenWith is Open with the settings that are not the account's location.

func (*Client) AcceptGroupInvitation

func (c *Client) AcceptGroupInvitation(ctx context.Context, groupID string) error

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

func (c *Client) AckMessage(ctx context.Context, messageID string) error

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

func (c *Client) AppendMessage(chatID string, m Message) error

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

func (c *Client) AttachmentFile(chatID, messageID string) ([]byte, error)

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

func (c *Client) AttachmentPath(chatID, messageID string) string

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

func (c *Client) AttachmentThumb(chatID, messageID string) ([]byte, error)

AttachmentThumb returns the stored preview, or nil when there is none.

func (*Client) AttachmentThumbPath

func (c *Client) AttachmentThumbPath(chatID, messageID string) string

AttachmentThumbPath is Client.AttachmentPath for the inline preview, which is present from the moment the message arrived.

func (*Client) BlockPeer

func (c *Client) BlockPeer(peer, server string) error

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

func (c *Client) ClaimBundle(ctx context.Context, peer PeerEndpoint) (Bundle, error)

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

func (c *Client) ClaimServer(ctx context.Context, server, setupToken string) (Identity, error)

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

func (c *Client) ClearDesyncEvidence(peer string) error

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

func (c *Client) ClearTranscript(chatID string) error

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

func (c *Client) Close() error

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

func (c *Client) ConsumeOneTimePrekey(id uint32) error

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

func (c *Client) CountOneTimePrekeys() (int, error)

CountOneTimePrekeys reports how many unclaimed prekeys remain, which is what decides whether the pool needs topping up.

func (*Client) CountProcessedMessages

func (c *Client) CountProcessedMessages() (int, error)

CountProcessedMessages reports how many ids are currently remembered. For tests and diagnostics; nothing in the protocol depends on it.

func (*Client) CreateGroup

func (c *Client) CreateGroup(ctx context.Context, name string) (string, error)

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

func (c *Client) DeleteBlob(ctx context.Context, blobID string) error

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

func (c *Client) DeleteChatMedia(chatID string) error

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

func (c *Client) DeleteConversation(peer string) error

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

func (c *Client) DeleteMessage(chatID, messageID string) error

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

func (c *Client) DissolveGroup(ctx context.Context, groupID string) error

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

func (c *Client) Endpoint(ctx context.Context, peer string) (PeerEndpoint, error)

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

func (c *Client) ForgetGroup(groupID string) error

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

func (c *Client) ForgetPeerDevice(peer string) error

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) GroupChat

func (c *Client) GroupChat(groupID string) (*GroupChat, error)

GroupChat returns a group's chat state, or nil when there is none.

func (*Client) GroupMembership

func (c *Client) GroupMembership(groupID string) (*group.Resolved, error)

GroupMembership folds a group's facts into its current membership, or returns nil for a group this account knows nothing about.

func (*Client) GroupPeerStateHashes

func (c *Client) GroupPeerStateHashes(groupID string) (map[string]string, error)

GroupPeerStateHashes is every member's last stated view of a group.

func (*Client) GroupState

func (c *Client) GroupState(groupID string) (*group.State, error)

GroupState returns the fact set held for a group, or nil for one this account knows nothing about.

func (*Client) Groups

func (c *Client) Groups() ([]string, error)

Groups lists every group this account holds facts about, by id.

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

func (c *Client) Identity() (Identity, error)

Identity reads the account's key material, or ErrNoIdentity if setup has not run yet.

func (*Client) InviteToGroup

func (c *Client) InviteToGroup(ctx context.Context, groupID, addressOrPrefix, server string) error

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

func (c *Client) IsPeerBlocked(peer string) (bool, error)

IsPeerBlocked reports whether peer is locally blocked.

func (*Client) IsPeerKnown

func (c *Client) IsPeerKnown(peer string) (bool, error)

IsPeerKnown reports whether peer has ever been accepted or reached out to.

func (*Client) LastMessage

func (c *Client) LastMessage(chatID string) (*Message, error)

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

func (c *Client) LeaveGroup(ctx context.Context, groupID string) error

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

func (c *Client) MarkMessageProcessed(id string) error

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

func (c *Client) MarkPeerKnown(peer string) error

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

func (c *Client) MessageExists(chatID, messageID string) (bool, error)

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) Messages

func (c *Client) Messages(chatID string) ([]Message, error)

Messages returns a chat's whole transcript in arrival order.

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) Path

func (c *Client) Path() string

Path is the account directory this Client was opened from.

func (*Client) PayGroupSnapshotDebts

func (c *Client) PayGroupSnapshotDebts(ctx context.Context) (paid int, gone []string, err error)

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

func (c *Client) PeerProfileNames() (map[string]string, error)

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

func (c *Client) PinMessage(chatID, messageID string) error

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

func (c *Client) PinnedMessageIDs(chatID string) ([]string, error)

PinnedMessageIDs lists a chat's pins, oldest-pinned first.

func (*Client) ProfileName added in v0.27.0

func (c *Client) ProfileName() (string, *time.Time, error)

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

func (c *Client) PurgeAndReplaceOneTimePrekeys(ctx context.Context) error

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

func (c *Client) PutGroupChat(chat GroupChat) error

PutGroupChat replaces a group's chat state.

func (*Client) PutGroupState

func (c *Client) PutGroupState(groupID string, st *group.State) error

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

func (c *Client) ReceiptsEnabled() (bool, error)

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

func (c *Client) RecordAutoRekey(peer string, at time.Time) error

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

func (c *Client) RecordDecryptFailure(id string) (giveUp bool, err error)

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

func (c *Client) RecordDesyncEvidence(peer string, at time.Time) (recorded bool, err error)

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

func (c *Client) RecordGroupPeerStateHash(groupID, accountID, stateHash string) error

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

func (c *Client) RecoverDesyncedSessions(ctx context.Context) ([]string, error)

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

func (c *Client) RemainingOneTimePrekeys(ctx context.Context) (int, error)

RemainingOneTimePrekeys is how many keys the server still holds for this device. Reading it consumes nothing, unlike claiming a bundle.

func (*Client) RemoveFromGroup

func (c *Client) RemoveFromGroup(ctx context.Context, groupID, accountID string) error

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

func (c *Client) RequestGroupSync(ctx context.Context, groupID string) error

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

func (c *Client) ResendPendingReceipts(ctx context.Context) (int, error)

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

func (c *Client) ResetSession(ctx context.Context, peerAccountID string, reason RekeyReason) error

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

func (c *Client) RotatePrekeys(ctx context.Context) error

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

func (c *Client) SendGroupSnapshot(ctx context.Context, groupID, toAccountID string) error

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

func (c *Client) ServerStatus(ctx context.Context, server string) (ServerStatus, error)

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

func (c *Client) Session(peer string, kind SessionKind) (*ratchet.Session, error)

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

func (c *Client) SetGroupMeta(ctx context.Context, groupID, name, topic string) error

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

func (c *Client) SetIdentity(id Identity) error

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

func (c *Client) SetMessageSendState(chatID, messageID string, state SendState) error

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

func (c *Client) SetProfileName(name string) error

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

func (c *Client) SetReceiptsEnabled(enabled bool) error

SetReceiptsEnabled records the account's answer, for every consumer of this directory and every process that opens it.

func (*Client) SetSession

func (c *Client) SetSession(peer string, kind SessionKind, s *ratchet.Session) error

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

func (c *Client) ShouldAutoRekey(peer string, now time.Time) (bool, error)

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

func (c *Client) TopUpOneTimePrekeys(ctx context.Context) error

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

func (c *Client) UnblockPeer(peer string) error

UnblockPeer lifts a local block.

func (*Client) UnpinMessage

func (c *Client) UnpinMessage(chatID, messageID string) error

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

func (c *Client) WasMessageProcessed(id string) (bool, error)

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

func (c *Client) WithdrawReport(ctx context.Context, peer string) error

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

func (c *Client) WriteAttachmentFile(chatID, messageID string, data []byte) error

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

func (c *Client) WriteAttachmentThumb(chatID, messageID string, thumb []byte) error

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

func DecodeContent(plaintext []byte) Content

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 {
	BlobID string
	Err    error
}

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

func NewIdentity(server string) (Identity, error)

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

func (i Identity) Address() address.Address

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

type NotFreizoneServerError struct {
	StatusCode int
	Host       string
}

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

type OneTimePrekey struct {
	KeyID uint32
	Pub   []byte
	Priv  []byte
}

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL