Help us shape the future of Go! Take the 2026 Go Developer Survey

pairing

package
v1.3.102 Latest Latest
Warning

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

Go to latest
Published: Oct 23, 2026 License: AGPL-3.0 Imports: 26 Imported by: 0

Documentation

Overview

Package pairing pkg/skychat/pairing/crypto.go c4-app-chat body encryption for chat-pair feeds.

Threat model: the pair feed lives at a publicly-known DMSG port under the publisher's PK, and any peer who learns the publisher PK + port can attempt to subscribe. The publisher-side allowlist (CXO TreeStore SubscriberAllowlist, added in #2378) gates read access to the feed at the connection layer. This file adds a belt-and-suspenders layer at the message-content layer:

  • Each Pair derives a 32-byte symmetric key from ECDH(my_sk, peer_pk). Both sides compute the same key from ECDH(my_pk, peer_sk) and ECDH(peer_pk, my_sk) — that's the symmetry property of ECDH.
  • Every leaf published into the pair feed is sealed with ChaCha20-Poly1305 AEAD, with a fresh random 12-byte nonce prepended to the ciphertext: [nonce | ct+tag].

What is encrypted:

  • The Message JSON body. The leaf bytes stored in CXO are ciphertext; subscribers decrypt before json.Unmarshal.

What is NOT encrypted:

  • The path: msgs/<unix-nanos>/<seq>. Timestamp + ordering are metadata; an observer who already breached the allowlist would learn the timing pattern of messages but not their content.

secp256k1 + ChaCha20-Poly1305: the skywire identity uses secp256k1 keys, not Curve25519, so we can't use NaCl box directly. skycoin/cipher.ECDH does the secp256k1 scalar-mult and SHA256s the raw shared point — that yields a 32-byte key suitable as the ChaCha20-Poly1305 key directly. No HKDF needed because we have a single fixed use (this pair) per derived key.

The static key is now the FALLBACK, not the norm

derivePairKey's output is a pure function of two long-term identity keys, which means it never changes and can be recomputed forever — so leaking a visor's identity secret retroactively opens every DM it ever exchanged. epoch.go / ratchet.go replace it with short-lived epoch keys whose secret halves are destroyed on rotation.

The static key survives for exactly two jobs, both compatibility:

  • Messages already on a feed, published before this visor (or the peer) had a ratchet. Those leaves stay in the CXO tree forever and must keep opening.
  • A peer running a build without ratchet support, which never announces and never sends an epoch envelope.

sealEnvelope tags every new message with the epoch it used, and openEnvelope routes a tagged blob to the ring and an untagged one to the static key. A pair falls back only when it genuinely has no epoch; it never downgrades once one exists.

Package pairing pkg/skychat/pairing/epoch.go c4-app-chat forward-secret epoch keys for a chat pair.

What was wrong with one key per pair

crypto.go derives the pair key as ECDH(my_sk, peer_pk). That is a pure function of two long-term identity keys, so it is the SAME key for every message the pair will ever exchange, and it can be recomputed from either side's identity secret at any point in the future. The consequences are worse than "no forward secrecy" in the abstract:

  • Pair feeds are CXO trees. Every ciphertext ever published stays in the tree and is served to anyone the allowlist admits. An observer only has to capture the feed once.
  • A visor's identity secret sits in its config file and is the same key used for dmsg, transports, and RPC auth — a broad surface with many ways to leak that have nothing to do with chat.
  • Put together: leaking that one file, ever, retroactively decrypts every DM the visor ever sent or received. Rotating it doesn't help either, because the old key still opens the old feed.

Epochs

Both sides now publish a short-lived RATCHET public key on their own feed (ratchet.go) and re-publish it periodically. The key for an epoch is derived from the two current ratchet keys:

epochSecret = ECDH(my_ratchet_sk, peer_ratchet_pk)
epochKey    = HKDF-SHA256(epochSecret, salt=epochID, info="skychat:pair-epoch:v1")

