relay

package
v0.13.23 Latest Latest
Warning

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

Go to latest
Published: Sep 2, 2026 License: MIT Imports: 35 Imported by: 0

Documentation

Overview

Package relay is the untrusted rendezvous/mailbox/push relay (R-REL.*, ADR-007 D9/D11). It stores and forwards ONLY ciphertext, relay-auth public keys, and routing metadata — never plaintext, identity keys, or the pairing secret. E2EE confidentiality does not depend on TLS (the relay is a blind forwarder); TLS is a metadata-only defense.

The relay wire envelope is a SEPARATE structure modeled on internal/wire framing (4-byte big-endian length + 1-byte tag, MaxFrame 1 MiB) with its own tag set — it is NOT an extension of the frozen client<->daemon wire.Type enum (plan D.0-A13). Control payloads are JSON, snake_case, unknown-field-tolerant, version/capability negotiated on r_hello.

Index

Constants

View Source
const (
	ReconnectInitialDelay = 500 * time.Millisecond
	ReconnectFactor       = 2
	ReconnectCeiling      = 30 * time.Second
	ReconnectJitter       = 0.20
)

The reconnect backoff between relay dial attempts: ADR-007 section 6.0's numeric budget (initial delay, growth factor, ceiling, jitter fraction), and the D9 clause that binds it -- "Client reconnect backoff + jitter on BOTH hops".

A client that reconnects hard exhausts the relay's per-target quota, which is shared with the traffic it is trying to carry; the exponential growth is what keeps a stuck relay from being redialled at a fixed high rate for the life of the process, and the jitter keeps a fleet reconnecting after a relay restart from arriving as one herd. These are the committee's approved numbers, not tuning knobs: a change here is a change to the budget.

IT LIVES IN THIS PACKAGE BECAUSE BOTH HOPS DIAL THROUGH IT AND NOTHING ELSE IS COMMON TO THEM. The schedule shipped first as unexported constants inside mobile/relay.go, which made it the PHONE's schedule -- and when the gateway hop finally got a reconnect there was nowhere to take the numbers from but a second copy. A budget that exists twice is two budgets. relay is the one package both the handset facade and the machine sidecar already import (it is on mobile's PB-BIND-0 allowlist), so putting the schedule here costs the bound handset no new dependency edge and gives the clause one home.

Section 6.0's transcription of these values is asserted directly, against literals, in mobile/pbnet4_backoff_test.go.

View Source
const CapabilityMailboxRecovery = "mailbox_recovery"

serverCaps is the relay's capability set; r_hello negotiates the intersection.

"wait" is the bounded server-side mailbox_wait (ADR-007 B7, PB-NET-5). It is advertised HERE, in the handshake the protocol has had all along, because the alternative was measured and rejected by the final-audit committee (finding H1): a client that has to PROBE the op learns "unsupported" only from a timeout, and a pre-wait relay's dispatch answers the unknown op with an ordinary in-order MsgError the client's parked waiter cannot correlate -- a stray reply that shifts the connection's request/reply pairing. With the capability advertised, a client never sends the op to a relay that did not claim it, and a reconnect to an upgraded relay re-evaluates for free. CapabilityMailboxRecovery is advertised by a gateway connection only when that gateway understands the destructive-mailbox-recovery command and will replace the compacted backlog with a roster reseed. The relay uses the negotiated capability on the CURRENT paired gateway session as a deletion fence; an old or disconnected gateway therefore cannot authorize a new phone to discard data it would not replace.

View Source
const DefaultCallTimeout = 10 * time.Second

DefaultCallTimeout bounds one authenticated request/reply exchange.

IT EXISTS BECAUSE SILENCE IS THE RELAY'S CHEAPEST ATTACK. The relay is the declared adversary and it need not misbehave to wedge a client: it can complete the websocket handshake, accept every frame, and reply to none. roundtrip holds c.mu across write-then-read and readFrame waits on the socket, so ONE unanswered exchange used to park every producer on the connection -- with no error, no state change and nothing to retry. A half-open TCP after a WiFi -> cellular handoff presents identically, so this is reached benignly as well as adversarially.

THE SAME BOUND THE GATEWAY ALREADY APPLIES TO THIS CLIENT, and for the same argument: remotegw's defaultAppendTimeout ("an UNBOUNDED append against a hung relay would pin that lock forever and wedge every producer AND Err()", Blocker 2). What that fixed was one caller; this fixes the client, so a call site nobody audited -- and every call site the phone has, which all pass context.Background() -- inherits the bound rather than the wedge.

TEN SECONDS IS §6.0'S, NOT THIS AUTHOR'S. The budget table binds "Non-wait request timeout | 10 s" to PB-NET-7 under a preamble that reads "Changing any value requires committee agreement, not implementer discretion" (docs/specifications/remote-phaseB-requirements.md). This constant first shipped at five seconds on latency grounds -- generous against PB-NET-5's 150 ms p50 and against push_trigger's one-second verdict wait, and matching App.awaitConn's five-second patience. Every word of that argument may still be true and none of it is this file's to make: a budget an implementer may re-derive locally is not a budget, and the row was NOT MET while the two disagreed (ADR-007 B99).

The 10 s used to live only as transport.RequestTimeout, in a package the shipped phone does not use -- so the value bound to a requirement existed exclusively in code nothing ran, which is the same defect as the fence-guarding-a-dead-path this constant was written to close. That package has since been deleted (B98), so this is now the ONLY place the budget exists at all, and TestCallDeadline_TheNonWaitRequestTimeoutIsTheCommitteeBudget pins it here, on the live path, so the next divergence is a failing test rather than a review round.

View Source
const DefaultDialTimeout = 10 * time.Second

DefaultDialTimeout bounds the CONNECT phase of every dial: the TCP handshake, the TLS handshake, the HTTP response and the websocket upgrade -- every stage before the first frame.

IT EXISTS BECAUSE THE DIAL HAPPENS BEFORE EVERY OTHER BOUND. DefaultCallTimeout bounds an exchange on an open connection and the gateway bounds its own MailboxWait, and NEITHER IS EVER REACHED by a caller still inside the dial. A relay that accepts the TCP connection and then stalls -- no ServerHello, no response head, a half-written 101 -- costs itself one file descriptor and parks its caller for as long as it cares to hold the socket. A half-open TCP after a WiFi -> cellular handoff presents identically, so this is benign as well as adversarial.

WHAT IT COSTS EACH SHIPPED CALLER, differently, and NOT ONE OF THE THREE declares a deadline:

THE PHONE never enters backoff. mobile/app.go passes context.WithCancel(context.Background()),
and App.run's reconnect schedule runs BETWEEN dial attempts -- a dial that never returns is a
dial that never fails, so PB-NET-4's backoff never runs once.

THE GATEWAY, cmd/swarm-remote, passes signal.NotifyContext(context.Background(), ...) and
starts by dialling, so a stalled dial is a sidecar that never starts and never says why.

THE PAIRING RENDEZVOUS burns the owner connection's pairing SLOT. internal/skeleton's
relayRendezvousFactory dials inside the closure BeginPairing calls, on the owner connection's
lifetime context -- and BEFORE pairing.go builds pairCtx, so ADR-007 B64's window is not yet
in force. The slot is already claimed and is released only by `result` or by BeginPairing's
error return; a dial that never returns takes neither, there is no pair_cancel op, and
dropping the owner connection is the only exit.

THE BOUND IS HERE, at the one seam every dial passes, and not at the callers. THREE callers exist and all three got it wrong independently -- and only two of them were known when this was written, which is the argument rather than a prediction about it: a per-caller fix would have been applied to the set we happened to know, and that set was wrong. It is also the quantifier B115 left open at the sibling defect ("fixing the one instance does not close a quantifier").

AND THE THIRD SITE SHOWS WHY THE CALLER IS THE WRONG PLACE even when it is remembered. The ctx relayRendezvousFactory receives has DUAL DUTY -- it bounds the dial AND owns the connection's lifetime, via the `go func(){ <-ctx.Done(); _ = conn.Close() }()` watcher three lines down -- so a caller-side `defer cancel()` there closes the connection it just returned. Bounding at this seam has no such hazard at any of the three.

TEN SECONDS IS SECTION 6.0'S, REUSED RATHER THAN RE-DERIVED. The connect phase IS one non-wait request/reply -- an HTTP GET carrying the upgrade, answered by a 101 -- and the budget table binds "Non-wait request timeout | 10 s" to PB-NET-7. Minting a second, local dial budget is what ADR-007 B99 refused.

AND THE COMPOSITION LANDS EXACTLY ON THE RELAY'S OWN PRE-AUTH WINDOW, which is the corroboration TestDialDeadline_TheWholeDialFitsTheRelaysOwnPreAuthWindow pins. A whole dial is this connect phase plus two non-wait requests (auth_init, auth_resp, each bounded at the same 10 s by DefaultCallTimeout) = 30 s; the relay bounds the SAME window from its own side at Config.HandshakeTimeout -- 30 s, "CUMULATIVE time-to-authenticate, anchored at accept time" (preAuthDeadline), one of the Phase A constants section 6.0's preamble names as the values its table is chosen to be consistent with. Past that instant an honest peer has already hung up.

ONLY THAT NUMBER IS BORROWED FROM THE ADVERSARY, never its enforcement. B112 recorded that exact error -- "MailboxWait bypasses it by construction under the relay's own 25 s ceiling", measured 2.8x past that ceiling -- and the deadline below is declared and enforced on this side, whatever the peer does.

