Documentation
¶
Overview ¶
Package relay owns transport security, relay error identities, and reconnect policy shared by the current relay-v2 clients.
Index ¶
Constants ¶
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.
Variables ¶
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") // 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") )
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.
Functions ¶
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 ReconnectBackoffBase ¶
ReconnectBackoffBase returns the un-jittered delay before dial attempt n, 1-based: ReconnectInitialDelay doubling on every failed attempt, never exceeding ReconnectCeiling.
Types ¶
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 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 resolved by relay-v2 clients. 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 local Workerd test endpoint is ws://, while the gateway sidecar is a release binary. Without the exception, local development and the native relay-v2 integration gates would require a TLS terminator and a pin solely for a same-host hop. An environment variable a deployment could set would instead be 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 ¶
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 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.