group

package
v0.13.1 Latest Latest
Warning

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

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

Documentation

Overview

Package group implements Freizone groups (SRV-01): a group's self-certifying identity, the signed events that describe who belongs to it and who may do what, and the order-independent fold from those events to the current membership.

A group is deliberately not a server object. It has no home server, no row anywhere, and no authority outside its own key hierarchy: a group root key signs the genesis record and admin grants, admins grant moderator, and moderators add and remove members -- each act carrying the certificate chain that authorizes it. State is a grow-only set of such facts, so members converge on the same membership regardless of the order events reach them and with no sequencer anywhere.

The signing byte layouts here are a cross-repo wire-format contract shared with the mobile client -- see docs/PROTOCOL.md and docs/design/01-groups.md.

Index

Constants

View Source
const (
	MaxNameLen  = 128
	MaxTopicLen = 512
)

MaxNameLen and MaxTopicLen bound the two free-text fields. A group's state is gossiped in full to every member on any state_hash mismatch, so an unbounded string here is an amplification vector against every other member's storage, not just the sender's.

View Source
const NonceSize = 16

NonceSize is the length of a group's nonce: the per-group salt that makes the group root key derivable from the founder's account root key. It is carried in the genesis event, i.e. it is public group state -- its job is domain separation between several groups founded by the same account, not secrecy.

Variables

This section is empty.

Functions

func DeriveID

func DeriveID(rootPubKey ed25519.PublicKey) (string, error)

DeriveID computes a group's self-certifying id from its root public key -- the account-address derivation with the group version marker, so the two share every line of encoding and checksum logic while remaining impossible to confuse.

func DeriveRootKey

func DeriveRootKey(accountRootSeed, nonce []byte) (ed25519.PrivateKey, error)

DeriveRootKey derives a group's root key from the founder's account root key and the group nonce.

Deriving rather than generating is what makes a group survive total device loss on the founder's side: the nonce lives in the genesis event, so a founder who restores the account root key from the recovery seed phrase and receives the group's state from any member can re-derive this exact key -- with no group-specific backup material to have lost in the first place.

accountRootSeed is the Ed25519 seed (ed25519.PrivateKey.Seed()), not the expanded private key, so the derivation depends on the account's actual secret rather than a representation of it.

func NewNonce

func NewNonce() ([]byte, error)

NewNonce generates a group nonce.

func SignDevice

func SignDevice(e *Event, signer *Signer, devicePriv ed25519.PrivateKey) error

SignDevice signs an event with a member's device key, attaching the signer block that lets any recipient chain it back to an account id.

func SignRoot

func SignRoot(e *Event, groupRootPriv ed25519.PrivateKey) error

SignRoot signs an event with the group root key -- the founder acting.

func VerifyID

func VerifyID(id string, rootPubKey ed25519.PublicKey) (bool, error)

VerifyID reports whether id is the correct, self-certifying group id for rootPubKey. This is what makes a group's identity independent of any server: anyone holding the genesis event can recompute it.

Types

type ApplyResult

type ApplyResult struct {
	Applied  []string    `json:"applied"`
	Known    []string    `json:"known"`
	Rejected []Rejection `json:"rejected"`
}

ApplyResult reports what a batch did. Ids already known are neither applied nor rejected: re-delivering a fact is normal, not an error.

type Event

type Event struct {
	Type     EventType `json:"type"`
	GroupID  string    `json:"group_id"`
	IssuedAt time.Time `json:"issued_at"`

	// RootPubKey and Nonce appear on the genesis event only. The public key is
	// what makes the group id checkable; the nonce is what makes the private
	// key re-derivable from the founder's recovery seed.
	RootPubKey ed25519.PublicKey `json:"root_pub_key,omitempty"`
	Nonce      []byte            `json:"nonce,omitempty"`

	// Subject is the account this event is about: the founder on genesis, the
	// granted/removed/joining account elsewhere.
	Subject string `json:"subject,omitempty"`

	// Server is the subject's home server -- the address half every other
	// member needs in order to deliver to them. On member_add, and on genesis
	// for the founder, who has no member_add of their own and would otherwise
	// be the one member nobody could address until they spoke first.
	Server string `json:"server,omitempty"`

	// Role is the role being granted or revoked.
	Role Role `json:"role,omitempty"`

	// Name and Topic are set together, as one last-writer-wins record: two
	// independently merged fields would produce a conflict case that buys
	// nothing.
	Name  string `json:"name,omitempty"`
	Topic string `json:"topic,omitempty"`

	Signer    *Signer `json:"signer,omitempty"`
	Signature []byte  `json:"signature"`
}