ECDH's symmetry gives both sides the same secret from their own secret half and the other's public half, exactly as before — but now neither half is a long-term identity key. When a side rotates its ratchet key it DELETES the old secret, and from that moment the epochs that used it cannot be re-derived by anyone, including the parties themselves. The identity keys are no longer sufficient to open past traffic, which is the whole point.

This is the DH half of a Double Ratchet and not the symmetric half: there is no per-message chain key, so within one epoch a compromise still reveals that epoch. Bounding an epoch is what ratchetMaxAge and ratchetMaxMessages are for. A per-message chain would need in-order delivery guarantees the CXO feed does not offer (leaves arrive in tree order after a resync, not send order), so the epoch is the unit here.

Why HKDF here and not in crypto.go

The legacy path used the raw ECDH output as the AEAD key, justified by there being exactly one use per derived secret. That stops being true with epochs: the same (my_ratchet, peer_ratchet) pair identifies an epoch, and mixing the epoch ID in through a KDF is what keeps the key bound to it. It also gives a versioned info string, so a future change of construction is a different key rather than a silent reinterpretation of the same bytes.

Package pairing pkg/skychat/pairing/manager.go c4-app-chat a visor's collection of chat pairs.

The Manager owns the bbolt Store and a map[cipher.PubKey]*Pair of live runtime state. It glues persistence to lifecycle: Add records a pair and brings up its Pair, Resume walks the store on startup and reopens every active pair, Remove transitions a record to revoked and tears down its Pair.

The pairing handshake itself (sending invite messages over legacy skychat, awaiting ack, transitioning pending→active) is intentionally out of scope — it lives one layer up in PR-3, where it can drive the Manager via its public surface.

Package pairing pkg/skychat/pairing/pair.go c4-app-chat pair's runtime state.

One CXO node per pair side, listening on the deterministic pair port (ComputePairPort(my_pk, peer_pk)). The node hosts both roles:

  • Publisher: shares this side's outbox feed, with an allowlist of exactly the peer's PK. Only the peer may subscribe and read.
  • Subscriber: connected to the peer's outbox feed (same port, peer's PK as the feed identity). Receives messages from peer.

One node per pair (not two) keeps the DMSG port allocation simple and matches the symmetric protocol: both ends listen on the same port number, both ends dial the other at that port. The CXO node multiplexes publisher and subscriber roles by feed PK.

Lifecycle: Open creates the publisher (ready to accept the peer's subscribe) and constructs the subscriber attached to the publisher's node. Connect dials the peer's node and registers the inbound subscribe. Open + Connect are split so the pairing handshake (PR-5+) can stage the publisher before the peer is known to be ready.

Package pairing pkg/skychat/pairing/ports.go c4-app-chat DMSG port allocation for chat-pair feeds.

Each unordered pair of visor public keys (Alice, Bob) maps to a stable DMSG port. Both sides of the pair compute the same number from public information alone, so no out-of-band port negotiation is needed.

Port plan: one port per pair, in [PortBase, PortBase+PortSpan). Each side's CXO node listens on this single port and hosts both roles — Alice publishes her own pair-with-Bob feed and subscribes to Bob's pair-with-Alice feed on the same node, with Bob's node doing the symmetric thing on the same port number on his side. One port suffices because publisher and subscriber operate on distinct feed PKs (each side's own PK), so the CXO node multiplexes them on the single DMSG conn.

The range sits clear of the system DMSG port table (everything <300 plus reserved ports around 80 / 100 / 136). Collisions with the reserved set are walked past so a pair can always Listen.

Package pairing pkg/skychat/pairing/ratchet.go c4-app-chat the per-pair ratchet: which short-lived keys we hold, which the peer has announced, and when to move on.

The announcement

