e2ee

package
v0.0.0-...-646c5a8 Latest Latest
Warning

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

Go to latest
Published: Aug 23, 2026 License: GPL-3.0 Imports: 23 Imported by: 0

Documentation

Overview

Package e2ee defines the contract a candidate end-to-end encryption profile must satisfy, and the bindings that tie one ciphertext to one conversation.

It carries a concrete default candidate, but that candidate remains a protocol-freeze decision: its presence is implementation evidence, not owner ratification. The package invents no cipher, MAC, signature, or ratchet. It fixes what the candidate provides, what a ciphertext is bound to, what published material commits, how a candidate is refuted, and how session state survives a crash.

The last of those is why the interface is a pure state transition rather than a mutable session object. A session that advances in memory has two truths, the one in memory and the one on disk, and every crash decides which survives. Returning the next state alongside the result makes the caller commit both together, and lets this repository state the commit order instead of discovering it from whichever library is chosen.

The conformance harness can only refute. A suite that fails a check definitively lacks the property; a suite that passes every check has cleared a floor, not earned an approval. Selection still requires cryptographic review.

Index

Constants

View Source
const (
	// FirstContactSchema identifies the public, transportable evidence needed
	// by a recipient device to accept an asynchronous first-contact session.
	FirstContactSchema = "tos.messaging.e2ee.first-contact.v1"
	// MaxInitialMessageBytes bounds suite-specific asynchronous handshake data.
	MaxInitialMessageBytes = 4 << 10
)
View Source
const (
	// BundleSchema is the strict wire schema identifier.
	BundleSchema = "tos.messaging.prekey-bundle.v2"

	// MaxMaterialBytes bounds one published prekey bundle's material.
	MaxMaterialBytes = 4 << 10
	// MaxBundleLifetimeSeconds bounds how long published material stays valid.
	// Published prekeys are consumed by senders the owner never hears from, so
	// material that never expires is material that can never be retired.
	MaxBundleLifetimeSeconds = 30 * 24 * 60 * 60
	// MaxDevicesPerSet bounds the devices one endpoint publishes at once.
	MaxDevicesPerSet = 16
)
View Source
const (
	// BundleSetSchema identifies the bounded JSON publication wrapper for all
	// device bundles committed by one descriptor. The wrapper is not itself
	// signed; SetCanonicalBytes is the descriptor-committed representation.
	BundleSetSchema = "tos.messaging.prekey-bundle-set.v2"

	// MaxBundleSetWireBytes bounds an object fetched before JSON decoding. It
	// leaves room for MaxDevicesPerSet bundles at their material bound without
	// permitting an unbounded discovery response.
	MaxBundleSetWireBytes = 128 << 10
)
View Source
const CommitOrder = "inbound: event then state; outbound: state then ciphertext"

CommitOrder documents the order in which a caller must make things durable.

The two directions commit in opposite orders, and each order is decided by what a crash between the two writes would cost.

Inbound:  the event record first, then the session state.
  A crash between them leaves the event durable and the state behind. The
  same ciphertext opens again to the same next state, so nothing is lost.
  The other order consumes the message key and then loses the event, and
  no retry can recover it: the peer's copy no longer opens.

Outbound: the session state first, then the ciphertext.
  A crash between them advances the state and loses the ciphertext, so the
  message is sealed again from the new state under a fresh key. The other
  order leaves a released ciphertext with the state rolled back, and the
  next seal reuses a message key and nonce, which is the one failure a
  ratchet cannot absorb.

The plaintext is queued before it is sealed, so re-sealing after a crash has something to seal.

View Source
const DefaultCandidateAlgorithmID = "tos.messaging.e2ee.x3dh-aes256gcm-dr.v2"

DefaultCandidateAlgorithmID names the concrete suite proposed for the M0 freeze. Its presence does not freeze the value: the owner still has to ratify the decision package before this identifier becomes a wire promise.

View Source
const MaxCiphertextOverheadBytes = 512