Event is one signed statement about a group.

The fields are a union across event types: each type signs exactly the ones it uses, and Validate rejects an event that carries any other field set. Without that rule a field outside a type's signing bytes would be attacker- controlled data riding inside a signed object.

func (*Event) ID

func (e *Event) ID() (string, error)

ID is the event's identity: the hash over exactly what was signed plus the signature. Two members therefore compute the same id for the same event without having to agree on a JSON encoding, and no field outside the signing bytes can influence it.

func (*Event) Validate

func (e *Event) Validate() error

Validate checks an event's shape: a known type, a plausible group id, the fields that type requires, and -- just as important -- that it carries no field its type does not sign.

func (*Event) Verify

func (e *Event) Verify(groupRootPubKey ed25519.PublicKey) error

Verify checks the event's shape, its signer chain, and its signature.

It deliberately says nothing about authority: whether the signer was allowed to do this depends on every other fact in the group and on when they arrive, so it is decided by the fold in Resolve, not here. Admission has to be context-free, or an event that merely overtook the grant authorizing it would be rejected forever.

type EventType

type EventType string

EventType names one kind of statement about a group.

const (
	// EventGenesis creates the group and names its founder. Signed by the
	// group root key, and the only event carrying that key, so every other
	// signature in the group is checkable once this one is held.
	EventGenesis EventType = "genesis"
	// EventRoleGrant raises an account to moderator or admin.
	EventRoleGrant EventType = "role_grant"
	// EventRoleRevoke takes that role away again.
	EventRoleRevoke EventType = "role_revoke"
	// EventMemberAdd invites an account. It is a proposal until the invitee
	// accepts -- being added discloses your address to every member, so that
	// disclosure is not someone else's decision to make.
	EventMemberAdd EventType = "member_add"
	// EventMemberRemove removes an account from the group.
	EventMemberRemove EventType = "member_remove"
	// EventJoinAccept is an invitee accepting their own invitation.
	EventJoinAccept EventType = "join_accept"
	// EventLeave is a member removing themselves.
	EventLeave EventType = "leave"
	// EventMeta sets the group's name and topic as one record.
	EventMeta EventType = "meta"
	// EventDissolve ends the group. Signed by the group root key: the founder
	// cannot leave a group, only dissolve it, since leaving would leave an
	// authority behind that is not in the member list.
	EventDissolve EventType = "dissolve"
)

type Member

type Member struct {
	AccountID string    `json:"account_id"`
	Server    string    `json:"server"`
	Role      Role      `json:"-"`
	RoleName  string    `json:"role"`
	AddedAt   time.Time `json:"added_at"`

	// Joined is false for an invitee who has not accepted yet. They are shown,
	// so a moderator can see the invitation is outstanding, but nothing is
	// sent to them: being added must not disclose their address to the group
	// before they agree to it.
	Joined bool `json:"joined"`
}

Member is one account's standing in a group.

type Rejection

type Rejection struct {
	// Index is the event's position in the submitted batch. It is the only
	// handle on an event whose id could not even be computed.
	Index  int    `json:"index"`
	ID     string `json:"id,omitempty"`
	Reason string `json:"reason"`
}

Rejection explains why one event in a batch was not admitted.

type Resolved

type Resolved struct {
	GroupID     string    `json:"group_id"`
	Founder     string    `json:"founder"`
	CreatedAt   time.Time `json:"created_at"`
	Name        string    `json:"name"`
	Topic       string    `json:"topic"`
	Members     []Member  `json:"members"`
	Dissolved   bool      `json:"dissolved"`
	DissolvedAt time.Time `json:"dissolved_at,omitempty"`
	StateHash   string    `json:"state_hash"`
}

Resolved is the current membership: the fold over the fact set.

func (*Resolved) RoleOf

func (r *Resolved) RoleOf(accountID string) Role

RoleOf returns an account's role, RoleNone if it is not a member.

type Role

type Role uint8

Role is a member's authority within a group.

The constants are ordered so that a plain > comparison IS the authority rule: an account may only act against strictly lower ranks. That collapses the whole permission table into two checks -- granting or revoking role R requires a rank above R (so only the founder touches admin, only an admin touches moderator), and removing a member requires at least moderator plus a rank above the target's.

Only RoleModerator and RoleAdmin are ever granted by an event. RoleMember is the consequence of being added, and RoleFounder is key possession -- neither is assignable, and neither may appear in a grant.