WHAT IS STILL THE COMMITTEE'S TO SAY, recorded rather than decided here: section 6.0 has no row for a dial or a connect, so "the upgrade is one non-wait request" is a READING of an existing row, not a row. It is the narrowest reading available -- it mints nothing and it composes onto a constant already in the table -- but a committee that wanted a distinct dial budget would be setting it, not confirming this.

View Source
const DiagnosticCapabilityTTL = 5 * time.Minute

DiagnosticCapabilityTTL is the hard ceiling on a minted diagnostic capability's lifetime (playbook 6.5: "capability TTL <= 5 min, single-use"). It is enforced server-side against the relay's OWN clock; a capability carries no TTL of its own to claim a longer one.

"SINGLE-USE" IS ENFORCED IN PROCESS MEMORY ONLY (spendDiagNonce's diagUsedNonces, server.go), not the durable store — the same shape and the same reasoning as rendezvous burns (see burnWindow's comment). A relay restart forgets every spent nonce, so a capability spent just before a restart and still inside its TTL could, in principle, be replayed just after one. The blast radius stays tiny even so: replaying it buys nothing but a fresh, empty, per-connection diagnostic route bounded by maxDiagItems and mailboxPageByteBudget — never real mailbox content, never a routing id.

View Source
const GenericPushAlert = "You have a new secure message."

GenericPushAlert is the fixed, content-free outer alert the relay attaches to every push. It never carries a routing id, session id, or command text — the real content is the opaque ciphertext the app decrypts on-device (R-REL.5).

View Source
const MaxFrame = 1 << 20 // 1 MiB

MaxFrame is the largest legal declared frame length L (payload+1 tag byte), the anti-DoS cap enforced before any body allocation or read.

View Source
const ProtocolVersion = 1

ProtocolVersion is the current relay wire protocol version. r_hello negotiates it; a client offering an incompatible version is refused, not downgraded.

View Source
const PushEnvelopeSize = 78

PushEnvelopeSize is the ONE ciphertext length the relay puts on the push channel.

It is a SCHEMA, not a bound, and that distinction is the requirement. PB-PUSH-3 concedes that the provider observes token, timing and SIZE — and a conceded disclosure is benign only while it is CONSTANT. That property is quantified over the CHANNEL, so it is a promise the RELAY keeps and not one the gateway can keep alone: every producer reaching the provider must agree on one number, or the provider separates WHAT happened from THAT something happened without touching crypto — the exact defeat the two-tier wake/content key split exists to prevent (PB-KEY-2, ADR-007 B87, residual 4.23). A bound would not do it: the disclosure is a DIFFERENCE, not a magnitude, so a cap leaves every shape below it separable.

WHY THE RELAY REFUSES RATHER THAN PADS. It forwards the opaque envelope BYTE FOR BYTE and has to keep doing so (TestPush_RelaySeesOnlyCiphertext): padding would make the relay a party that edits ciphertext, and truncating would silently destroy a wake the phone then cannot open — with the loss surfacing as an AEAD failure on the one background path the phone has. Refusal is the only normalisation available to a party that holds no key, and a refused push puts nothing on the channel and so discloses nothing.

THE NUMBER is crypto's 62-byte cleartext envelope header plus a 16-byte AEAD tag over an EMPTY plaintext — the same constant as remotegw.PushWakeEnvelopeSize, which is the PRODUCER's side of it. This package cannot import remotegw (remotegw imports this one), so the two are pinned to each other by test rather than by the compiler: the reachability arm of TestPBPUSH3_AnUnschemadTriggerEnvelopeIsTheSameSizeOnTheChannel drives a remotegw.PushWakeEnvelopeSize envelope through push_trigger and requires it DELIVERED, so a relay that disagreed with the gateway would refuse every real wake and fail there.

Variables

View Source
var (
	// ErrQuotaExceeded is a clean refusal past a rate/quota cap.
	ErrQuotaExceeded = errors.New("relay: quota exceeded")
	// ErrNotAuthorized is returned when a caller acts on a route it is not
	// paired to (R-REL.12).
	ErrNotAuthorized = errors.New("relay: not authorized for route")
	// ErrPeerCapabilityUnavailable is returned when an operation is safe only
	// while the caller's paired peer is connected through a protocol generation
	// that explicitly advertises support for it. Callers may retry after the peer
	// reconnects or is upgraded; the refused operation has made no state change.
	ErrPeerCapabilityUnavailable = errors.New("relay: active peer does not advertise required capability")
	// ErrRevoked is returned when a de-authorized relay-auth key tries to
	// authenticate (R-REL.13).
	ErrRevoked = errors.New("relay: relay-auth registration revoked")
	// ErrDuplicateConnection is returned to a connection that has been
	// superseded by a newer connection for the same routing id (takeover).
	ErrDuplicateConnection = errors.New("relay: connection superseded by a newer one")
	// ErrWaitInProgress refuses a SECOND concurrent bounded server-side wait on
	// one client. §6.0 caps pending waits per client at 1 and REFUSES the extra
	// one rather than queueing it: a queue would let a client pin unbounded
	// server-side wait state on one connection and make cancellation ambiguous.
	ErrWaitInProgress = errors.New("relay: a mailbox wait is already outstanding on this client")
	// ErrMailboxCursorResetRequired means the caller's durable mailbox cursor cannot
	// be a resume point in the mailbox the relay currently holds. This is recoverable:
	// the caller rewinds ONLY the relay storage cursor, preserving its authenticated
	// per-stream replay high-waters, and drains again from zero.
	//
	// It is distinct from a malformed cursor. A store reinitialisation or rollback can
	// leave an otherwise valid persisted cursor past the new mailbox's high-water. The
	// authenticated replay high-waters, not this relay cursor, prevent duplicate effects
	// when the caller drains again from zero.
	ErrMailboxCursorResetRequired = errors.New("relay: mailbox cursor no longer names a safe resume point; reset required")
	// ErrRendezvousFull is returned when a third party claims a rendezvous that
	// already has two participants.
	ErrRendezvousFull = errors.New("relay: rendezvous already has two participants")
	// ErrRendezvousExpired is returned when a rendezvous is claimed past its
	// hard relay-side TTL.
	ErrRendezvousExpired = errors.New("relay: rendezvous expired")
	// ErrRendezvousExists is returned when rendezvous_create targets an id that
	// already holds a live slot, so the original creator is never overwritten.
	ErrRendezvousExists = errors.New("relay: rendezvous id already in use")
	// ErrRendezvousBurned is returned when a completed (single-use) rendezvous
	// id is claimed again.
	ErrRendezvousBurned = errors.New("relay: rendezvous already used")
	// ErrConsentRetired refuses a route consent whose pairing ceremony has been
	// superseded or revoked (ADR-007 B47). It is DISTINCT from ErrNotAuthorized
	// because its remedy is: the credential is well-formed and genuinely signed by
	// the named device, and what it needs is a new pairing, not a different caller.
	ErrConsentRetired = errors.New("relay: this pairing's route consent has been retired; pair the device again")
	// ErrConsentMalformed refuses a credential that is not a consent at all.
	ErrConsentMalformed = errors.New("relay: malformed route consent")

	// ErrTimeout reports an exchange that reached its deadline with no reply. It is
	// the relay ANSWERING NOTHING -- distinct from every refusal above, which are
	// answers -- and it is what a caller sees instead of parking forever when the
	// relay completes the handshake and then goes quiet (DefaultCallTimeout).
	//
	// IT IS NOT A REFUSAL AND MUST NOT BE TREATED AS ONE. The relay writes its reply
	// AFTER it stores the item, so a timed-out append may well have committed, and
	// re-appending the IDENTICAL sealed envelope is the only safe retry. Note that the
	// gateway draws no seq consequence from the refusal sentinels either: a seq handed
	// to the appender is spent whatever the reply says (ADR-007 B127).
	ErrTimeout = errors.New("relay: the relay did not answer within the call deadline")

	// ErrConnClosed reports a connection that died underneath a caller. The underlying
	// network error is not propagated: every caller's response is the same (the
	// connection is gone), and a resilient one reconnects.
	//
	// IT IS EXPORTED SO THE PHONE CAN CLASS IT (mobile/errorclass.go). It was reachable
	// by any call racing a drop -- the ordinary end of a mobile outage -- and, being
	// unexported, matched no arm of the phone's classifier and landed in the class whose
	// remedy is "report a bug". It is the same user-visible condition as ErrTimeout and
	// must not depend on which of the two won the race.
	ErrConnClosed = errors.New("relay: connection closed")
)
View Source
var (
	// ErrCleartextRefused rejects a ws:// relay URL. E2EE does not depend on TLS,
	// but cleartext exposes routing metadata, so it is refused outside the
	// loopback carve-out below.
	ErrCleartextRefused = errors.New("relay: cleartext ws:// refused; use wss://")
	// ErrPinMismatch rejects a relay certificate that is not the pinned one.
	ErrPinMismatch = errors.New("relay: server certificate does not match the pin")
	// ErrPinRequired rejects a verified dial on a platform whose trust-root source
	// is TrustRootsPinned: there is no usable root store, so the caller must pin.
	// It names PinnedSPKISHA256 FIRST because this error is the only instruction
	// most operators will ever read about how to configure a pin, and the other
	// form stops working at the relay's next certificate renewal (PB-OPS-5).
	ErrPinRequired = errors.New("relay: platform has no usable trust roots; Security.PinnedSPKISHA256 (renewal-safe) or Security.PinnedCert is required")
	// ErrPinMalformed rejects a pin that is not a SHA-256 digest. It is decided
	// before the dial rather than inside VerifyPeerCertificate: a truncated pin
	// that only ever surfaced as a handshake failure would read as "the relay is
	// down", and one silently zero-padded to length would weaken the check.
	ErrPinMalformed = errors.New("relay: Security.PinnedSPKISHA256 must be a 32-byte SHA-256 digest")
)