MaxCiphertextOverheadBytes bounds how much a suite may add to a plaintext. Every message pays this on every hop and every Relay stores it, so an unbounded expansion is a protocol cost, not an implementation detail.

View Source
const MaxSessionStateBytes = 256 << 10

MaxSessionStateBytes bounds one persisted session state. State is committed on the critical path of every message, so a suite whose state grows without limit would make every send slower than the last.

Variables

View Source
var (
	// ErrSetRollback reports an observed publication older than the accepted
	// freshness watermark.
	ErrSetRollback = errors.New("prekey device set rolled back")
	// ErrSetEquivocation reports different non-retirement content at the same
	// freshness watermark. The two digests are intentionally not tie-broken:
	// arrival order is not endpoint authority.
	ErrSetEquivocation = errors.New("prekey device set equivocated")
)
View Source
var (
	// ErrReplayed reports a ciphertext this state has already opened.
	ErrReplayed = errors.New("message was already opened")
	// ErrNotAuthentic reports a ciphertext that failed authentication, whether
	// because it was altered or because its binding does not match.
	ErrNotAuthentic = errors.New("message is not authentic for this binding")
	// ErrSessionExpired reports a session past its permitted lifetime.
	ErrSessionExpired = errors.New("session has expired")
	// ErrStateUnusable reports state a suite cannot interpret, including state
	// written by a version it does not understand.
	ErrStateUnusable = errors.New("session state is unusable")
)

Errors a suite must return rather than inventing its own semantics. A caller distinguishes a replayed message from a corrupt one, and a rejected binding from either.

View Source
var AlgorithmPattern = regexp.MustCompile(`^tos\.messaging\.e2ee\.[a-z0-9-]{1,32}\.v[0-9]{1,3}$`)

AlgorithmPattern matches a frozen suite identifier. A suite that cannot name itself cannot be negotiated, deprecated, or upgraded.

Functions

func BindBundle

func BindBundle(delegation identity.Delegation, bundle Bundle, now time.Time) error

BindBundle admits published material only under the delegation that authorized the key which signed it.

A sender uses this material to start a session with someone who is offline and cannot object, so the check runs before the material is used, not after the first reply.

func BindBundleSet

func BindBundleSet(delegation identity.Delegation, bundles []Bundle, committed string, now time.Time) error

BindBundleSet admits a published device set under one delegation.

It does in one call what a caller would otherwise have to remember to do for each device: the set is coherent, every bundle is signed by the delegated key, none outlives the delegation, and the whole set reproduces the digest the descriptor committed.

func BundleDigest

func BundleDigest(bundle Bundle) (string, error)

BundleDigest identifies one published bundle.

func BundleSigningBytes

func BundleSigningBytes(bundle Bundle) ([]byte, error)

BundleSigningBytes returns the exact preimage the endpoint key signs.

func DeviceSessionID

func DeviceSessionID(deviceA, deviceB string) (string, error)

DeviceSessionID derives the session identifier for one device pair.

It is deterministic and symmetric: both ends compute the same identifier without negotiating, because a session-identity handshake would be one more exchange that can disagree. One pair, one session, across conversations -- the ratchet inside it is what provides freshness, not session churn.

func EncodeBundleJSON

func EncodeBundleJSON(bundle Bundle) ([]byte, error)

EncodeBundleJSON returns the publishable bundle.

func EncodeBundleSetJSON

func EncodeBundleSetJSON(bundles []Bundle) ([]byte, error)

EncodeBundleSetJSON returns the complete publishable device set. The input order is preserved on the wire; identity remains order independent because the descriptor commits SetCanonicalBytes, which sorts bundle digests.

func EncodeFirstContactJSON

func EncodeFirstContactJSON(value FirstContact) ([]byte, error)

EncodeFirstContactJSON returns the strict public bootstrap wire value.

func MatchesDescriptorDigest

func MatchesDescriptorDigest(committed string, bundles []Bundle) error

MatchesDescriptorDigest checks a published device set against the prekey bundle digest a Messaging Contact Descriptor committed.