const (
	// RoleNone is not a member of this group.
	RoleNone Role = 0
	// RoleMember may read and write, nothing more.
	RoleMember Role = 1
	// RoleModerator may invite, remove lower ranks, and set name/topic.
	RoleModerator Role = 2
	// RoleAdmin may additionally grant and revoke moderator.
	RoleAdmin Role = 3
	// RoleFounder holds the group root key: the only rank that may grant and
	// revoke admin, and the only one that cannot be removed or leave.
	RoleFounder Role = 4
)

func (Role) String

func (r Role) String() string

String renders a role for diagnostics and for the client-facing JSON view.

type Signer

type Signer struct {
	AccountID  string                       `json:"account_id"`
	RootPubKey ed25519.PublicKey            `json:"root_pub_key"`
	DeviceCert devicecert.DeviceCertificate `json:"device_cert"`
}

Signer identifies the device that signed an event, and carries everything needed to check that it was entitled to -- the same self-describing identity block federated message delivery already uses (docs/PROTOCOL.md section 9). Nil on a root-signed event, where the group root key in the genesis record is the whole story.

type State

type State struct {
	// contains filtered or unexported fields
}

State is everything a member knows about a group: a grow-only set of signed events, keyed by event id.

Grow-only is the whole trick. Union is idempotent and commutative, so two members who hold the same facts fold to the same membership no matter what order those facts arrived in, and reconciling two views is just "send me yours" -- no delta protocol, no version vectors, and no sequencer anywhere.

func NewState

func NewState() *State

NewState creates an empty state. It has no group identity until a genesis event is applied.

func (*State) Apply

func (s *State) Apply(events []*Event) ApplyResult

Apply admits a batch of events into the fact set.

Admission is context-free on purpose: shape, signer chain and signature, nothing more. Whether the signer was *allowed* to do this depends on every other fact in the group and on when those facts arrive, so authority is decided by Resolve. An event that merely overtook the grant authorizing it must not be thrown away -- it would never come back.

func (*State) Events

func (s *State) Events() []*Event

Events returns the fact set, ordered by event id -- the canonical order used for hashing and for handing a full snapshot to a peer.

func (*State) Genesis

func (s *State) Genesis() *Event

Genesis returns the group's genesis event, or nil before it has arrived.

Callers need it for the group nonce: a founder re-deriving the group root key after restoring from their recovery seed reads it from here, having received the state from any member.

func (*State) GroupID

func (s *State) GroupID() string

GroupID is the group this state describes, or "" before genesis.

func (*State) MarshalJSON

func (s *State) MarshalJSON() ([]byte, error)

MarshalJSON writes the fact set in canonical order, so the same state serializes to the same bytes on every device.

func (*State) Resolve

func (s *State) Resolve() *Resolved

Resolve folds the fact set into the current membership.

Events are replayed in timestamp order (ties broken by event id), and each is checked against the state built from everything before it. That is exactly the rule "the signer must have held the required role at the moment they signed", and it makes the result a pure function of the fact set: every member holding the same facts gets the same answer, in any arrival order.

Events sharing a timestamp are *concurrent*, though, and hash order between them is arbitrary -- so within one timestamp the replay iterates to a fixpoint instead: repeat until a pass applies nothing new. Without that, a group founded and named in the same second could lose its name, because the name event's hash happened to sort ahead of the genesis it depends on. The fixpoint is still deterministic (the pending set shrinks in hash order every pass) and still a pure function of the fact set; it just refuses to let an arbitrary tie-break decide which of two same-second facts survives.

The price, accepted deliberately, is that a fact arriving late can change the past -- a revocation that turns up after the act it invalidates removes that act's effect. Deterministically, and for everyone.

func (*State) RootPubKey

func (s *State) RootPubKey() ed25519.PublicKey

RootPubKey is the group's root public key, or nil before genesis.

func (*State) StateHash

func (s *State) StateHash() string

StateHash is the fingerprint a member puts on every group message so a peer can tell, without exchanging anything, whether the two of them are missing each other's facts: SHA-256 over the event ids in canonical order.

It says "we differ", never who is behind -- which is enough, because the answer to a mismatch is for both sides to send what they have.

func (*State) UnmarshalJSON

func (s *State) UnmarshalJSON(data []byte) error

UnmarshalJSON reloads a persisted state, re-verifying every event on the way in. Storage is not a trust boundary we want to assume: a state read back from disk gets the same checks it got on arrival.

Jump to

Keyboard shortcuts

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