Transport-security sentinels (PB-NET-2). Both are decided BEFORE any packet is sent, so a refusal never costs a connection attempt and never leaks a dial to an unverified peer.

View Source
var ErrDiagRouteNotOpen = errors.New("relay: no diagnostic route is open on this connection")

ErrDiagRouteNotOpen is returned by diag_append/diag_read/diag_close when this connection has no live diagnostic route -- diag_open was never called, failed, or a prior diag_close already ended it. It carries no information about any OTHER connection's route: there is nothing to enumerate.

View Source
var ErrDiagnosticCapabilityInvalid = errors.New("relay: invalid diagnostic capability")

ErrDiagnosticCapabilityInvalid covers every way a presented capability fails verification ONCE AN OPERATOR SECRET IS CONFIGURED: wrong shape, wrong MAC (wrong secret or tampered bytes), wrong identity binding, expired, future-dated past clock skew, or already spent. It is DELIBERATELY ONE ERROR: telling a caller who already knows a secret is configured WHICH of those checks failed would let it narrow a search for one that verifies.

R2 review LOW (info leak): this does NOT cover the ONE case handled before any of the above ever runs -- no operator secret configured at all (ErrDiagnosticsDisabled, checked first in handleDiagOpen) -- so an authenticated stranger CAN still distinguish "diagnostics disabled" from "diagnostics configured, capability rejected." That single fact is accepted, not closed: it is also the one fact that makes `swarm relay doctor`'s own failure message actionable (docs/operations/relay-runbook.md section 12) rather than as uninformative as everything else this error deliberately hides.

View Source
var ErrDiagnosticsDisabled = errors.New("relay: diagnostics disabled; no operator secret configured")

ErrDiagnosticsDisabled is returned when the relay has no operator secret configured (or MintDiagnosticCapability is called with none): there is no default secret, so diagnostics are refused outright rather than minting a capability nothing can ever verify.

View Source
var ErrFrameTooLarge = errors.New("relay: frame exceeds MaxFrame")

ErrFrameTooLarge is returned when a payload (WriteFrame) or a declared length (ReadFrame) exceeds MaxFrame.

View Source
var ErrPushUnregistered = errors.New("relay: push token is no longer registered with the provider")

ErrPushUnregistered is the ONE verdict a sink returns that the relay acts on: the provider reports this token belongs to no live installation (FCM `UNREGISTERED`). The relay then PRUNES the token, since retrying it burns quota against a handset that no longer exists.

It is a sentinel rather than a status code so a sender can wrap it with context and the relay still classifies via errors.Is, and it is deliberately narrow: every OTHER failure — a 5xx, a network drop, a bad credential — leaves the token in place. Pruning on a transient error silently disables push for a LIVE handset, and nothing surfaces until a user misses a hand-off.

View Source
var ErrRelayAnswered = errors.New("relay: the relay answered")

Sentinel errors returned by Client/Conn operations. Each maps to a stable wire error code so a caller can errors.Is against it after a round-trip. Every over-limit or refusal is a CLEAN error, never resource exhaustion (R-REL.8). ErrRelayAnswered marks an error that IS the relay's answer -- an error frame the relay composed and sent -- as opposed to a transport, timeout or decode failure AROUND the exchange. It exists for errors.Is only and is never returned bare: decodeError wraps every answer it manufactures so a caller deciding "did the relay answer no, or did the exchange fail?" tests this sentinel instead of inferring the answer from what the error is not -- a fallthrough that classified a truncated or oversized reply frame as a substantive refusal (SH5 round-3 review F1).

View Source
var ErrRelayRunning = errors.New("relay: store is locked by a running relay")

ErrRelayRunning is returned by Backup and Restore when the store file is currently held open by another process (in production, a running relay). Backing up or restoring into a live store risks reading a torn snapshot or replacing a file a live writer still has open.

Functions

func AuthChallengeMessage

func AuthChallengeMessage(nonce []byte, routingID string) []byte

AuthChallengeMessage is the canonical message the relay-auth key signs during connection auth: a domain-separated binding of the server nonce AND the claimed routing id (nonce||ctx). Binding both means a signature cannot be replayed across routes or contexts. It is deterministic in its inputs.

func Backup

func Backup(dbPath, destPath string) error

Backup writes a consistent snapshot of the bbolt store at dbPath to destPath using bolt.Tx.WriteTo inside a read-only transaction (PB-6.5) -- bbolt's own documented hot-backup mechanism. The read-only Open above already requires the exclusive flock a running relay holds be free (see the package comment), so nothing else can be writing dbPath while this runs; Tx.WriteTo's role here is producing a self-contained snapshot as of one transaction, not defending against a concurrent writer that cannot exist.

destPath is written via a temp file in the same directory, fsynced, and renamed into place, so a failure partway through (including a disk-full write) never leaves a partial or corrupt file at destPath, and a crash or power loss immediately AFTER a 'successful' backup cannot leave a truncated file behind either -- the rename is not visible until the fsync ahead of it completes. The destination directory is fsynced too, once the rename lands, so the directory entry itself is not lost to the same crash.

func ConsentMessage

func ConsentMessage(ceremonyID, granteeRoutingID string) []byte

ConsentMessage is the canonical statement a party's relay-auth key signs to grant granteeRoutingID authority over that party's own route: append to its mailbox, wake it, and revoke it. It is ADR-007 B27's consent signature, which B38 made mandatory, and it needs no new crypto — the signer is the existing relay-auth key, through the custody it already has (crypto.KeyStore.SignRelayAuth on the phone, machineid.Identity.RelayAuthSign on the machine).

WHAT IT BINDS, AND WHY THAT IS THE WHOLE SECURITY ARGUMENT. The GRANTER is bound implicitly and unforgeably: the relay verifies under the granter's own relay-auth public key, which is the key its routing id derives from. The GRANTEE is bound explicitly by its routing id, so the signature is NOT a bearer token — a copy taken off the machine's disk, off the wire, or out of a device registry authorizes only the one routing id it names, and any other caller presenting it is refused.

AND IT BINDS THE CEREMONY THAT PRODUCED IT (ADR-007 B47). This used to read "there is no nonce and none is needed: the statement is a standing grant... and it is revoked by device_revoke rather than by expiry". Both halves were true and the join was never checked: revokeAndPurge deletes the pairs edges and writes a ban, and the SIGNATURE is a durable artifact the grantee still holds — so re-presenting the identical bytes rewrote both edges and, because authorizePair clears a ban placed by that same pairer, lifted the ban in the same transaction. `swarm remote revoke` was undone by a file, and the phone was never asked.

THE CEREMONY ID IS THE RENDEZVOUS ID, WHICH IS WHY THERE IS NO BOOTSTRAP HERE. Both parties hold it from the QR before msg1, so nothing has to be fetched from the relay before the machine can speak — the recorded alternative, a relay-held generation counter, would have to reach the phone in msg2 while being keyed by a routing id the machine does not learn until msg3, which is deliverEpochGrant's own shape.

It is NOT a challenge and confers no freshness on the connection: the statement stays a standing grant, presentable as often as the machine's gateway needs it (see store.authorizePair, where a re-presentation of the LIVE id is idempotent). What it buys is that the relay can RETIRE one, which is what a revoke now does.

It is deterministic in its inputs, and the ceremony id is length-prefixed because there are now two variable-length fields: without it "ab"+"c" and "a"+"bc" would share an encoding (F11 — no splicing).

func EmbeddedTrustRoots

func EmbeddedTrustRoots() []byte

EmbeddedTrustRoots returns the compiled-in CA bundle, in PEM form. It is empty because no platform we ship to declares TrustRootsEmbedded.

func EnsureOperatorSecret

func EnsureOperatorSecret(path string) (string, error)

EnsureOperatorSecret returns the relay operator secret at path, generating and persisting a fresh high-entropy one at 0600 if the file does not yet exist. An existing file's contents are reused unchanged -- this is idempotent across restarts, not a rotation mechanism.

THE RETURNED SECRET MUST NEVER BE LOGGED. Every caller in this tree checks only the error and, when it needs the value, reads the file back rather than holding the secret in a log-adjacent variable.

func MarshalConsent

func MarshalConsent(ceremonyID string, sig []byte) []byte

MarshalConsent packages a signed consent for carriage: the ceremony id the signature covers, then the signature. It travels as ONE opaque credential rather than two parameters so everything that stores or forwards it — the device registry, the gateway params, authorize_device — keeps handling a single blob, and so no call site can hold a signature and a ceremony id that were never produced together.

Carrying the id is not trusting it: ParseConsent only reads it back out, and handleAuthorizeDevice verifies the signature OVER it, so a relabelled credential verifies against nothing.

func MintDiagnosticCapability

func MintDiagnosticCapability(secret []byte, now time.Time, rid string) ([]byte, error)