A sender resolves the descriptor from finalized identity and then fetches material from wherever it is served. This is what stops the two from disagreeing: material that does not reproduce the committed digest is not the material the endpoint published, whatever the server that returned it claims.

func SetCanonicalBytes

func SetCanonicalBytes(bundles []Bundle) ([]byte, error)

SetCanonicalBytes returns the exact preimage committed by a descriptor's prekey bundle digest. It is exported so another implementation can consume the same positive vector without treating the JSON publication wrapper as the committed representation.

func SetDigest

func SetDigest(bundles []Bundle) (string, error)

SetDigest is the value a Messaging Contact Descriptor commits as its prekey bundle digest.

It covers every device's bundle, so a descriptor cannot be paired with a device set the endpoint never published, and adding or removing a device changes the descriptor rather than happening silently underneath it. The order devices are listed in does not change the result.

func ValidateAlgorithmID

func ValidateAlgorithmID(algorithm string) error

ValidateAlgorithmID reports whether a suite identifier is well formed.

func ValidateBundle

func ValidateBundle(bundle Bundle, signed bool) error

ValidateBundle enforces every structural rule.

func ValidateFirstContact

func ValidateFirstContact(value FirstContact) error

ValidateFirstContact binds the independently signed sender prekey to the asserted sender and the exact directional session context.

func ValidateSet

func ValidateSet(bundles []Bundle) error

ValidateSet enforces that a published set is one endpoint's devices and nothing else.

A protocol core cannot rely on every caller remembering to check each bundle afterwards. A set that mixes Agents, networks, or suites would produce a digest a descriptor could commit, and the mixing would only be noticed by whoever happened to verify the bundles individually.

func ValidateState

func ValidateState(state State) error

ValidateState enforces the bounds every persisted state must respect.

Types

type Binding

type Binding struct {
	Network             *nativev1.NetworkDomain
	AlgorithmID         string
	ConversationID      string
	SenderAgentID       string
	SenderEndpointID    string
	SenderDeviceID      string
	RecipientAgentID    string
	RecipientEndpointID string
	RecipientDeviceID   string
}

Binding names exactly where one ciphertext belongs.

It is passed to Seal and Open as associated data, so a ciphertext that is lifted out of its conversation, replayed in the other direction, or presented under a different suite fails to open rather than decrypting into the wrong context. None of this is confidentiality: it is the reason a message means what its position says it means.

func (Binding) Bytes

func (b Binding) Bytes() ([]byte, error)

Bytes returns the canonical associated data.

func (Binding) Reply

func (b Binding) Reply() Binding

Reply returns the binding for a message travelling the other way. Direction is part of the binding, so the two are never interchangeable.

func (Binding) Validate

func (b Binding) Validate() error

Validate enforces every structural rule.

type Bundle

type Bundle struct {
	Network           *nativev1.NetworkDomain
	AgentID           string
	EndpointID        string
	DeviceID          string
	AlgorithmID       string
	Material          []byte
	IssuedAtUnix      uint64
	ExpiresAtUnix     uint64
	EndpointSignature []byte
}

Bundle is one device's published prekey material.

It is signed by the delegated Messaging Endpoint key, never by the Agent controller key, and it means nothing until the delegation behind that key is resolved from finalized TOS state.

func DecodeBundleJSON

func DecodeBundleJSON(raw []byte) (Bundle, error)

DecodeBundleJSON rejects unknown fields, trailing data, and malformed bundles.

func DecodeBundleSetJSON

func DecodeBundleSetJSON(raw []byte) ([]Bundle, error)

DecodeBundleSetJSON strictly decodes a bounded publication wrapper and enforces whole-set coherence. Individual bundles retain their own strict schema and signature-shape validation.

func SignBundle

func SignBundle(bundle Bundle, endpointKey ed25519.PrivateKey) (Bundle, error)

SignBundle signs published material with the delegated endpoint key.

func SignBundleWith

func SignBundleWith(bundle Bundle, signer crypto.Signer) (Bundle, error)

SignBundleWith signs through a narrow crypto.Signer boundary. An endpoint may keep its online identity key in an HSM or another isolated process; the prekey publisher needs signatures, not possession of the key bytes.

