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
- Variables
- type AppendRequest
- type ConflictError
- type Entry
- type Eph
- type Frame
- type Hello
- type Identity
- type Kind
- type LeaseOp
- type Member
- type OpenRequest
- type OpenResult
- type Options
- type Relay
- func (r *Relay) Append(sid, client string, req AppendRequest) (uint64, error)
- func (r *Relay) Blob(sid, name string) ([]byte, error)
- func (r *Relay) Join(ctx context.Context, sid string) (Hello, error)
- func (r *Relay) Lease(sid, client string, op LeaseOp, changesSeen uint64) (bool, error)
- func (r *Relay) Leave(sid, client string) error
- func (r *Relay) Open(req OpenRequest) (OpenResult, error)
- func (r *Relay) PutBlob(sid, name string, sealed []byte) error
- func (r *Relay) Saved(sid, client string, through uint64, sig string) (uint64, error)
- func (r *Relay) SendEph(sid, client string, sealed []byte) error
- func (r *Relay) SetKey(sid string, sealed []byte) error
- func (r *Relay) State(sid string) (State, error)
- func (r *Relay) StateOf(doc string) (State, bool)
- func (r *Relay) Subscribe(sid, client string, from uint64) ([]Entry, <-chan Frame, error)
- func (r *Relay) Sweep() []string
- type State
Constants ¶
const BaseBlob = "base"
BaseBlob is the name of the sealed base document every member opens.
const ( // DefaultUnsavedRetention is how long unsaved changes wait for somebody // to save them: 30 days. DefaultUnsavedRetention = 30 * 24 * time.Hour )
Variables ¶
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 ¶
Eph is a frame that is not kept: a cursor. It is sealed like an entry and goes to the other members only.
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 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 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 (*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) Lease ¶
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 ¶
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) Saved ¶
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) Subscribe ¶
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.
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.