Each side publishes its current ratchet PUBLIC key as a leaf on its own feed, at ratchet/<generation>. That is the whole handshake — there is no round trip, no negotiation, and no ordering requirement. Each side derives every epoch it can from (its own ratchet secrets) × (the peer's announced ratchet keys), so the two ends converge on the same set of epoch keys whichever order the leaves arrive in.

Announcements are signed with the visor's IDENTITY key. The CXO Root that carries the leaf is already signed by the feed owner, so this is belt-and-braces — but the field it protects is the one that decides which key we derive, and an attacker who could rewrite a leaf without the signature would be able to steer us onto a key of their choosing. The cost is one signature per rotation, which is hours apart.

Why announcements are re-published, not just published once

A pair's CXO tree is the durable record: a subscriber that resyncs walks the whole tree and sees every ratchet/<gen> leaf, including ones published while it was offline. Keeping the past generations in the tree (rather than overwriting one path) is deliberate — a peer that was away for three of our rotations still needs the announcements for the epochs whose messages it hasn't read yet, and pruning them would make that history unopenable for a reason unrelated to forward secrecy.

The ratchet SECRETS are the ones that get dropped, and dropping them is what makes those old epochs forward-secret: the announcement being public is fine, since a public key alone derives nothing.

What is deliberately not here

No per-message chain key. See epoch.go for why the epoch, not the message, is the unit of forward secrecy on a CXO feed.

Package pairing pkg/skychat/pairing/store.go c4-app-chat persistence for chat pairs.

One bucket "pairs" with key = peer PK hex, value = JSON Record. Crash-safe by virtue of bolt's single-writer semantics: each Put is one transaction, each Resume is a read.

The store is intentionally narrow — Add / Get / List / Delete / Set — so the lifecycle code in pair.go can drive policy (e.g. when to transition status from "pending" to "active") without the store having to know about it.

Package pairing pkg/skychat/pairing/store_seal.go c4-app-chat sealing a pair's ratchet secrets at rest.

The pair store gained something worth protecting when the ratchet landed. Before it, a Record held only metadata — peer PK, port, status, timestamps — and the one secret in the system (the static pair key) was never stored at all because it could be recomputed from the visor's identity key on demand. Recomputable was the problem the ratchet exists to fix, and the fix means the secrets now have to live somewhere.

What is at stake if they are stored in the clear: the current ratchet SECRET (opens the current epoch, and every future one until the next rotation) and the epoch-key ring (opens up to ratchetRingCap epochs of feed history). Writing those as plaintext JSON would hand an attacker with read access to pairs.db exactly what the ratchet was built to deny them, without needing the identity key at all — a strictly worse position than before the feature.

Same construction as the group store (cmd/apps/skychat/group/ store_seal.go), deliberately, so there is one at-rest idiom in this app: HKDF-SHA256 from the visor's secret key into a ChaCha20-Poly1305 key, per-record AAD binding a blob to the slot it belongs in, and a best-effort open so a record whose secrets won't decrypt still loads its metadata.

Same honest limit, too: the sealing key derives from the visor SK, which lives in the config file on the same disk. This raises the bar from "read one file" to "read two specific files on the same host". It is not disk encryption and not a passphrase.

Index

Constants

View Source
const (
	PortBase uint16 = 10000
	PortSpan uint16 = 50000
)

Port-allocation tunables. Public constants so callers (e.g. the visor's CXO user-feed registry, which needs to reject overlapping port allocations) can validate against the same numbers.

View Source
const EpochIDLen = 8

EpochIDLen is the length of the epoch identifier carried on every message.

Eight bytes: long enough that two live epochs of one pair colliding is not a thing that happens (a pair would need ~2^32 epochs for an even chance, against a ring that holds 256), short enough to be cheap on every leaf. It is not a security parameter — a collision costs one failed AEAD open and a fall through to the next candidate key, because the tag is what actually authenticates.

View Source
const MessagePathPrefix = "msgs"

MessagePathPrefix is the prefix used for message leaves within a pair feed. Subscribers set this on their TreeStore.Subscriber so future non-message metadata (e.g. typing indicators) can land under different prefixes without surfacing here.

View Source
const MessageTypeDelete = "delete"

MessageTypeDelete marks a control record that retracts an earlier message rather than carrying one: Type is set, ID names the target (see Message.MsgID), and Text is empty.

A peer running a build that predates this field decodes the record as a Message with no text and no Type, and nothing on that path filters empty text — so it won't apply the delete and may render a blank bubble. Handlers on this side skip any record with a non-empty Type, which is what keeps a future control type from doing the same to us.

View Source
const RatchetPathPrefix = "ratchet"

RatchetPathPrefix is the CXO path prefix for ratchet announcements on a pair feed: "<RatchetPathPrefix>/<generation>". Distinct from MessagePathPrefix so a subscriber can route the two without parsing.

Variables

View Source
var ErrPairClosed = errors.New("pairing: pair is closed")

ErrPairClosed is returned by Send / Connect when the pair has already been Closed. Surfaces the post-close state as a clean error instead of a nil-pointer panic on p.pub / p.sub. Sentinel so callers can distinguish closed-pair from transient publisher errors via errors.Is(err, ErrPairClosed).

View Source
var ErrStoreSealKeyRequired = errors.New("pairing: OpenStore: a visor secret key is required to seal pair secrets at rest")

ErrStoreSealKeyRequired is returned by OpenStore when handed a zero secret key. Refusing is the point: silently falling back to plaintext would leave the ratchet secrets exposed while the caller believes they are sealed.

Functions

func ComputePairPort

func ComputePairPort(a, b cipher.PubKey) (uint16, error)

ComputePairPort returns the deterministic DMSG port for the chat pair (a, b). Symmetric: ComputePairPort(a, b) == ComputePairPort(b, a).

Returns an error only when every port slot in the span is also in ReservedPorts (impossible with the current static table, but checked so future expansions of that table fail loudly rather than infinite-loop).

func ReservedPorts

func ReservedPorts() map[uint16]struct{}

ReservedPorts is the set of DMSG ports already bound by visor subsystems. The pair-feed allocator avoids these so a publisher can always Listen. Mirrors pkg/visor/cxo_user_feeds.go reservedDmsgPorts; kept in sync manually because dragging in pkg/visor here would create an import cycle (skychat is invoked by the launcher inside the visor).

Types

type Config

type Config struct {
	// MyPK / MySK are this visor's identity.
	MyPK cipher.PubKey
	MySK cipher.SecKey

	// PeerPK is the chat partner.
	PeerPK cipher.PubKey

	// DmsgC is the shared DMSG client.
	DmsgC *dmsg.Client

	// DataDir is the per-visor parent directory for CXO state. Each
	// Pair carves its own pair/<peer-pk-hex>/ subtree inside it.
	DataDir string

	// InMemoryDB runs this pair's CXO tree in memory (no DataDir /
	// filesystem). Required for the browser (js/wasm) visor, where the
	// on-disk CXDS is a stub that always errors. When set, DataDir may be
	// empty and is ignored.
	InMemoryDB bool

	// Logger is optional; nil falls back to a tag-based default.
	Logger *logging.Logger

	// BatchWindow forwards to the publisher (see treestore.Config).
	BatchWindow time.Duration

	// Ratchet is the pair's persisted forward-secrecy state, restored
	// from the store. Nil for a brand-new pair (or one whose record
	// predates the ratchet), in which case Open mints a first keypair.
	Ratchet *RatchetState

	// OnRatchetChange is fired with the ratchet's persistable snapshot
	// whenever it advances, so the Manager can save it. Optional — a
	// Pair without one still works, it just re-mints its ratchet on
	// every restart and loses the epochs it had derived.
	OnRatchetChange func(RatchetState)
}

Config bundles the inputs Pair.Open needs.

type EpochID

type EpochID [EpochIDLen]byte

EpochID names one (my ratchet key, peer ratchet key) combination.

func (EpochID) IsZero

func (e EpochID) IsZero() bool

IsZero reports whether this is the zero ID, which is what a legacy (pre-ratchet) message carries.

func (EpochID) MarshalText

func (e EpochID) MarshalText() ([]byte, error)

MarshalText / UnmarshalText render an EpochID as hex wherever encoding/json touches one, so a persisted ring reads as "3f1c…" instead of the array of 8 integers a [8]byte marshals to by default. Same form the wire envelope carries, so there is one rendering of an epoch across logs, disk, and the feed.

func (EpochID) String

func (e EpochID) String() string

String renders the ID as hex, for logs and for the operator-facing epoch label in the UI.

func (*EpochID) UnmarshalText

func (e *EpochID) UnmarshalText(b []byte) error

UnmarshalText parses the hex form.

type Manager

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

Manager owns the persistent record set and live Pair instances. Safe for concurrent use.

func NewManager

func NewManager(cfg ManagerConfig) (*Manager, error)

NewManager constructs a manager. Does not open any pairs; call Resume to bring up everything in the store.

func (*Manager) Add

func (m *Manager) Add(peerPK cipher.PubKey) (*Pair, error)

Add creates a new pending pair, persists its record, brings up the Pair (publisher only — Connect is the caller's choice), and returns it. Returns the existing Pair if one is already live for peerPK.

func (*Manager) Close

func (m *Manager) Close() error

Close tears down every live pair. Does not close the store — callers manage the store lifetime themselves.

func (*Manager) Get

func (m *Manager) Get(peerPK cipher.PubKey) (*Pair, bool)

Get returns the live Pair for peerPK, or nil + false if none.

func (*Manager) MarkActive

func (m *Manager) MarkActive(peerPK cipher.PubKey) error

MarkActive transitions the record for peerPK from pending to active. Called by the pairing handshake (PR-3) when the peer's pair-ack arrives.

func (*Manager) Remove

func (m *Manager) Remove(peerPK cipher.PubKey) error

Remove tears down the pair, transitions its record to revoked, and drops it from the live map. The record is kept (rather than deleted) so the user can audit prior contacts; future tooling may expose a hard-purge.

func (*Manager) Resume

func (m *Manager) Resume() (resumed int, err error)

Resume brings up every active record from the store. Skips revoked records. Pending records are also resumed (so a crash between invite-send and ack doesn't lose state); the caller can decide whether to re-send the invite.

Errors on any single pair are logged and skipped — a corrupted or transient-unavailable pair shouldn't block the rest from coming up. The manager tracks how many resumed successfully.

func (*Manager) SetMessageHandler

func (m *Manager) SetMessageHandler(h MessageHandler)

SetMessageHandler installs the callback that fires for every inbound peer message across every pair. Replaces any prior handler and is propagated to existing live pairs.

type ManagerConfig

type ManagerConfig struct {
	// Store is required. The manager does not take ownership — call
	// Store.Close yourself when shutting down.
	Store *Store

	// DmsgC is the shared DMSG client passed to every Pair.
	DmsgC *dmsg.Client

	// MyPK / MySK are this visor's identity (the publisher feed-PK
	// for every pair this manager creates).
	MyPK cipher.PubKey
	MySK cipher.SecKey

	// DataDir is the parent directory under which each Pair gets
	// pub/<peer>/ and sub/<peer>/ subtrees.
	DataDir string

	// InMemoryDB runs every pair's CXO tree in memory (no DataDir /
	// filesystem). Required for the browser (js/wasm) visor, where the
	// on-disk CXDS is a stub that always errors. When set, DataDir may be
	// empty and is ignored.
	InMemoryDB bool

	// Logger is optional.
	Logger *logging.Logger
}

ManagerConfig wires a Manager.

type Message

type Message struct {
	Text string    `json:"text"`
	TS   time.Time `json:"ts"`

	// Type is empty for a chat message, MessageTypeDelete for a
	// retraction. Kept omitempty so the common record is unchanged
	// on the wire.
	Type string `json:"type,omitempty"`

	// ID is the target of a MessageTypeDelete record — the MsgID of the
	// message being retracted. Unused (and omitted) on a chat message,
	// whose own id is derived from TS and Seq.
	ID string `json:"id,omitempty"`

	// Seq is the publisher's per-pair counter for this record, the same
	// one that disambiguates the leaf path. It's in the body so MsgID can
	// stay unique when two messages land on the same nanosecond — the
	// case the path's seq already exists to cover. Absent on records from
	// a peer that predates this field, which just means their ids end in
	// -0; both ends still derive the id from the same bytes, so they
	// agree either way.
	Seq uint64 `json:"seq,omitempty"`
}

Message is the on-the-wire form of a chat message inside a pair feed. Body encryption (PR-6) wraps Text into a ciphertext field; for now Text is plaintext.

func (Message) MsgID

func (m Message) MsgID() string

MsgID is the identifier both ends use to name this message. It is derived only from fields sealed into the body, so the peer computes the same string from the record it receives — which is what lets a delete published later name a message sent earlier.

(timestamp, seq) is the same pair that keys the leaf path, so two messages can share an id only if they'd have collided on the feed too.

type MessageHandler

type MessageHandler func(peerPK cipher.PubKey, msg Message)

MessageHandler is invoked on every newly-arrived peer message. Called from the subscriber's update goroutine; implementations should be non-blocking (queue the message and return).

type Pair

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

Pair is one live chat pair. Safe for concurrent use after Open returns; Send and Close may be called from any goroutine.

func Open

func Open(cfg Config) (*Pair, error)

Open constructs the pair's CXO node, brings up the publisher with allowlist=[PeerPK], and prepares a subscriber attached to the same node. The subscriber is not yet connected — call Connect after the peer is known to be ready (or immediately when both sides are brought up at once, e.g. in tests).

func (*Pair) Close

func (p *Pair) Close() error

Close tears down the subscriber + publisher (and the underlying CXO node, owned by the publisher). Idempotent.

func (*Pair) Connect

func (p *Pair) Connect(ctx context.Context) error

Connect dials the peer's CXO node and starts the subscribe handshake. Idempotent: returns nil if already connected.

ctx bounds the dmsg dial — see Subscriber.Connect. Callers without a natural ctx can pass context.Background(); the dial will then rely on dmsg's own timeouts to escape a hung peer.

Open + Connect are split so the pairing handshake can stage the publisher before the peer is known to be ready, then activate the inbound side once the peer's pair-ack arrives.

Returns ErrPairClosed if Close has already torn down the subscriber. Pre-fix this nil-deref'd on p.sub.Connect.

func (*Pair) EpochID

func (p *Pair) EpochID() (EpochID, bool)

EpochID returns the epoch this pair currently seals under, and false when it is still on the legacy static key.

False is the normal state for a conversation nobody has spoken in yet: an epoch needs BOTH ratchet keys, each side publishes its own as part of a Send (see maybeAnnounce), so the epoch appears once each end has sent at least once. It also stays false against a peer running a build with no ratchet at all.

Operator-facing: the UI shows it so a user can tell which of the two modes a conversation is in, since they are otherwise indistinguishable and differ in exactly the property that matters here.

func (*Pair) PeerPK

func (p *Pair) PeerPK() cipher.PubKey

PeerPK is a convenience accessor.

func (*Pair) Port

func (p *Pair) Port() uint16

Port returns the deterministic DMSG port this pair uses on both sides.

func (*Pair) Send

func (p *Pair) Send(text string) error

Send publishes a message to this side's outbox feed. The peer's subscriber will see it on the next CXO publish-batch cycle.

The message body is sealed with the pair's ECDH-derived key before being stored as the leaf value, so anyone who breaches the publisher allowlist still cannot read message content. The path itself (timestamp + seq) stays plaintext.

Returns ErrPairClosed if Close has already torn down the publisher. Pre-fix this nil-deref'd on p.pub.Put.

func (*Pair) SendDelete

func (p *Pair) SendDelete(id string) error

SendDelete retracts an earlier message by publishing a delete record naming its id. The record rides the same feed as the message it retracts, so it is durable and ordered: a peer that is offline now applies the delete when it next syncs, and a peer that has not yet received the message gets both in the same batch.

No authorship check is possible or needed — a pair feed is single-writer, so this can only ever retract our own messages.

func (*Pair) SendID

func (p *Pair) SendID(text string) (string, error)

SendID publishes text like Send and returns the new message's MsgID. Callers that may later retract the message need this: the id is minted here from the timestamp that goes into the sealed body, so it is the only value that names the same message on both sides.

func (*Pair) SetMessageHandler

func (p *Pair) SetMessageHandler(h MessageHandler)

SetMessageHandler registers (or replaces) the inbound-message callback. Pass nil to unregister.

type PersistedEpoch

type PersistedEpoch struct {
	ID      EpochID   `json:"id"`
	Key     pairKey   `json:"key,omitempty"`
	Sealed  []byte    `json:"key_sealed,omitempty"`
	AddedAt time.Time `json:"added_at"`
}

PersistedEpoch is one epoch key as stored. Key is plaintext in memory and cleared in favor of Sealed on disk.

type RatchetAnnounce

type RatchetAnnounce struct {
	// Generation counts this side's ratchet keys, starting at 1. Used
	// for the leaf path and for picking the newest announcement when
	// several arrive together; it is NOT the epoch (an epoch is formed
	// by one of these and one of the peer's).
	Generation uint64 `json:"generation"`

	// RatchetPK is the public half of the announcing side's current
	// ratchet keypair. Its secret half never leaves that visor and is
	// destroyed when the generation is retired.
	RatchetPK cipher.PubKey `json:"ratchet_pk"`

	IssuedAt  time.Time     `json:"issued_at"`
	IssuerPK  cipher.PubKey `json:"issuer_pk"`
	Signature cipher.Sig    `json:"signature"`
}

RatchetAnnounce is one side's published ratchet public key.

type RatchetState

type RatchetState struct {
	// Generation / RatchetPK / RatchetSK are our current ratchet
	// keypair. RatchetSK is sealed on disk (RatchetSKSealed).
	Generation uint64        `json:"-"`
	RatchetPK  cipher.PubKey `json:"-"`
	RatchetSK  cipher.SecKey `json:"-"`

	// RatchetSKSealed is the on-disk form of RatchetSK.
	RatchetSKSealed []byte `json:"-"`

	// PeerGeneration / PeerRatchetPK track the newest announcement we
	// accepted from the peer. Public data — not sealed.
	PeerGeneration uint64        `json:"-"`
	PeerRatchetPK  cipher.PubKey `json:"-"`

	// Current is the epoch Send seals under.
	Current EpochID `json:"-"`

	// Ring holds the epoch keys we can still open history with.
	Ring []PersistedEpoch `json:"-"`

	RotatedAt time.Time `json:"-"`
	Sealed    uint64    `json:"-"`
}

RatchetState is the persisted form of a pair's ratchet. Lives on the Record so a restart resumes on the same epoch.

Every field is `json:"-"`: the wire form is ratchetStateJSON below, via MarshalJSON. Two reasons, and the first is a correctness bug rather than taste. cipher.PubKey and cipher.SecKey are fixed-size ARRAYS, so `omitempty` does nothing for them — a zero SecKey (which is exactly what RatchetSK holds after sealing clears it) marshals to sixty-four zeroes and then FAILS to unmarshal with "Invalid secret key", taking the whole record with it. The second: routing through an explicit wire struct makes it structurally impossible for the plaintext secret to reach the file, because there is no field for it there at all.

func (RatchetState) MarshalJSON

func (r RatchetState) MarshalJSON() ([]byte, error)

MarshalJSON writes the wire shape.

func (*RatchetState) UnmarshalJSON

func (r *RatchetState) UnmarshalJSON(b []byte) error

UnmarshalJSON reads it back.

type Record

type Record struct {
	// PeerPK is the partner visor's public key.
	PeerPK cipher.PubKey `json:"peer_pk"`

	// Port is the DMSG port both sides use for this pair. Equal to
	// ComputePairPort(myPK, peerPK). Each side's CXO node listens
	// on this port and hosts both publisher (own feed) and
	// subscriber (peer's feed) roles.
	//
	// Persisted (rather than recomputed every time) so a future
	// reserved-port table change doesn't silently shift live pairs.
	Port uint16 `json:"port"`

	Status        Status    `json:"status"`
	EstablishedAt time.Time `json:"established_at"`
	LastMessageAt time.Time `json:"last_message_at,omitempty"`

	// Ratchet is this pair's forward-secrecy state: our current ratchet
	// keypair, the peer's latest announced key, and the ring of epoch
	// keys that still open history. Nil for a record written before the
	// ratchet existed — Open mints a fresh one in that case.
	//
	// Its secrets are sealed at rest (store_seal.go), so this pointer
	// holds plaintext keys in memory and never on disk.
	Ratchet *RatchetState `json:"ratchet,omitempty"`
}

Record is the persisted form of a chat pair.

type Status

type Status string

Status enumerates a Record's lifecycle.

const (
	// StatusPending means we've created our publisher and sent (or
	// are about to send) a pair-invite to the peer; the peer hasn't
	// yet acknowledged.
	StatusPending Status = "pending"

	// StatusActive means both sides have publishers up and are
	// subscribed to each other. Normal operating state.
	StatusActive Status = "active"

	// StatusRevoked means we've torn down the pair from our side
	// (e.g. user removed the contact). The record is kept for audit
	// or potential reactivation; pair.go skips revoked records on
	// Resume.
	StatusRevoked Status = "revoked"
)

type Store

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

Store is a bolt-backed pair record store. Safe for concurrent use.

func OpenStore

func OpenStore(path string, sk cipher.SecKey) (*Store, error)

OpenStore opens (or creates) the bolt file at path. The parent dir is created if missing.

sk is the visor's secret key and is REQUIRED: it derives the at-rest key for the ratchet secrets this store now holds. Passing the zero key is refused rather than silently degraded to plaintext.

func (*Store) Close

func (s *Store) Close() error

Close releases the bolt file handle.

func (*Store) Delete

func (s *Store) Delete(peerPK cipher.PubKey) error

Delete removes the record for peerPK. No-op if absent.

func (*Store) Get

func (s *Store) Get(peerPK cipher.PubKey) (Record, bool, error)

Get returns the record for peerPK and a boolean indicating presence.

func (*Store) List

func (s *Store) List() ([]Record, error)

List returns every persisted record. Order is bolt's bucket-sorted order (lexicographic on hex peer PK).

func (*Store) MarkMessage

func (s *Store) MarkMessage(peerPK cipher.PubKey, ts time.Time) error

MarkMessage updates LastMessageAt to the given timestamp without touching other fields.

func (*Store) Put

func (s *Store) Put(r Record) error

Put writes (or replaces) the record for r.PeerPK.

func (*Store) SetRatchet

func (s *Store) SetRatchet(peerPK cipher.PubKey, st RatchetState) error

SetRatchet persists a pair's ratchet state.

Called on every ratchet change — a rotation, an accepted peer announcement, a newly derived epoch — because losing any of them is not cosmetic. A visor that rotated and failed to save comes back holding a secret the peer has never seen, so its own sends are unopenable and the peer's are undecryptable until both sides rotate past the gap.

func (*Store) SetStatus

func (s *Store) SetStatus(peerPK cipher.PubKey, status Status) error

SetStatus updates only the Status field of an existing record. Returns an error if no record exists for peerPK.

Jump to

Keyboard shortcuts

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