The returned signature is verified before it leaves this function. A remote signer that selected the wrong key or returned malformed output therefore cannot create a publication that only fails after the descriptor changes.

type FirstContact

type FirstContact struct {
	Binding               Binding
	SenderBundle          Bundle
	RecipientBundleDigest string
	Initial               []byte
}

FirstContact contains no secret material. The recipient independently verifies SenderBundle under finalized Endpoint authority and selects the exact local private prekey named by RecipientBundleDigest before accepting the session. Binding is the authority-bearing context used as AEAD AAD.

func DecodeFirstContactJSON

func DecodeFirstContactJSON(raw []byte) (FirstContact, error)

DecodeFirstContactJSON rejects ambiguous encodings and revalidates every digest and identity relationship before returning bootstrap evidence.

type PlanInput

type PlanInput struct {
	// SenderDeviceID is this device.
	SenderDeviceID string
	// SenderSet is the sender's own current set, for self-fan-out.
	SenderSet []Bundle
	// RecipientSet is the recipient's current set.
	RecipientSet []Bundle
	// Now bounds bundle expiry for bootstraps.
	Now time.Time
	// SessionExists reports whether a session identifier is established.
	SessionExists func(sessionID string) bool
}

Plan computes the per-device fan-out for one logical event.

One event, one identifier, many sealed copies: every live device of the recipient gets one, and every other device of the sender gets one, so the sender's own devices agree about what was said. The event's identity is its content, so the copies are the same event, not siblings.

sessionExists answers whether a session is already established; expired bundles are skipped for bootstrap but do not close established sessions, because a bundle only ever bootstraps -- the ratchet is the ongoing key. A recipient device whose bundle has expired and with whom no session exists is simply unreachable until its owner rotates, and the plan says so rather than guessing.

type PlanResult

type PlanResult struct {
	Recipients []Target
	// SelfCopies are the sender's other devices.
	SelfCopies []Target
	// Unreachable are recipient devices with no session and no live bundle.
	Unreachable []string
}

Plan output: recipient targets, then self targets, both sorted by device.

func FanOut

func FanOut(input PlanInput) (PlanResult, error)

FanOut plans the sealed copies for one event.

type SetEquivocationError

type SetEquivocationError struct {
	CurrentDigest   string
	CandidateDigest string
	IssuedAtUnix    uint64
}

SetEquivocationError identifies the two conflicting commitments. The caller already holds the signed candidate set and can export this summary alongside it; errors.Is(err, ErrSetEquivocation) classifies the refusal.

func (*SetEquivocationError) Error

func (e *SetEquivocationError) Error() string

func (*SetEquivocationError) Unwrap

func (e *SetEquivocationError) Unwrap() error

Unwrap supports errors.Is without discarding the conflicting digests.

type SetSummary

type SetSummary struct {
	// Digest is the set's identity, as committed in the descriptor.
	Digest string
	// EndpointID is whose devices these are.
	EndpointID string
	// DeviceIDs is the sorted device list.
	DeviceIDs []string
	// BundleDigests is the sorted per-bundle digest list. It is what lets a
	// pure retirement be recognised: a successor whose bundles are a subset of
	// the current ones removed devices and changed nothing else.
	BundleDigests []string
	// NewestIssuedAtUnix is the freshest issuance in the set. A successor that
	// is not a pure retirement must be strictly fresher, which is what stops
	// an old set being replayed as a new one.
	NewestIssuedAtUnix uint64
}

SetSummary is what a receiver remembers about a peer's device set.

It is the anchor for two protections a single set cannot provide. Rollback: a peer's directory entry can be replayed by whoever can reach the DHT, so the receiver keeps the newest set it has accepted and refuses regressions. Revocation: a device removed from the set must stay removed, so the summary is judged against tombstones the receiver also keeps.

func Summarize

func Summarize(bundles []Bundle) (SetSummary, error)

Summarize reduces a valid set to what succession is judged on.

type State

type State []byte

