e2eoffice

package
v0.55.0 Latest Latest
Warning

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

Go to latest
Published: Oct 9, 2026 License: MIT Imports: 8 Imported by: 0

Documentation

Overview

Package e2eoffice is the server's half of editing an end-to-end encrypted office document with ONLYOFFICE in the browser (task #189): a relay that puts the editors' sealed entries in one order without being able to read them.

The model

The editor runs in the browser. Its Document Server protocol is answered by a bridge in the editor's frame (the filex-office-editor app, AGPL), never by a Document Server, so the document, its changes, its locks and its images do not leave the browser in the clear. Whatever has to be shared with the other people editing - a batch of changes, a lock request, a released lock - the filex page seals with the session key (AES-256-GCM, packages/core lib/e2eoffice.ts) and hands to this relay. The relay:

  • gives every entry its place: an entry is written against the head the writer has seen (Append's Base) and lands right after it or not at all (ConflictError). The writer binds the position into the entry's authenticated data, so the order the relay hands out is the order the writers sealed, and a reader notices a gap, a swap or a replay;
  • keeps the Document Server's "save lock" promise with a lease: changes are appended only by the one member holding it, and the lease goes only to a member that has seen every change already in the log (Lease). Two editors therefore never send changes built on different states - the guarantee CryptPad's bridge gives up by always answering "not locked";
  • writes the control entries everyone has to agree on itself: who joined (with the indexUser the editor needs, never reused in a session), who left, and which part of the log a save holds (Saved). Locks are not arbitrated here: their requests are sealed, every bridge applies the Document Server's rules to the same sequence and reaches the same table;
  • holds the sealed session key, the sealed base document and its sealed images, so a late joiner opens exactly the bytes the first editor did.

What the relay sees is metadata: who is in which session, when, how many entries of which kind and how big. docs/E2E-OFFICE.md lists it.

Status: prototype

Nothing routes to this package yet; no surface of the product can reach it, so there is no switch to turn it off. It keeps its state in memory, in one process: a restart forgets every session, and two filex instances behind one database would each have their own. The routes, the database store (sessions and log as tables, the lease as a compare-and-set row) and the blob store inside the encrypted folder come with the slice that wires it, behind a setting that is off by default.

Index

Constants

View Source
const BaseBlob = "base"

BaseBlob is the name of the sealed base document every member opens.

View Source
const (

	// DefaultUnsavedRetention is how long unsaved changes wait for somebody
	// to save them: 30 days.
	DefaultUnsavedRetention = 30 * 24 * time.Hour
)

Variables

View Source
var (
	ErrNoSession   = errors.New("e2eoffice: no such session")
	ErrNotMember   = errors.New("e2eoffice: not a member of this session")
	ErrReadOnly    = errors.New("e2eoffice: this member may not write")
	ErrKind        = errors.New("e2eoffice: a member may not append this kind")
	ErrNoLease     = errors.New("e2eoffice: changes need the lease")
	ErrEmpty       = errors.New("e2eoffice: an entry needs a sealed payload")
	ErrTooLarge    = errors.New("e2eoffice: too large")
	ErrLogFull     = errors.New("e2eoffice: the session log is full; save, and the next opening starts a new session")
	ErrCounter     = errors.New("e2eoffice: the counter must grow")
	ErrBuild       = errors.New("e2eoffice: the session runs another editor build")
	ErrFileChanged = errors.New("e2eoffice: the file changed outside the session")
	ErrKeySet      = errors.New("e2eoffice: the session key is already set")
	ErrBlobSet     = errors.New("e2eoffice: that blob is already stored")
	ErrBlobName    = errors.New("e2eoffice: bad blob name")
	ErrNoBlob      = errors.New("e2eoffice: no such blob")
	ErrNotReady    = errors.New("e2eoffice: the session has no key or no base yet")
	ErrSaveBehind  = errors.New("e2eoffice: a save of a later point is already recorded")
	ErrSaveAhead   = errors.New("e2eoffice: a save cannot hold entries the log does not have")
	ErrBadRequest  = errors.New("e2eoffice: bad request")
	ErrNoIdentity  = errors.New("e2eoffice: who is joining is not known")
)

Functions

This section is empty.

Types

type AppendRequest

type AppendRequest struct {
	Kind Kind
	// Base is the head the writer has seen; the entry lands at Base+1 or not
	// at all.
	Base uint64
	Ctr  uint64
	CT   []byte
}

AppendRequest is a member's sealed entry.

type ConflictError

type ConflictError struct {
	Head uint64
}

ConflictError says the entry was written against another head than the log's. The writer catches up to Head, seals again and retries.

func (*ConflictError) Error

func (e *ConflictError) Error() string

type Entry

type Entry struct {
	Seq    uint64 `json:"seq"`
	Kind   Kind   `json:"kind"`
	Client string `json:"client"`
	User   int64  `json:"user"`
	// At is the relay's clock when the entry landed, in Unix milliseconds.
	// Every bridge uses it where the Document Server would use its own clock
	// (a lock's time), so all of them derive the same state.
	At int64 `json:"at"`
	// Ctr is the writer's counter, bound into the sealed entry; the relay
	// only checks that it grows.
	Ctr uint64 `json:"ctr,omitempty"`
	// CT is the sealed payload of a member's entry.
	CT []byte `json:"ct,omitempty"`
	// Member is who joined (KindJoin).
	Member *Member `json:"member,omitempty"`
	// Through and Sig describe a save (KindSaved): the last entry the saved
	// file holds and the file's new signature.
	Through uint64 `json:"through,omitempty"`
	Sig     string `json:"sig,omitempty"`
}

Entry is one place in a session's log.

type Eph

type Eph struct {
	Client string `json:"client"`
	CT     []byte `json:"ct"`
}

Eph is a frame that is not kept: a cursor. It is sealed like an entry and goes to the other members only.

type Frame

type Frame struct {
	Entry *Entry
	Eph   *Eph
}

Frame is what a subscriber receives: an entry or an ephemeral frame.

type Hello

type Hello struct {
	Client       string
	IndexUser    int
	Head         uint64
	ChangesHead  uint64
	SavedThrough uint64
	Key          []byte
}

Hello is what a new member needs to start.

type Identity

type Identity struct {
	User    int64
	Name    string
	CanEdit bool
}

Identity is who a joining member is, as the server knows it (Options.Who).

type Kind

type Kind string

Kind names what an entry is. The first three are written by members and carry a sealed payload the relay cannot read; the last three are the relay's own and carry plain metadata.

const (
	// KindChanges is one chunk of an editor's changes (its saveChanges).
	KindChanges Kind = "changes"
	// KindLock is an editor's request for locks (its getLock).
	KindLock Kind = "lock"
	// KindRelease releases an editor's locks (its unLockDocument).
	KindRelease Kind = "release"
	// KindJoin records a member joining, with the indexUser it was given.
	KindJoin Kind = "join"
	// KindLeave records a member leaving.
	KindLeave Kind = "leave"
	// KindSaved records a save: the file now holds the log up to Through.
	KindSaved Kind = "saved"
)

type LeaseOp

type LeaseOp string

LeaseOp is what a member does with the changes lease.

const (
	LeaseAcquire LeaseOp = "acquire"
	LeaseRelease LeaseOp = "release"
)

type Member

type Member struct {
	Client string `json:"client"`
	User   int64  `json:"user"`
	Name   string `json:"name"`
	// IndexUser is the editor's per-session user index. The editor builds its
	// user id from it and its object ids from the user id, so two members
	// must never share one, not even one that left: it only grows.
	IndexUser int  `json:"index_user"`
	CanEdit   bool `json:"can_edit"`
}

Member is one connection in a session.

type OpenRequest

type OpenRequest struct {
	// Doc is the caller's key for the file (tenant, storage and path). The
	// relay only compares it.
	Doc string
	// Build is the editor build the opener runs. Members of one session run
	// one build: another build may write changes this one cannot read.
	Build string
	// FileSig is the file as the opener read it (size:mtime). A session goes
	// on from the file it started from or from its last save; a file that
	// changed outside it is not the document its log applies to.
	FileSig string
}

OpenRequest asks for the session of one file.

type OpenResult

type OpenResult struct {
	SID string
	// Created: this opener started the session and must set its key and
	// store its base before anybody can join.
	Created bool
	// Key is the sealed session key, nil until the creator set it.
	Key []byte
}

OpenResult is the session to join.

type Options

type Options struct {
	// LeaseTTL is how long the changes lease lasts without a renewal (the
	// Document Server's save lock expires after 60 s).
	LeaseTTL time.Duration
	// MaxEntryBytes caps one sealed payload. The editor cuts its changes at
	// 1.5 MB per message (websocketMaxPayloadSize); the rest is sealing.
	MaxEntryBytes int
	// MaxLogBytes caps a session's log. Past it a member has to save and the
	// next opening starts a new session.
	MaxLogBytes int64
	// MaxBlobBytes caps a session's blobs (the base document and its images).
	MaxBlobBytes int64
	// SubscriberBuffer is how many frames a subscriber may fall behind before
	// it is cut off (and has to subscribe again from the last entry it has).
	SubscriberBuffer int
	// SavedIdle is how long an empty session whose log is all saved is kept.
	SavedIdle time.Duration
	// UnsavedRetention is how long an empty session with unsaved changes is
	// kept for somebody to open and save it.
	UnsavedRetention time.Duration
	// NotReadyTTL is how long a session may wait for its key and its base.
	NotReadyTTL time.Duration
	// Now is the clock (tests).
	Now func() time.Time
	// Who answers, for a caller joining the session of doc, who they are and
	// whether they may write - from the server's own records: the account
	// signed in on ctx and filex's rules for the file (an editor role and
	// files.modify), never from anything the caller sent. nil lets nobody
	// join (ErrNoIdentity); an error from it refuses the join.
	Who func(ctx context.Context, doc string) (Identity, error)
}

Options tune a Relay. The zero value of each field takes its default.

type Relay

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

Relay holds the sessions.

func New

func New(opts Options) *Relay

New builds an empty relay.

func (*Relay) Append

func (r *Relay) Append(sid, client string, req AppendRequest) (uint64, error)

Append puts a member's entry in the log and returns its seq.

func (*Relay) Blob

func (r *Relay) Blob(sid, name string) ([]byte, error)

Blob returns a stored sealed blob.

func (*Relay) Join

func (r *Relay) Join(ctx context.Context, sid string) (Hello, error)

Join adds the caller ctx carries as a member and records it in the log.

func (*Relay) Lease

func (r *Relay) Lease(sid, client string, op LeaseOp, changesSeen uint64) (bool, error)

Lease acquires (or renews) the changes lease, or releases it. It is given only to a writer that has seen every change in the log (changesSeen is the seq of the last changes entry it applied) while nobody else holds it: the Document Server's isSaveLock, which refuses an editor that is behind and one whose turn it is not.

func (*Relay) Leave

func (r *Relay) Leave(sid, client string) error

Leave removes a member: its lease goes, its subscription closes, and the log records it (every bridge then drops its locks).

func (*Relay) Open

func (r *Relay) Open(req OpenRequest) (OpenResult, error)

Open finds the file's session or starts one. An empty session whose log is all saved does not hold anybody back: a new build or a changed file simply replaces it.

func (*Relay) PutBlob

func (r *Relay) PutBlob(sid, name string, sealed []byte) error

PutBlob stores a sealed blob once: the base document, or an image.

func (*Relay) Saved

func (r *Relay) Saved(sid, client string, through uint64, sig string) (uint64, error)

Saved records that a member saved the file with the log up to `through` in it, and the file's new signature. Saves only move forward.

func (*Relay) SendEph

func (r *Relay) SendEph(sid, client string, sealed []byte) error

SendEph passes a sealed cursor frame to the other members. It is not kept.

func (*Relay) SetKey

func (r *Relay) SetKey(sid string, sealed []byte) error

SetKey stores the sealed session key, once.

func (*Relay) State

func (r *Relay) State(sid string) (State, error)

State reports on one session.

func (*Relay) StateOf

func (r *Relay) StateOf(doc string) (State, bool)

StateOf reports on a file's session, if it has one.

func (*Relay) Subscribe

func (r *Relay) Subscribe(sid, client string, from uint64) ([]Entry, <-chan Frame, error)

Subscribe returns the entries after `from` and a channel for what comes next, with nothing lost or repeated between the two. A subscriber that falls SubscriberBuffer frames behind is cut off (the channel closes; the member stays) and subscribes again from the last entry it has: an entry is never skipped. Cursor frames are the exception: a full buffer drops them.

func (*Relay) Sweep

func (r *Relay) Sweep() []string

Sweep forgets the sessions nobody is in any more: a saved one after SavedIdle, one with unsaved changes after UnsavedRetention (30 days), one that never got its key or base after NotReadyTTL. It returns their ids, for the caller to delete what it keeps beside them.

type State

type State struct {
	SID          string
	Doc          string
	Head         uint64
	ChangesHead  uint64
	SavedThrough uint64
	Members      int
	// Dirty: the log holds changes no save has (the explorer can say "unsaved
	// changes from a co-editing session").
	Dirty      bool
	LastActive time.Time
}

State is what the server may say about a session without reading it.

Jump to

Keyboard shortcuts

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