MintDiagnosticCapability produces a fresh, single-use diagnostic capability bound to secret (the relay operator secret, e.g. EnsureOperatorSecret's file contents), good for DiagnosticCapabilityTTL from now, and presentable ONLY by rid -- the RoutingID of the relay-auth keypair the caller is about to authenticate with. It runs the SAME computation the relay verifies against (parseDiagCap), so `swarm relay doctor` -- given the same operator_secret_file the relay reads -- mints one with no network round trip and no privileged endpoint: presenting the result is the ONLY thing the relay's authenticated surface ever checks.

R2 review LOW (design): rid closes an unaudienced-bearer-token gap. Without it, the MAC covered only (context, nonce, issued_at) -- ANY endpoint the capability was ever shown to (a typo'd URL, a hijacked DNS record) could replay the identical bytes against the REAL relay inside the TTL, under a throwaway identity of its own choosing. Binding to a SPECIFIC identity's routing id means replaying it still needs that identity's private relay-auth key to authenticate as -- exactly what an endpoint that only ever OBSERVED the capability never has.

func ParseConsent

func ParseConsent(b []byte) (ceremonyID string, sig []byte, err error)

ParseConsent is the inverse of MarshalConsent. A malformed or truncated credential is an error, never a zero-length ceremony id that might match a stored one.

func ReconnectBackoffBase

func ReconnectBackoffBase(attempt int) time.Duration

ReconnectBackoffBase returns the un-jittered delay before dial attempt n, 1-based: ReconnectInitialDelay doubling on every failed attempt, never exceeding ReconnectCeiling.

func ReconnectJittered

func ReconnectJittered(base time.Duration, frac float64) time.Duration

ReconnectJittered spreads base by +/-ReconnectJitter. frac is a value in [-1, 1] -- taken as a parameter, rather than drawn here, so the spread itself is testable without a random source.

func Restore

func Restore(dbPath, backupPath string) error

Restore replaces the store at dbPath with the backup at backupPath. It refuses while dbPath is held open by a running relay, validates backupPath opens as a bbolt database with every bucket the relay requires, and only then replaces dbPath -- via a temp file in the same directory, fsynced and renamed into place, so a failure partway through never leaves dbPath partially written; the directory is fsynced too, once the rename lands, so the directory entry itself is not lost to a crash immediately after.

Restore is a point-in-time rollback of the WHOLE store, which includes the revocation state: restoring a backup taken before a `swarm remote revoke` brings back the deleted pairing edge and drops the retired-ceremony tombstone (ADR-007 B47) that make the revoke durable, so a grantee that kept its old consent bytes is accepted again after the restore. This is inherent to any point-in-time restore, not a bug in this package -- see relay-runbook.md Sec.11's "Restore is a revocation rollback" caveat, and re-revoke anything that was revoked after the backup was taken.

func RoutingID

func RoutingID(pub ed25519.PublicKey) string

RoutingID is the relay's opaque handle for a party: HKDF-SHA256 over the party's relay-auth Ed25519 public key. It is deterministic and collision- distinct, and it is NOT the raw pubkey — the relay never needs, stores, or can recover the pubkey from it (R-REL.11). A zero/short key yields a stable but distinct value; callers pass a real 32-byte Ed25519 pubkey.

func ValidMailboxIncarnation

func ValidMailboxIncarnation(incarnation string) bool

ValidMailboxIncarnation reports whether incarnation is the canonical wire/storage form: a 128-bit value encoded as exactly 32 lowercase hexadecimal characters.

func WriteConfigFile

func WriteConfigFile(path string, cfg Config) error

WriteConfigFile writes cfg as JSON, 0600.

func WriteFrame

func WriteFrame(w io.Writer, tag MsgType, payload []byte) error

WriteFrame encodes tag and payload as one frame and writes it to w. It rejects a payload larger than MaxFrame-1 without writing any bytes.

Types

type Client

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

Client is an authenticated relay connection bound to RoutingID(relay-auth pub).

func Dial

func Dial(ctx context.Context, url string, auth ClientAuth) (*Client, error)

Dial opens a connection and completes the Ed25519 signed-challenge handshake, binding the connection to RoutingID(auth.RelayAuthPub). A revoked key, a rate refusal, or a bad signature returns a non-nil error and no Client.

It applies no transport-security policy: the URL is dialed as given, so the auth_init frame -- which carries auth.RelayAuthPub in full -- is readable by any observer of a ws:// hop (ADR-007 B37). It remains exported for the test rigs that stand up an in-process relay and dial it deliberately; NO production caller may reach it, which internal/remote/transport's productiondial_test.go enforces at the call site. Production dials go through DialSecure, machine-side ones under MachineSecurity.

func DialSecure

func DialSecure(ctx context.Context, url string, auth ClientAuth, sec Security) (*Client, error)

DialSecure is Dial under an explicit transport-security policy (PB-NET-2): TLS verified against the platform's stated trust roots by default, a pinned certificate as a per-connection opt-in for a self-hosted relay, and cleartext refused. The policy is decided before any packet is sent, so a refusal returns ErrCleartextRefused/ErrPinMismatch without a connection attempt and never yields a Client. It is re-decided on every redirect hop, so a relay cannot answer the upgrade with a 302 into cleartext.

func (*Client) AuthorizeDevice

func (c *Client) AuthorizeDevice(ctx context.Context, devicePub ed25519.PublicKey, consentSig []byte) error

AuthorizeDevice pairs this caller with the named device, authorizing mailbox/push routing between the two — in BOTH directions, which is why it takes the device's consent.

consentSig is the device's own signature over ConsentMessage(c.RoutingID()), produced by the device's relay-auth key during the SAS-authenticated pairing ceremony and carried here by the caller (ADR-007 B27, made mandatory by B38). Without it the relay has no way to tell this call from a stranger naming a routing id it merely read off an unprotected auth_init or a photographed QR, because at the relay those two are the same shape. An absent, malformed, or non-matching signature is refused with ErrNotAuthorized.

func (*Client) Close

func (c *Client) Close() error

Close severs the connection with the polite websocket close handshake. It is idempotent. Use it where an orderly goodbye is the point -- a finished exchange, a process exiting; a caller that is ABANDONING the connection uses CloseNow, because against a peer that has stopped answering the handshake waits its full five seconds with nothing to show for it (measured: the cost lands exactly when the pump has exited and the peer is silent -- the state a dead link leaves behind).

func (*Client) CloseNow

func (c *Client) CloseNow() error

CloseNow severs the connection WITHOUT the close handshake -- the teardown for a caller that is abandoning the connection rather than finishing it (see Conn.CloseNow). It shares Close's once, so whichever teardown runs first decides.

func (*Client) DeviceRevoke

func (c *Client) DeviceRevoke(ctx context.Context, target string) error

DeviceRevoke de-authorizes target's relay-auth registration and purges its relay-side mailbox.

func (*Client) DiagAppend

func (c *Client) DiagAppend(ctx context.Context, envelope []byte) error

DiagAppend stores one opaque envelope in this connection's diagnostic route. ErrDiagRouteNotOpen if DiagOpen has not (yet, or successfully) unlocked one.

func (*Client) DiagClose

func (c *Client) DiagClose(ctx context.Context) error

DiagClose deletes this connection's diagnostic route.

func (*Client) DiagOpen

func (c *Client) DiagOpen(ctx context.Context, capability []byte) error

DiagOpen presents a diagnostic capability (MintDiagnosticCapability) and, if it verifies, opens a fresh ephemeral diagnostic route bound to this connection. A malformed, expired, wrong-secret, or already-spent capability is ErrDiagnosticCapabilityInvalid; no operator secret configured on the relay at all is ErrDiagnosticsDisabled.

func (*Client) DiagRead

func (c *Client) DiagRead(ctx context.Context) ([]Item, error)

DiagRead returns every item appended to this connection's diagnostic route -- never any other connection's, and never a real mailbox.

func (*Client) DiagStatus

func (c *Client) DiagStatus(ctx context.Context) (DiagStatus, error)

DiagStatus reports the relay's storage health. It requires an already-open diagnostic route (DiagOpen), exactly like DiagAppend/DiagRead: ErrDiagRouteNotOpen otherwise.

func (*Client) Done

func (c *Client) Done() <-chan struct{}

Done is closed when the underlying connection dies, so a caller can notice a drop without issuing a request.

func (*Client) Hello

func (c *Client) Hello(ctx context.Context, version int, caps []string) (int, []string, error)

Hello negotiates the protocol version and capability set on an authenticated connection -- the same r_hello a raw Conn speaks, surfaced here so a client can learn PER CONNECTION which optional ops this relay serves (the "wait" capability, committee finding M1) instead of probing an op blind and reading the verdict out of a timeout.

func (*Client) MailboxAck

func (c *Client) MailboxAck(ctx context.Context, cursor uint64) error

MailboxAck compacts away every item at or below cursor.

func (*Client) MailboxAckGeneration

func (c *Client) MailboxAckGeneration(ctx context.Context, cursor, generation uint64) error

MailboxAckGeneration acks a cursor only if the delivery generation it belongs to is still current. The generation check and incarnation capture are atomic: a reset before them refuses the stale cursor, while a reset afterwards leaves the request bound to the old incarnation and therefore unable to purge the replacement mailbox.

func (*Client) MailboxAppend

func (c *Client) MailboxAppend(ctx context.Context, target string, env []byte) (uint64, error)

MailboxAppend stores an opaque envelope in target's mailbox and returns the relay's assigned storage cursor.

func (*Client) MailboxDiscard added in v0.13.11

func (c *Client) MailboxDiscard(ctx context.Context, peer string) (MailboxDiscardResult, error)

MailboxDiscard explicitly compacts the authenticated caller's own current mailbox. It is intentionally unavailable until a successful read has adopted a non-empty incarnation: a destructive request must never fall back to an unqualified numeric coordinate or an unfenced legacy relay.

func (*Client) MailboxGeneration

func (c *Client) MailboxGeneration() uint64

MailboxGeneration snapshots the live continuity generation for a delivery whose ack may race an explicit reset. Pass it to MailboxAckGeneration after processing that delivery.

func (*Client) MailboxIncarnation

func (c *Client) MailboxIncarnation() string

func (*Client) MailboxRead

func (c *Client) MailboxRead(ctx context.Context, cursor uint64) ([]Item, error)

MailboxRead returns items whose storage cursor is strictly greater than cursor. The reply is a bounded first page (CR-4): on a large backlog it returns a subset that fits one frame rather than tearing the connection. Callers that need to drain a backlog and observe whether more remains use MailboxReadPage.

func (*Client) MailboxReadPage

func (c *Client) MailboxReadPage(ctx context.Context, cursor uint64, limit int) ([]Item, bool, error)

MailboxReadPage returns at most a bounded page of items whose storage cursor is strictly greater than cursor, plus has_more indicating whether further items remain past the page (CR-4). limit caps the page's item count; limit <= 0 asks for the server's own default page bound. A page always fits under MaxFrame, so draining an arbitrarily large backlog is a loop of MailboxReadPage + MailboxAck that never overflows a frame.

func (*Client) MailboxWait

func (c *Client) MailboxWait(ctx context.Context, cursor uint64) ([]Item, bool, error)

MailboxWait blocks SERVER-side until at least one item past cursor exists in this client's own mailbox, and returns that bounded page — the same {items, has_more} shape as MailboxReadPage. At Config.MaxServerWait it returns an empty page and a nil error, so a caller's loop is a wait, not a poll.

It returns the ITEMS, not a bare signal: a signal a caller then had to read would cost two metered ops per batch, which §6.0's inbound drain budget cannot absorb. It meters exactly ONCE per call however many items come back, so batching under load actually buys something.

§6.0 caps pending waits per client at one. A second concurrent wait is refused with ErrWaitInProgress, never queued — a queue would make cancellation ambiguous and let one connection pin unbounded server-side wait state.

func (*Client) Presence

func (c *Client) Presence(ctx context.Context, target string) (PresenceInfo, error)

Presence returns target's coarse reachability.

func (*Client) PushTrigger

func (c *Client) PushTrigger(ctx context.Context, target string, env []byte) error

PushTrigger forwards an opaque wake envelope to target's registered push token.

func (*Client) ResetMailboxIncarnation

func (c *Client) ResetMailboxIncarnation()

func (*Client) RoutingID

func (c *Client) RoutingID() string

RoutingID returns the connection's bound routing id.

func (*Client) SetMailboxIncarnation

func (c *Client) SetMailboxIncarnation(incarnation string)

SetMailboxIncarnation seeds the durable mailbox identity a consumer resumed with. Older relays ignore the optional request field; newer relays refuse a different store before interpreting the numeric cursor.

func (*Client) TokenDelete

func (c *Client) TokenDelete(ctx context.Context) error

TokenDelete stops push delivery to this device.

func (*Client) TokenRegister

func (c *Client) TokenRegister(ctx context.Context, token string) error

TokenRegister registers (or refreshes) this device's APNs push token.

type ClientAuth

type ClientAuth struct {
	RelayAuthPub ed25519.PublicKey
	Sign         func(challenge []byte) ([]byte, error)

	// Peer is the routing id of the counterparty whose revocation of THIS identity the
	// dialer wants to be told about, and it is optional (ADR-007 B49).
	Peer string
}

ClientAuth carries the only key a party ever discloses to the untrusted relay: its Ed25519 relay-auth public key, plus a signer over the relay's challenge. The signer is a closure so a hardware-gated key never leaves its boundary.

Sign can FAIL (ADR-007 B18(a)): the only production phone-side implementation is crypto.KeyStore.SignRelayAuth, which a hardware-gated custody refuses. Swallowing that refusal here — or signing nil and letting the relay reject opaquely — would re-create one layer up exactly the errorless interface B14 removed.

type Clock

type Clock interface {
	Now() time.Time
}

Clock is the single authoritative time source the relay reads for every TTL, rate window, presence timeout, and retention cap (ADR-007). Tests inject a fake clock so no assertion depends on a real sleep.

type Config

type Config struct {
	// Listen is the TCP listen address (host:port; :0 for an ephemeral port).
	Listen string `json:"listen"`
	// TLSMode is "off" for plain ws:// (E2EE does not depend on TLS) or "on"
	// for a metadata-defense TLS terminator.
	TLSMode string `json:"tls_mode"`
	// DBPath is the bbolt persistence file.
	DBPath string `json:"db_path"`
	// AdminListen is the TCP listen address for the health/readiness admin
	// surface (playbook 6.5: /healthz, /readyz) -- a SEPARATE port from Listen,
	// never the public one. Start REFUSES a non-loopback value: the doctor
	// rule ("the normal public protocol gains no privileged unauthenticated
	// endpoint") applies to health too, so this is not a flag that can turn
	// off the loopback restriction, only one that names which loopback
	// address/port to use. Empty disables admin serving entirely.
	AdminListen string `json:"admin_listen"`
	// OperatorSecretFile is the path to the generated high-entropy relay
	// operator secret (playbook 6.5: "for diagnostic/admin authority"). If the
	// file does not yet exist, the relay generates one at boot and persists it
	// here at 0600 (EnsureOperatorSecret); if it exists, the existing secret is
	// reused. The secret is NEVER logged. It is not a substitute for Web-PKI
	// server authentication (playbook 6.5) -- the future `swarm relay doctor`
	// capability (a separate R2 slice) is its consumer. Empty disables
	// generation entirely, matching PushCredentials' opt-in shape.
	OperatorSecretFile string `json:"operator_secret_file"`
	// PushCredentials is the path to a Google service-account JSON document. Set, the
	// shipped binary constructs the FCM sender (internal/remote/push) and injects it via
	// WithPushSink; EMPTY, the relay runs with NO push transport at all, which is a
	// supported configuration -- pushes are dropped and every other path is unaffected
	// (PB-PUSH-5). A path that is set but unreadable or invalid fails the boot: silently
	// running push-less because a credential moved is precisely the failure an operator
	// only learns about from a user who missed a hand-off.
	PushCredentials string `json:"push_credentials"`

	// TrustedProxies lists CIDRs whose connections are a trusted reverse proxy,
	// never a client (playbook 6.5). Default empty means today's behavior
	// unchanged: every per-source quota keys off the raw TCP peer address. When
	// the peer that reached the relay falls inside one of these CIDRs, the
	// relay instead keys quotas off the client address recovered from the LAST
	// (rightmost) X-Forwarded-For hop -- the one hop that proxy itself
	// appended -- so a Caddy-in-front-of-a-loopback-relay deployment
	// (docs/operations/relay-vps-deploy.md) stops collapsing every real client
	// into Caddy's one shared bucket. See resolveSourceAddr.
	TrustedProxies []string `json:"trusted_proxies"`

	// HandshakeTimeout bounds a read on a connection that has not yet
	// authenticated or joined a rendezvous: an idle socket that completes the ws
	// handshake but sends no frame is closed within it (CR-1 slowloris defense).
	// A value <= 0 disables the bound.
	HandshakeTimeout time.Duration `json:"handshake_timeout"`
	// PresenceTimeout is how long after a gateway drop presence goes offline and
	// the silent-push path fires (R-REL.3).
	PresenceTimeout time.Duration `json:"presence_timeout"`
	// RendezvousTTL is the hard relay-side pairing-rendezvous lifetime (R-PAIR.6).
	RendezvousTTL time.Duration `json:"rendezvous_ttl"`
	// MaxServerWait is the ceiling on a bounded server-side wait (mailbox_wait,
	// ADR-007 B7): with nothing to deliver the relay answers a clean empty page
	// rather than holding the socket open indefinitely. §6.0 sets 25 s, chosen to
	// sit under the 30-60 s idle timeout common to intermediaries, so the relay
	// always answers before a proxy severs the connection. A value <= 0 selects
	// defaultMaxServerWait.
	MaxServerWait time.Duration `json:"max_server_wait"`
	// RetentionCap purges mailbox items this old even if never acked (R-REL.10).
	RetentionCap time.Duration `json:"retention_cap"`
	// SweepInterval is the cadence at which Start runs the clock-driven maintenance
	// sweeps (presence-went-silent pushes + retention purges) on a timer (CR-3). A
	// value <= 0 disables the loop, leaving the sweeps to be invoked manually — the
	// DefaultConfig value, so existing manual-sweep tests stay deterministic. The
	// shipped binary (cmd/swarm-relay) sets a non-zero production value.
	SweepInterval time.Duration `json:"sweep_interval"`

	Quotas Quotas `json:"quotas"`
}

Config is the relay's on-disk configuration (R-REL.9). cmd/swarm-relay reads exactly one of these and boots.

func DefaultConfig

func DefaultConfig() Config

DefaultConfig returns a config with safe, generous defaults. Callers override Listen/TLSMode/DBPath (and tighten quotas) before New.

func LoadConfig

func LoadConfig(path string) (Config, error)

LoadConfig reads and parses a config file. A missing or malformed file is a clean error (the binary fails closed), never silent defaults.

type Conn

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

Conn is a raw, unauthenticated framed connection to the relay over a single websocket. Pairing rendezvous rides it (pairing peers are not yet relay- registered); authenticated clients wrap it (see Dial).

func DialRaw

func DialRaw(ctx context.Context, url string) (*Conn, error)

DialRaw opens an unauthenticated framed connection (rendezvous + adversarial framing use it).

It applies NO transport-security policy: the URL is dialed as given. Production callers use DialRawSecure -- see the note on Dial.

func DialRawSecure

func DialRawSecure(ctx context.Context, url string, sec Security) (*Conn, error)

DialRawSecure is DialRaw under a transport-security policy (PB-NET-2). It is the pairing rendezvous's entry point: no relay-auth key is disclosed there, but it is the first packet either end sends to a URL a scanned QR chose, so the same refusal applies and it is decided before a socket is opened.

func (*Conn) Close

func (c *Conn) Close() error

Close severs the connection. It is idempotent.

func (*Conn) CloseNow

func (c *Conn) CloseNow() error

CloseNow severs the connection WITHOUT the websocket close handshake, for a caller that is ABANDONING the exchange rather than finishing it.

Close below cancels the connection's own context and then performs the polite close, which waits up to five seconds for the peer's close frame -- and cannot observe one, because cancelling the context has already stopped the reader. So an aborted connection pays that timeout in full, every time. That was invisible while nothing waited on the teardown; it is five seconds on the caller's shutdown path as soon as something does.

It shares Close's once, so whichever teardown runs first decides and the other is a no-op: a graceful Close already in flight is not turned into an abort by a late CloseNow, or the reverse.

func (*Conn) Done

func (c *Conn) Done() <-chan struct{}

Done is closed when the connection is no longer usable. On a pumped connection (Dial, DialSecure) that happens as soon as the peer or the network drops it; on a raw one it happens at Close.

func (*Conn) Hello

func (c *Conn) Hello(ctx context.Context, version int, caps []string) (int, []string, error)

Hello negotiates the protocol version and the intersected capability set. An unsupported version is refused (returns a non-nil error), not downgraded.

func (*Conn) PeerSPKI

func (c *Conn) PeerSPKI() []byte

PeerSPKI is the SHA-256 SubjectPublicKeyInfo digest of the certificate this connection's peer presented, or nil if none was seen (a cleartext loopback dial, or a dial that did not go through DialRawSecure).

IT EXISTS FOR EXACTLY ONE COMPARISON (ADR-007 B48). The pairing rendezvous dials UNVERIFIED, because it is the dial that fetches the pin that would verify it, and B45 accepted that on the grounds that the certificate never protected the Noise payload. True of the payload, and B48 amends it: that ruling also lowered the cost of B46's consent harvest from "hold a certificate valid for the operator's relay" to "be on the path". So the certificate is recorded here and compared, when msg2 arrives, against the RelaySPKIPin the real machine authored. A network attacker terminating this TLS cannot make the two agree.

WHAT IT DOES NOT COVER, stated so it is not assumed: a QR-holder is a legitimate party to the ceremony and presents the relay's real certificate. That case is the SAS gate's and the deferred consent's (B52), not this.

func (*Conn) ReadMsg

func (c *Conn) ReadMsg() (MsgType, []byte, error)

ReadMsg receives one raw framed message using the connection's own context.

func (*Conn) RendezvousClaim

func (c *Conn) RendezvousClaim(ctx context.Context, id string) error

RendezvousClaim joins an existing rendezvous as its single second participant.

func (*Conn) RendezvousComplete

func (c *Conn) RendezvousComplete(ctx context.Context, id string) error

RendezvousComplete burns the rendezvous id (single use).

func (*Conn) RendezvousCreate

func (c *Conn) RendezvousCreate(ctx context.Context, id string) error

RendezvousCreate opens a two-party pairing rendezvous keyed by id.

func (*Conn) RendezvousRecv

func (c *Conn) RendezvousRecv(ctx context.Context) ([]byte, error)

RendezvousRecv blocks for the next opaque message from the other participant.

func (*Conn) RendezvousSend

func (c *Conn) RendezvousSend(ctx context.Context, id string, msg []byte) error

RendezvousSend forwards opaque bytes to the other participant.

func (*Conn) WriteMsg

func (c *Conn) WriteMsg(tag MsgType, payload []byte) error

WriteMsg sends one raw framed message using the connection's own context.

type DiagStatus

type DiagStatus struct {
	StoreOK          bool   `json:"store_ok"`
	StoreError       string `json:"store_error,omitempty"`
	DiskCheckEnabled bool   `json:"disk_check_enabled"`
	DiskOK           bool   `json:"disk_ok"`
	DiskError        string `json:"disk_error,omitempty"`
	DiskFreeBytes    uint64 `json:"disk_free_bytes"`
	DiskFreeMinBytes int64  `json:"disk_free_min_bytes"`
}

DiagStatus is the storage-health snapshot the diag_status op returns (R2 review MEDIUM, playbook 6.5's doctor "storage" result): whether the relay's persistence store is writable, and whether free disk is above the configured disk_free_min_bytes alarm. It is the SAME check /readyz reports on the admin listener -- diag_status exists so `swarm relay doctor`, which typically has no admin_listen access, can prove it over the ordinary public wss:// connection instead.

type Item

type Item struct {
	Cursor   uint64 `json:"cursor"`
	Envelope []byte `json:"envelope"`
}

Item is one stored mailbox entry as the relay serves it: the relay's own monotonic storage cursor (untrusted ordering, DISTINCT from the authenticated per-epoch seq inside the envelope) and the opaque ciphertext envelope.

type MailboxDiscardResult added in v0.13.11

type MailboxDiscardResult struct {
	ThroughCursor      uint64 `json:"through_cursor"`
	MailboxIncarnation string `json:"mailbox_incarnation"`
}

MailboxDiscardResult is the exact current mailbox generation and cursor compacted by an explicit self-mailbox discard. A consumer must persist both before requesting replacement state; through_cursor is a relay-owned transport coordinate, never an authenticated content sequence.

type MsgType

type MsgType byte

MsgType is a relay frame tag. It is a distinct tag space from wire.Type.

const (
	// MsgError is a server->client error reply: JSON {code, message}.
	MsgError MsgType = 0x00
	// MsgOK is a server->client success reply: op-specific JSON body.
	MsgOK MsgType = 0x01
	// MsgRelay is a generic control request: JSON {op, ...fields}.
	MsgRelay MsgType = 0x02
	// MsgMailboxAppend is the dedicated tag for the hot mailbox-append path.
	MsgMailboxAppend MsgType = 0x03
	// MsgWaitReply is the server->client reply to a bounded server-side wait
	// (mailbox_wait). It carries its own tag rather than riding MsgOK/MsgError
	// because it is the ONE reply that arrives out of order with respect to the
	// connection's serialised request/reply exchanges: the tag is what lets the
	// client demux it straight to the parked waiter, and the wait_id inside it is
	// what discards the reply to a wait the client has already withdrawn
	// (ADR-007 B7 request-id correlation).
	MsgWaitReply MsgType = 0x04
)

Relay frame tags. Only a subset carries dedicated tags; the remaining control operations ride MsgRelay with a JSON "op" discriminator.

func ReadFrame

func ReadFrame(r io.Reader) (MsgType, []byte, error)

ReadFrame reads one frame from r and returns its tag and payload.

The declared length is validated against MaxFrame BEFORE any body allocation or read (the anti-DoS cap): an oversized length is refused on the 4-byte header alone. A reader exhausted exactly at a frame boundary yields io.EOF; a truncated header or body yields io.ErrUnexpectedEOF. The tag is not validated here — the dispatcher answers an unknown op with a clean r_error.

type Option

type Option func(*Server)

Option configures a Server at construction.

func WithClock

func WithClock(c Clock) Option

WithClock injects the authoritative clock.

func WithDiskFreeFunc

func WithDiskFreeFunc(fn func() (uint64, error)) Option

WithDiskFreeFunc overrides the low-disk check's free-space source (default: a real statfs of DBPath's directory). Tests use this instead of touching the filesystem, the same seam WithClock/WithSourceKeyFunc already establish.

func WithLogWriter

func WithLogWriter(w io.Writer) Option

WithLogWriter directs the relay's (body-free) log output.

func WithOperatorSecret

func WithOperatorSecret(secret []byte) Option

WithOperatorSecret installs the relay operator secret (playbook 6.5) on a Server at construction, the same seam WithPushSink uses for the push transport. A nil/empty secret leaves diagnostic/admin authority disabled -- consumed by the `swarm relay doctor` capability (a separate R2 slice), never by this file, and never logged by anything that touches it.

func WithPushSink

func WithPushSink(p PushSink) Option

WithPushSink injects the push transport (nil = pushes are dropped).

func WithSourceKeyFunc

func WithSourceKeyFunc(fn func(remoteAddr string) string) Option

WithSourceKeyFunc installs the pre-authentication source-key deriver. The relay evaluates it ONCE per accepted connection and uses the result to key every PRE-SIGNATURE rate window — auth_init and the unauthenticated rendezvous ops — instead of any client-presented (and still unproven) relay-auth pubkey (ADR-007 amendment 2026-07-20, remediating R1-H1/H2). A nil fn keeps the default (the IP host of RemoteAddr).

The value passed in is that connection's transport RemoteAddr UNLESS trusted_proxies is configured and the peer matches it (playbook 6.5, R2 "proxy-quota"): then it is the validated client address recovered from X-Forwarded-For instead, which typically carries NO port. A custom fn doing SplitHostPort should not assume a port is always present; see resolveSourceAddr.

type PlatformVerifier

type PlatformVerifier interface {
	// VerifyRelayChain reports whether the presented certificate chain -- PEM-encoded,
	// leaf first -- is trusted for host. A non-nil error refuses the peer.
	VerifyRelayChain(host string, pemChain []byte) error
}

PlatformVerifier is the Go-side shape of ADR-016 W2's reverse-bound RelayTrust seam: Go calls it, a platform (Kotlin's X509TrustManagerExtensions) implements it. mobile's gomobile-bound RelayTrust interface satisfies this structurally, with the SAME method name, so no adapter type is needed at the boundary.

It is asked about CHAIN trust only. Go keeps the hostname and validity checks for itself (VerifyPeerCertificate, below): a verifier that approves everything still cannot admit a certificate whose SAN does not cover the configured host.

type PresenceInfo

type PresenceInfo struct {
	State PresenceState `json:"state"`
}

PresenceInfo is the presence answer for a routing id.

type PresenceState

type PresenceState string

PresenceState is a party's coarse reachability as the relay sees it.

const (
	// PresenceUnknown means the relay has no live record (e.g. after restart —
	// presence is never persisted).
	PresenceUnknown PresenceState = "unknown"
	// PresenceOffline means the gateway dropped and the silent-push bound elapsed.
	PresenceOffline PresenceState = "offline"
	// PresenceOnline means a live authenticated connection is bound.
	PresenceOnline PresenceState = "online"
)

type PushPayload

type PushPayload struct {
	Alert      string
	Ciphertext []byte
}

PushPayload is the outer push the relay hands to the push sink: a generic alert plus the opaque ciphertext envelope. The relay cannot read the ciphertext and never derives the alert from it.

type PushSink

type PushSink interface {
	Push(ctx context.Context, token string, p PushPayload) error
}

PushSink is the push transport. The relay only ever hands it a generic outer alert and ciphertext.

The name is transport-NEUTRAL on purpose (PB-PUSH-1). This seam was called APNsSink through Phase A while no sender existed; the transport that actually implements it is FCM, on Android, and there is no Apple account in the project at all — so the old name was a fact-shaped claim every later reader would have inherited. internal/remote/push.FCM is the implementation.

type Quotas

type Quotas struct {
	// MaxDurableObjects caps caller-controlled durable rows and nested mailbox
	// buckets across the whole relay. It is the public-service Sybil fence: a
	// distributed caller cannot evade it by minting more source addresses or
	// relay identities. A value <= 0 disables the fence.
	MaxDurableObjects int64 `json:"max_durable_objects"`
	// DurableGrowthWritesPerMin caps, gateway-wide, transactions that can add
	// durable state. Cleanup, acknowledgement, token deletion, and revocation
	// are deliberately exempt so a full relay can always shed state. A value
	// <= 0 disables the fence.
	DurableGrowthWritesPerMin int `json:"durable_growth_writes_per_min"`
	// MaxDBBytes refuses durable-growth transactions once the bbolt file has
	// reached this size. Deleting transactions remain available; bbolt reuses
	// freed pages even though its file does not shrink. A value <= 0 disables
	// the fence.
	MaxDBBytes int64 `json:"max_db_bytes"`
	// MaxConcurrentRendezvous caps live pairing rendezvous slots.
	MaxConcurrentRendezvous int `json:"max_concurrent_rendezvous"`
	// MaxConcurrentConnections is the global cap on live websocket connections
	// admitted at once (CR-1 admission control). A value <= 0 means unlimited;
	// the (cap+1)th concurrent connection is cleanly closed, not served.
	MaxConcurrentConnections int `json:"max_concurrent_connections"`
	// MaxConcurrentConnectionsPerSource caps live websocket connections admitted
	// at once from a single transport source (CR-1 slice 2), alongside the global
	// MaxConcurrentConnections cap, so one source cannot monopolize the whole
	// connection pool. A value <= 0 means unlimited.
	MaxConcurrentConnectionsPerSource int `json:"max_concurrent_connections_per_source"`
	// MailboxAppendPerMin caps appends per target routing id per minute.
	MailboxAppendPerMin int `json:"mailbox_append_per_min"`
	// MailboxMaxItems is the per-mailbox depth cap (CR-4): an append that would
	// push a target's live mailbox past this many items is refused with a clean
	// ErrQuotaExceeded before storing, so a device that never drains cannot drive
	// unbounded growth. The cap is on live depth, so capacity recovers on ack. A
	// value <= 0 means no depth cap.
	MailboxMaxItems int `json:"mailbox_max_items"`
	// PushPerMin caps push triggers per target routing id per minute.
	PushPerMin int `json:"push_per_min"`
	// ConnPerMin caps pre-signature authentication attempts (auth_init) per
	// TRANSPORT SOURCE (client IP; the presented, still-unproven relay-auth pubkey
	// is NEVER a rate key). There is no global auth counter (ADR-007 amendment
	// 2026-07-20).
	ConnPerMin int `json:"conn_per_min"`
	// OpsPerMin is the per-source cap applied to every state-touching control op
	// (auth_resp, authorize_device, mailbox_read/ack, token_register/delete,
	// presence, device_revoke, and the rendezvous ops). mailbox_append and
	// push_trigger keep their own dedicated windows above. A value <= 0 means
	// unlimited.
	OpsPerMin int `json:"ops_per_min"`
	// DiskFreeMinBytes is the low-disk alarm threshold (playbook 6.5): /readyz
	// on the admin listener refuses (503) once free space on the DBPath
	// filesystem falls below this many bytes, and the relay logs one bounded
	// warning per transition into the low state (never once per poll). A value
	// <= 0 disables the check.
	DiskFreeMinBytes int64 `json:"disk_free_min_bytes"`
}

Quotas are the day-one abuse controls (R-REL.8). Every window is evaluated on the injected clock. Defaults are generous; an operator tightens them per deployment.

type ReconnectBackoff

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

ReconnectBackoff tracks consecutive failed dial attempts across one reconnect generation and computes each retry delay.

RESET IS THE CALLER'S DECISION, AND IT IS NOT "the dial succeeded". A relay that completes the websocket handshake and then answers nothing keeps the socket up while every call reaches its deadline, so a backoff reset by connection alone turns that into a fixed-rate redial cycle the adversary drives for free. Reset on evidence of PROGRESS -- traffic that actually crossed the link -- which is why this type has no opinion about when that happened.

func NewReconnectBackoff

func NewReconnectBackoff() *ReconnectBackoff

NewReconnectBackoff returns a backoff at its initial delay, jittered from the default random source.

func (*ReconnectBackoff) Next

func (b *ReconnectBackoff) Next() time.Duration

Next returns the delay before the next dial attempt and advances the backoff state.

func (*ReconnectBackoff) Reset

func (b *ReconnectBackoff) Reset()

Reset returns the backoff to its initial state, as if no attempt had yet failed.

type Security

type Security struct {
	// PinnedCert is a DER-encoded certificate that the relay must present. It is
	// an explicit, per-connection opt-in for a self-hosted relay with a
	// self-signed certificate: pinning is a whitelist of exactly one certificate,
	// never a global relaxation of verification.
	//
	// IT DOES NOT SURVIVE A CERTIFICATE RENEWAL, and on Android that is fatal
	// rather than inconvenient: TrustRootSourceFor makes the handset pinning-only,
	// so there is no root-store path to fall back to. A reissue changes the leaf
	// DER even when the key is unchanged, so a relay renewing on the Let's Encrypt
	// cadence takes the handset offline every 60-90 days. Prefer
	// PinnedSPKISHA256 unless the certificate is long-lived and self-signed.
	PinnedCert []byte
	// PinnedSPKISHA256 is SHA-256 over the presented certificate's
	// SubjectPublicKeyInfo -- the renewal-safe pin (PB-OPS-5), and the value
	//
	//	openssl x509 -in relay.crt -pubkey -noout |
	//	  openssl pkey -pubin -outform der | openssl dgst -sha256 -binary
	//
	// produces. A reissue that REUSES THE KEY presents the same SPKI, so the pin
	// keeps matching across renewals at the same security level: the digest still
	// admits exactly one public key, and an impostor holding a different key is
	// refused exactly as it is under PinnedCert.
	//
	// KEY REUSE IS PART OF THE PIN, not an implementation detail of the operator's
	// ACME client. certbot rotates the keypair on every renewal unless it is told
	// not to (--reuse-key, or reuse_key = True in the renewal config), and a
	// rotated key is a new SPKI, which breaks this pin on exactly the cadence it
	// was adopted to survive. docs/operations/relay-runbook.md carries the step;
	// TestPBOPS5_SPKIPinIsBrokenByARenewalThatROTATESTheKey carries the proof.
	//
	// Either pin alone satisfies a TrustRootsPinned platform; both may be set, in
	// which case either matching admits the peer.
	PinnedSPKISHA256 []byte
	// AllowLoopbackCleartext admits a ws:// relay whose host is a loopback IP
	// literal. It exists because the relay server is ws://-only today, so an
	// unconditional cleartext ban makes the in-process integration tests
	// unsatisfiable. It is honoured ONLY inside a test binary: in a release build
	// no URL reaches cleartext through this policy, whatever this field says --
	// neither the URL the caller supplies nor any URL a redirect points at.
	AllowLoopbackCleartext bool
	// contains filtered or unexported fields
}

Security is the per-connection transport-security policy for DialSecure. The zero value is the default policy: TLS verified against the platform's stated trust-root source, cleartext refused.

func MachineSecurity

func MachineSecurity() Security

MachineSecurity is the transport policy every machine-side dial takes: the gateway sidecar, the CLI's short-lived owner connection, and the daemon's pairing rendezvous.

It is Security's default policy -- TLS verified against the platform trust roots, cleartext refused, the decision re-asked on every redirect hop -- with ONE exception: a ws:// URL whose host is a loopback IP LITERAL is admitted, in a release build as well as a test binary.

WHY THE EXCEPTION IS SAFE, and why it is not the flag ADR-007 B37 forbids. B37's chain begins with a PASSIVE ON-PATH OBSERVER of a cleartext hop; a connection to 127.0.0.1 never leaves the host, so there is no path to sit on. The exception cannot be widened into one either: it is decided from the URL, a NAME is never accepted (isLoopbackLiteral parses an IP and does not resolve), and the same decision is re-run on every redirect, so a loopback relay cannot answer the upgrade with "302 -> ws://elsewhere". An operator who set this on a real deployment would gain the ability to speak cleartext to their own machine and nothing else.

WHY IT HAS TO EXIST. The relay server is ws://-only (server.go sets "ws://"+addr), the gateway sidecar is a release binary, and the S19 exit demonstration builds and spawns that binary against a real ws://127.0.0.1 relay. Without the exception, local development and the exit demonstration both require a TLS terminator and a pin that has no channel yet -- and the alternative, an environment variable a deployment could set, would be exactly the general kill switch B37 rules out.

func PairingSecurity

func PairingSecurity() Security

PairingSecurity is the policy the HANDSET's pairing rendezvous dials under, and the only place in the product that accepts an unverified certificate (ADR-007 B45).

WHY IT HAS TO EXIST. The relay pin reaches a phone through pairing.MachinePayload, so the pairing dial is the dial that FETCHES the pin and cannot itself be pinned. On a pinning-only platform -- every Android handset -- an unpinned wss:// dial is not merely unverified, it is REFUSED with ErrPinRequired before a packet. Under the default policy the pairing dial is therefore refused, the pin never arrives, and the phone can never pair over wss:// at all. That deadlock is B45.

WHY IT IS SOUND. The relay's certificate never protected this exchange in the first place. The payload is a Noise handshake whose peer the operator authenticates by comparing a six-symbol SAS against the machine's own screen, and nothing is pinned until that comparison is confirmed. TLS on this hop buys metadata confidentiality, not content authenticity.

WHAT IT COSTS, named rather than buried: a hostile terminator on this one hop learns which routing ids are pairing and when. It learns nothing it could use -- the handshake is sealed against it and a substituted payload fails the SAS -- but that metadata is precisely what PB-NET-2's cleartext ban protects, and this policy gives it up for one dial.

WHAT IT DOES NOT RELAX. Cleartext is refused exactly as everywhere else, decided from the URL before any socket: a ws:// pairing URL is still refused outside the loopback carve-out. And a Security carrying a pin ignores this flag entirely, so it can never downgrade a dial that had something better to check.

func WithPlatformVerifier

func WithPlatformVerifier(sec Security, v PlatformVerifier) Security

WithPlatformVerifier installs v as sec's ADR-016 W2 platform trust delegate and selects TrustRootsPlatformDelegate as its trust-root source.

UNLIKE WithTrustRootSource, THIS IS A PRODUCTION CONSTRUCTOR: it is not gated by testing.Testing(), because it is how Android wires a real RelayTrust implementation into a release build, and gating it the same way WithTrustRootSource is gated would make that wiring impossible outside a test binary. That is deliberate and named in the Blast radius: "WithPlatformVerifier is a different function precisely so that fence is not loosened to make room for a production caller" -- WithTrustRootSource's own test-only fence is untouched.

v may be nil: Kotlin never registered a verifier, or registration failed. The delegate policy is still selected, and tlsConfig then refuses with ErrPinRequired -- absence fails closed, exactly like TrustRootsPinned with no pin, and never falls back to Go's own (wrong, on Android) system pool.

func WithTrustRootSource

func WithTrustRootSource(sec Security, src TrustRootSource) Security

WithTrustRootSource returns sec with its trust-root source overridden.

IT IS HONOURED ONLY INSIDE A TEST BINARY, and the field it sets is unexported, so a release build cannot select a trust-root source it is not on however this is called. That is the same shape as AllowLoopbackCleartext and for the same reason.

It exists because the pinning-only refusal had never been executed. TrustRootSourceFor is a pure function of GOOS and tlsConfig consults runtime.GOOS, so on the desktop the whole suite runs on, the ErrPinRequired arm is unreachable -- the only test naming that error asserts its message text. A fail-closed branch that has never run is the defect class this phase exists to find, so the branch is made reachable rather than reasoned about.

func (Security) Resolve

func (s Security) Resolve(rawURL string) (*tls.Config, error)

Resolve is the exported form of resolve: it decides whether rawURL may be dialed under this policy and, if so, with what TLS configuration (nil means plain ws://). It exists so a diagnostic caller (`swarm relay doctor`, playbook 4.1/6.5) can build and report the EXACT policy a real dial would apply -- name, pin state, cleartext refusal -- without duplicating this type's decisions in cmd/swarm.

type Server

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

Server is the untrusted relay: a websocket listener over a bbolt store. It forwards ciphertext and routing metadata and holds no plaintext or identity keys.

func New

func New(cfg Config, opts ...Option) (*Server, error)

New constructs a relay over cfg.DBPath. It opens the persistence store; call Start to bind the listener.

func (*Server) AdminURL

func (s *Server) AdminURL() string

AdminURL is the bound admin listener's http:// base, or "" if admin_listen was empty (admin serving disabled).

func (*Server) Close

func (s *Server) Close() error

Close severs every connection, stops the listener, and closes the store. It is idempotent.

func (*Server) MailboxDepth

func (s *Server) MailboxDepth(rid string) int

MailboxDepth reports how many items a routing id's mailbox holds (test/ops visibility; revocation asserts it drops to zero).

func (*Server) Start

func (s *Server) Start(ctx context.Context) error

Start binds the listener and begins serving. The relay lives until Close (or ctx cancellation).

func (*Server) SweepPresence

func (s *Server) SweepPresence(ctx context.Context)

SweepPresence transitions machines that dropped past PresenceTimeout to offline and fires exactly one silent push per transition toward each paired device that has a registered token (R-REL.3).

func (*Server) SweepRendezvous

func (s *Server) SweepRendezvous(_ context.Context)

SweepRendezvous expires aged rendezvous slots and collects burn windows that have closed, on the maintenance tick (round-4 threat review C2).

It exists because purgeExpiredRendezvous ran in exactly one place: inside handleRendezvousCreate. A table filled by connections that then went quiet was therefore reclaimed only when some stranger happened to call create — which is precisely the call a full table refuses, so nothing reclaimed it at all and no phone on that relay could pair. Every other retention rule the relay has (presence, mailbox) is already on this tick; this one was the exception.

func (*Server) SweepRetention

func (s *Server) SweepRetention(_ context.Context)

SweepRetention purges mailbox items older than the retention cap even if never acked (R-REL.10).

func (*Server) URL

func (s *Server) URL() string

URL is the relay's ws:// endpoint (plain ws is intentional — E2EE does not depend on TLS).

type TrustRootSource

type TrustRootSource string

TrustRootSource names where a platform's relay TLS verification gets its roots. It is STATED per platform rather than left to x509.SystemCertPool, because that pool is not the desktop pool on every platform we ship to (opus H3), and PB-SEC-5 establishes that Android's networkSecurityConfig does not govern crypto/tls inside a native .so -- this is the sole control for the relay transport there.

const (
	// TrustRootsSystem verifies against the platform root store.
	TrustRootsSystem TrustRootSource = "system"
	// TrustRootsEmbedded verifies against a CA bundle compiled into the binary.
	TrustRootsEmbedded TrustRootSource = "embedded"
	// TrustRootsPinned does not verify against any root store: the caller must
	// supply Security.PinnedSPKISHA256 or Security.PinnedCert, or the dial is
	// refused.
	TrustRootsPinned TrustRootSource = "pinned"
	// TrustRootsPlatformDelegate delegates the CHAIN trust decision to a platform verifier
	// installed by WithPlatformVerifier (ADR-016 W2: Android's X509TrustManagerExtensions,
	// over the reverse-bound RelayTrust seam). Go still enforces hostname and validity
	// itself, on the leaf, independent of the delegate's answer -- "neither half alone
	// admits a peer". No verifier installed selects this source anyway and then refuses
	// with ErrPinRequired: absence fails closed, exactly like TrustRootsPinned with no pin,
	// and never falls back to Go's own (wrong, on Android) system pool.
	TrustRootsPlatformDelegate TrustRootSource = "platform-delegate"
)

func TrustRootSourceFor

func TrustRootSourceFor(goos string) TrustRootSource

TrustRootSourceFor returns the trust-root source for a GOOS.

Android is TrustRootsPinned. Not because Go ignores the platform store -- it does read it: GOOS=android implies the linux build tag, and crypto/x509/root_linux.go lists /system/etc/security/cacerts. The problem is that the store it reads is the wrong one and an incomplete one:

  • Android 14 moved the system CA store into the Conscrypt APEX (/apex/com.android.conscrypt/cacerts), which is not in Go's search path, so the pool is stale or empty on a modern handset;
  • user-installed and enterprise CAs are never picked up at all.

Shipping an embedded CA bundle instead means shipping a trust store that rots between releases. Refusing to verify against any root store -- and requiring the operator's certificate to be pinned -- is the unambiguous answer, so EmbeddedTrustRoots is deliberately empty.

Jump to

Keyboard shortcuts

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