State is one session's complete persisted state.

It is opaque, and it is everything the suite needs to continue: keys, ratchet position, skipped-message keys, and replay bookkeeping. A state that omitted replay bookkeeping would let a restart re-accept a message the session had already opened.

type Succession

type Succession struct {
	// Accepted is the summary to record.
	Accepted SetSummary
	// Removed are the devices this succession revoked. Their sessions are to
	// be closed, and their identifiers never return.
	Removed []string
}

Succession is the outcome of judging a successor set.

func Succeed

func Succeed(current SetSummary, tombstones map[string]struct{}, next []Bundle) (Succession, error)

Succeed judges whether a set may replace the one on record.

The rules are few and each has one reason:

  • a device on the tombstone list never returns. Removal is revocation, and revocation with an undo is a suggestion. A device that legitimately comes back generates a fresh key, which is a fresh identifier;
  • a pure retirement -- every bundle already in the current set, some devices gone -- is accepted at the same freshness, because removing a device should not require re-issuing everyone else's material;
  • anything else must be strictly fresher than what is on record. Equal freshness with different content is two sets claiming the same moment, and the receiver has no way to order them except by whoever spoke last, which is exactly the authority a replayed directory entry would have.

current may be the zero SetSummary for a peer never seen before; tombstones still apply, because a first sight that includes an already-revoked device is a first sight of a forgery.

type Suite

type Suite interface {
	// AlgorithmID returns the frozen suite identifier. It must be stable for
	// the lifetime of the value.
	AlgorithmID() string

	// NewPrekeyMaterial produces the public material an endpoint publishes and
	// the private state that answers it.
	NewPrekeyMaterial() (public []byte, private []byte, err error)

	// Initiate starts a session using this device's private prekey material and
	// the peer's published material. Requiring both is the possession proof
	// behind the identities in the binding; published material alone would let
	// anyone initiate while claiming to be any delegated endpoint.
	Initiate(private []byte, peerPublic []byte, binding []byte) (State, []byte, error)

	// Accept completes a session from an initial message and the initiator's
	// independently fetched, endpoint-signed published material.
	Accept(private []byte, peerPublic []byte, initial []byte, binding []byte) (State, error)

	// Seal encrypts under a binding and returns the state that must be durable
	// before the ciphertext is released.
	Seal(state State, plaintext []byte, binding []byte) (ciphertext []byte, next State, err error)

	// Open decrypts under a binding and returns the state that must be durable
	// before the plaintext is acted on.
	Open(state State, ciphertext []byte, binding []byte) (plaintext []byte, next State, err error)

	// KeyMaterial returns the part of a state an attacker obtains when they
	// take the device: keys, without replay bookkeeping.
	//
	// It exists so a compromise check cannot be satisfied by retaining a list
	// of what was already seen. Whether an implementation kept such a list has
	// no bearing on whether past traffic is readable, and a suite whose
	// persisted state was accepted as the attacker's view could appear to
	// protect a backlog it does not protect.
	//
	// Production code has no reason to call this.
	KeyMaterial(state State) (State, error)
}

Suite is a candidate cryptographic profile.

Establishment is asynchronous by construction: an initiator starts from published material alone, because the recipient of a first message is usually offline. A suite that requires both parties online cannot serve this Messenger.

func NewDefaultSuite

func NewDefaultSuite() Suite

NewDefaultSuite returns the route-independent one-to-one suite proposed by the decision package. The suite uses the operating system's cryptographic random source and keeps all mutable protocol state in the State values returned by its methods.

type Target

type Target struct {
	DeviceID  string
	SessionID string
	// Bootstrap reports that no session exists yet and one has to be built
	// from the device's published bundle.
	Bootstrap bool
	// BundleDigest names the bundle to bootstrap from, when Bootstrap is set.
	BundleDigest string
}

Target is one sealed copy the fan-out owes.

Directories

Path Synopsis
Package conformance refutes candidate end-to-end encryption suites.
Package conformance refutes candidate end-to-end encryption suites.

Jump to

Keyboard shortcuts

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