control

package
v0.19.0 Latest Latest
Warning

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

Go to latest
Published: Jul 21, 2026 License: Apache-2.0 Imports: 24 Imported by: 0

Documentation

Overview

Package control implements ICX's key-establishment control plane (a QUIC/mTLS channel) and the PSP-model key derivation that turns an authenticated, forward-secret session into per-Security-Association AEAD keys for the existing Geneve/AF_XDP data plane.

The cryptographic primitives (SP 800-108/AES-CMAC KDF and the SPI bit layout) live in the stdlib-only leaf package psp, shared with the data-plane handler; this package re-exports them so its API is unchanged.

Index

Constants

View Source
const (
	// AESGCM128 selects AES-GCM-128: a 16-byte SA key. The ICX default.
	AESGCM128 = psp.AESGCM128
	// AESGCM256 selects AES-GCM-256: a 32-byte SA key. The CNSA / 256-bit path.
	AESGCM256 = psp.AESGCM256
)
View Source
const (
	Initiator = psp.Initiator
	Responder = psp.Responder
)
View Source
const ALPN = "icx-ctrl/1"

ALPN is the application-layer protocol name negotiated on the ICX control channel. A mismatch (e.g. a stray TLS client) fails the handshake.

View Source
const MasterKeyLen = psp.MasterKeyLen

MasterKeyLen is the required length of a PSP master key (256 bits).

View Source
const MaxVNI = 1<<24 - 1

MaxVNI is the top of the 24-bit Geneve VNI space. VNI 0 is reserved.

View Source
const (
	// ProtocolVersion is the control-plane wire-protocol version.
	ProtocolVersion = 1
)
View Source
const RootSecretLen = 32

RootSecretLen is the length of the exported master-key seed (256-bit).

Variables

View Source
var ErrGrantRejected = errors.New("control: key request rejected by responder")

ErrGrantRejected is returned by RequestKeys/ReleaseKeys when the responder refused the request. The responder deliberately does not say why (authorization detail stays server-side).

View Source
var ErrSPIExhausted = errors.New("control: SPI counter space exhausted; master-key rotation required")

ErrSPIExhausted is returned by Allocate when the 2^30 counter space for a master-key index is used up. It is a TERMINAL condition: the only remedy is master-key rotation, which this build does not yet support (the active master-key index is fixed at 0). Callers treat it as a non-retryable, fail-closed error rather than looping a reconnect.

View Source
var ErrVNIExhausted = errors.New("control: VNI space exhausted")

ErrVNIExhausted is returned by Allocate when every VNI is live or quarantined. Unlike SPI exhaustion it is transient: releases and quarantine expiry free the space again.

Functions

func CanonicalInitiator

func CanonicalInitiator(localPub, peerPub *ecdsa.PublicKey) (bool, error)

CanonicalInitiator reports whether the local node is the control-plane initiator — the peer that dials. The role is elected deterministically from the two pinned identities so both ends agree with zero configuration (WireGuard-style): the node whose SubjectPublicKeyInfo DER sorts lower is the initiator, the other listens. Identical keys are rejected — a node must not tunnel to itself, and equal keys would make both ends pick the same role (double-dial / double-listen deadlock).

func ClientTLSConfig

func ClientTLSConfig(local *Identity, peerPub *ecdsa.PublicKey) (*tls.Config, error)

ClientTLSConfig builds the initiator side of the control-plane mTLS.

func DeriveSAKey

func DeriveSAKey(masterKey []byte, spi uint32, v ICXVersion) ([]byte, error)

DeriveSAKey derives a PSP security-association key from a 256-bit master key and a 32-bit SPI per the PSP Architecture Specification. See psp.DeriveSAKey.

func ExportRootSecret

func ExportRootSecret(cs tls.ConnectionState) ([]byte, error)

ExportRootSecret derives the 32-byte data-plane master-key seed from a completed TLS 1.3 handshake via the RFC 8446 exporter. Both peers compute the identical value; it is the forward-secret root the PSP master keys are seeded from (see keys.go). It must only be called after the handshake completes.

func MakeSPI

func MakeSPI(masterKeyIndex int, role Role, counter uint32) (uint32, error)

MakeSPI composes an SPI from the active master-key index, the allocating role and a per-(index,role) counter. See psp.MakeSPI for the bit layout.

func MarshalPublicKey

func MarshalPublicKey(pub *ecdsa.PublicKey) (string, error)

MarshalPublicKey encodes a public key as base64(SPKI DER).

func MasterKeyIndex

func MasterKeyIndex(spi uint32) int

MasterKeyIndex returns which master key (0 or 1) an SPI selects: per PSP, the most-significant bit of the SPI. See psp.MasterKeyIndex.

func ParsePublicKey

func ParsePublicKey(s string) (*ecdsa.PublicKey, error)

ParsePublicKey decodes a base64(SPKI DER) public key (the --peer-key value) and verifies it is ECDSA P-256.

func PublicKeyEqual

func PublicKeyEqual(a, b *ecdsa.PublicKey) bool

PublicKeyEqual reports whether two ECDSA public keys are identical.

func ReservedSPI

func ReservedSPI(spi uint32) bool

ReservedSPI reports whether an SPI's low 31 bits are zero, which the PSP spec reserves and the allocator never issues. See psp.ReservedSPI.

func ServerTLSConfig

func ServerTLSConfig(local *Identity, peerPub *ecdsa.PublicKey) (*tls.Config, error)

ServerTLSConfig builds the responder side of the control-plane mTLS: it requires (and pins) a client certificate.

func ServerTLSConfigAuth

func ServerTLSConfigAuth(local *Identity, authorize PeerAuthorizer) (*tls.Config, error)

ServerTLSConfigAuth builds a multi-peer responder mTLS config: any client whose identity key passes authorize may complete the handshake. This is the key-plane trust model (one responder, many authorized initiators), vs ServerTLSConfig's 1:1 pinned tunnel.

Types

type DirectionalSAs

type DirectionalSAs struct {
	Master  [MasterKeyLen]byte
	RxSPI   uint32
	TxSPI   uint32
	Version ICXVersion
}

DirectionalSAs is a peer's pair of simplex SAs for one session generation, expressed as derivation inputs rather than finished keys: the data-plane handler derives each direction's AEAD key itself from the master key and the SPI (handler.UpdateVirtualNetworkSecret), so no key material leaves the control plane. TxSPI is what we encrypt outbound to (the peer's RX SPI), RxSPI is what we decrypt inbound under (our own RX SPI); Master is the session master key both derive from.

type ICXVersion

type ICXVersion = psp.ICXVersion

ICXVersion is an AEAD cipher-suite codepoint for an SA. See psp.ICXVersion.

type Identity

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

Identity is a node's long-term signing key used to mutually authenticate the QUIC/mTLS control channel. It is an ECDSA P-256 key: a FIPS 186-approved signature algorithm in the Go FIPS 140-3 module, and a curve TLS 1.3 will use in FIPS mode. Peers authenticate each other WireGuard-style — by pinning the expected public key — rather than via a CA, so identities are self-signed.

Note this signing key is distinct from the ephemeral ECDHE that TLS performs for forward secrecy; the identity only proves "who", the handshake provides the fresh per-session secret.

func GenerateIdentity

func GenerateIdentity() (*Identity, error)

GenerateIdentity creates a fresh ECDSA P-256 identity using crypto/rand.

func LoadIdentityPEM

func LoadIdentityPEM(pemBytes []byte) (*Identity, error)

LoadIdentityPEM parses a PKCS#8 PEM private key produced by MarshalPrivatePEM. It rejects anything that is not an ECDSA P-256 key.

func (*Identity) Fingerprint

func (id *Identity) Fingerprint() (string, error)

Fingerprint returns a short, stable identifier for the public key: base64(SHA-256(SPKI DER)). Used as the certificate subject and in logs.

func (*Identity) MarshalPrivatePEM

func (id *Identity) MarshalPrivatePEM() ([]byte, error)

MarshalPrivatePEM encodes the identity private key as a PKCS#8 PEM block, suitable for writing to a 0600 key file.

func (*Identity) PublicKey

func (id *Identity) PublicKey() *ecdsa.PublicKey

PublicKey returns the identity's public key.

func (*Identity) PublicKeyString

func (id *Identity) PublicKeyString() (string, error)

PublicKeyString returns the base64(SPKI DER) encoding of the public key. This is the value distributed to peers and supplied via --peer-key (analogous to a WireGuard public key).

func (*Identity) TLSCertificate

func (id *Identity) TLSCertificate() (tls.Certificate, error)

TLSCertificate builds a self-signed leaf certificate for this identity, for use as the local end of the mTLS handshake. Authentication is by key pinning, not by chain validation, so the certificate is its own issuer.

type KeyGrant

type KeyGrant struct {
	VNI uint32
	SAs *DirectionalSAs
}

KeyGrant is the initiator's result of one per-network key exchange: the VNI the responder allocated and the per-direction SAs, derived locally (key material never crossed the wire).

type KeyGranter

type KeyGranter interface {
	Grant(peer *ecdsa.PublicKey, addr netip.Addr, sas *DirectionalSAs) (vni uint32, err error)
	Release(peer *ecdsa.PublicKey, vni uint32) error
}

KeyGranter is the responder's policy seam. Grant must atomically allocate a VNI for addr and install the responder-side SAs (sas.RxSPI decrypts traffic arriving FROM the peer's network, sas.TxSPI encrypts traffic sent back TO it; the handler derives both keys from sas.Master) before returning; a returned error rejects the request and installs nothing. Release must uninstall the VNI's SAs and start its quarantine, and should be idempotent — an explicit release and the session-teardown sweep can each target the same VNI under crash/disconnect races. Both must be safe for concurrent use across sessions; returning ErrVNIExhausted from Grant maps to a typed rejection on the wire, any other error to a generic one (the error text never reaches the peer).

type Listener

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

Listener accepts inbound control sessions on a UDP socket. The underlying quic.Transport performs Retry-based source-address validation and enforces QUIC's 3x anti-amplification limit, which is the handshake-flood defense.

func Listen

func Listen(pconn net.PacketConn, local *Identity, peerPub *ecdsa.PublicKey) (*Listener, error)

Listen returns a control-plane listener on pconn that authenticates as local and pins peerPub.

func ListenPeers

func ListenPeers(pconn net.PacketConn, local *Identity, authorize PeerAuthorizer) (*Listener, error)

ListenPeers returns a multi-peer control-plane listener: any peer whose identity key passes authorize may establish a session. This is the key-plane trust model (one responder, many authorized initiators — see ServeKeyPlane); the symmetric 1:1 tunnel keeps using Listen. The QUIC config allows a deeper incoming-stream window than the 1:1 tunnel because each sandbox start opens a short-lived exchange stream and starts arrive in bursts.

func (*Listener) Accept

func (l *Listener) Accept(ctx context.Context) (*Session, error)

Accept blocks until a peer completes the mTLS handshake, then returns the established session (responder role).

func (*Listener) Addr

func (l *Listener) Addr() net.Addr

Addr returns the local address the listener is bound to.

func (*Listener) Close

func (l *Listener) Close() error

Close tears down the listener and its transport.

type MasterKeys

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

MasterKeys holds the two 256-bit PSP master keys. They are seeded from the forward-secret TLS exporter (see ExportRootSecret) and live only in RAM; they are never persisted, so a recorded session cannot be decrypted once they are dropped — this is where the forward secrecy reaches the data plane.

func DeriveMasterKeys

func DeriveMasterKeys(rootSecret []byte) (*MasterKeys, error)

DeriveMasterKeys expands the TLS-exported root secret into the two PSP master keys via HKDF-SHA-256 (FIPS SP 800-56C). Both peers feed the identical root secret and therefore derive the identical master keys, so each can compute any SA key locally from its SPI — no key material ever crosses the wire.

func (*MasterKeys) DeriveSA

func (m *MasterKeys) DeriveSA(spi uint32, v ICXVersion) (*SA, error)

DeriveSA derives the SA key for spi using the master key its MSB selects.

type PeerAuthorizer

type PeerAuthorizer func(peerPub *ecdsa.PublicKey) error

PeerAuthorizer authenticates a connecting peer by its identity public key. It runs inside the TLS handshake (VerifyConnection); a non-nil error fails the handshake before any session state exists. Implementations must be safe for concurrent use — a multi-peer listener runs one handshake per inbound peer.

func PinnedPeer

func PinnedPeer(peerPub *ecdsa.PublicKey) PeerAuthorizer

PinnedPeer returns a PeerAuthorizer that accepts exactly one peer key — the WireGuard-style 1:1 trust model used by the symmetric tunnel.

type Role

type Role = psp.Role

Role identifies which peer allocated an SPI; the SPI space is partitioned by role so the two directions always derive distinct keys. See psp.Role.

func RoleOf

func RoleOf(spi uint32) Role

RoleOf reports which role allocated an SPI, per the role bit (bit30). See psp.RoleOf.

type SA

type SA struct {
	SPI     uint32
	Key     []byte
	Version ICXVersion
}

SA is a unidirectional PSP security association: an SPI, the derived AES-GCM key, and the cipher suite (which fixes the key length / cipher).

type SAInstaller

type SAInstaller func(master [MasterKeyLen]byte, rxSPI, txSPI uint32) error

SAInstaller installs a negotiated SA generation into the data plane. master is the session master key both directions derive from; rxSPI is our receive SPI (we decrypt inbound frames under it); txSPI is the peer's receive SPI (we encrypt outbound frames to it). The installer hands these to the handler, which derives the per-direction AES-GCM keys itself (UpdateVirtualNetworkSecret) — no key material crosses this boundary. The installer owns the key lifetime/expiry; the handler enforces the fail-closed guards (reserved SPIs, distinct SPIs, TX SPI monotonicity under an unchanged master). A returned error is treated as a rejected rotation, not a session failure.

type SPIAllocator

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

SPIAllocator hands out monotonically increasing, collision-free SPIs for one peer's role. SPIs are never reused within a master-key generation (PSP requirement); exhaustion of the 2^30 counter space forces a master-key rotation.

func NewSPIAllocator

func NewSPIAllocator(role Role) *SPIAllocator

NewSPIAllocator returns an allocator for the given role.

func (*SPIAllocator) Allocate

func (a *SPIAllocator) Allocate(masterKeyIndex int) (uint32, error)

Allocate returns the next SPI for the active master-key index.

type Session

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

Session is an established control-plane connection: an authenticated, forward-secret QUIC/mTLS channel plus the PSP master keys derived from its TLS exporter. From a Session, peers negotiate the per-direction SAs whose keys feed the Geneve/AF_XDP data plane.

func Dial

func Dial(ctx context.Context, pconn net.PacketConn, peerAddr net.Addr, local *Identity, peerPub *ecdsa.PublicKey) (*Session, error)

Dial establishes the initiator side of a control session to peerAddr over the already-bound UDP socket pconn, authenticating as local and pinning peerPub.

func (*Session) Close

func (s *Session) Close() error

Close cleanly shuts the session down.

It deliberately does NOT zero s.masterKeys: the master keys can still be read by an in-flight DeriveSA on another goroutine (e.g. the multi-peer key plane), so wiping them here would be a use-after-clear race for a best-effort hygiene gain. The higher-value wipe — the exported root secret that seeds every master/SA key — is done at derivation time in newSession, where its lifetime is provably local.

func (*Session) Context

func (s *Session) Context() context.Context

Context returns a context that is cancelled when the underlying QUIC connection closes (peer close, idle timeout, or transport error). RunTunnel selects on it to detect session loss promptly rather than waiting for the next rekey tick.

func (*Session) MasterKeys

func (s *Session) MasterKeys() *MasterKeys

MasterKeys returns the PSP master keys derived from this session.

func (*Session) NegotiateSAs

func (s *Session) NegotiateSAs(ctx context.Context, v ICXVersion) (*DirectionalSAs, error)

NegotiateSAs runs the SA-setup exchange over a fresh QUIC stream and returns the tx/rx SAs for cipher suite v. Each peer allocates and announces its own RX SPI; both then derive every key locally from the shared master keys. The initiator writes first, the responder replies, so there is no deadlock.

This round-trip is also the mutual key-confirmation: in TLS 1.3 mutual auth the initiator's handshake completes before the responder verifies the initiator's certificate, so a successful Dial does NOT prove the peer accepted us. A peer that fails to pin us tears the connection down, which makes this exchange fail. Callers MUST therefore treat a successful NegotiateSAs — not a successful Dial/Accept — as the precondition for installing keys (fail-closed).

Concurrency: NOT safe for unmatched concurrent calls on one Session. It pairs one initiator OpenStreamSync with one responder AcceptStream, so call it sequentially, or have both peers issue the same number of concurrent calls (≤ MaxIncomingStreams); a surplus initiator call blocks until a matching responder call or the ctx deadline.

func (*Session) PeerPublicKey

func (s *Session) PeerPublicKey() (*ecdsa.PublicKey, error)

PeerPublicKey returns the peer's authenticated identity key — the leaf certificate's ECDSA key, already verified by the handshake's authorizer. On a multi-peer responder this is how a session maps back to which initiator it belongs to.

func (*Session) ReleaseKeys

func (s *Session) ReleaseKeys(ctx context.Context, vni uint32) error

ReleaseKeys tells the responder the VNI's network is gone, starting its quarantine. The grant's SAs must not be used after this returns.

func (*Session) RequestKeys

func (s *Session) RequestKeys(ctx context.Context, v ICXVersion, addr netip.Addr) (*KeyGrant, error)

RequestKeys runs one key-plane exchange on an initiator session: it asks the responder to key addr, and returns the granted VNI plus the derived per-direction SAs. Each call allocates a fresh SPI, so concurrent calls on one session are safe and every grant has distinct keys. On ErrVNIExhausted/ErrSPIExhausted the session is useless for new grants; existing grants keep working until released or rotated.

func (*Session) Role

func (s *Session) Role() Role

Role reports whether this peer is the initiator or responder.

func (*Session) ServeKeyPlane

func (s *Session) ServeKeyPlane(ctx context.Context, granter KeyGranter) error

ServeKeyPlane serves key-plane exchanges on a responder session until ctx is cancelled or the session dies. On return it releases every VNI still granted on this session — for a peer that crashed without releasing, this is what starts the quarantine clock. Returns nil on ctx cancellation, the session error otherwise.

func (*Session) TLSState

func (s *Session) TLSState() tls.ConnectionState

TLSState returns the negotiated TLS connection state (version, cipher suite, peer certificate). Useful for logging and for asserting the FIPS suite.

type Tunnel

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

Tunnel runs the control-plane lifecycle for one peer: it establishes the QUIC/mTLS session, performs the initial SA negotiation and install (fail-closed) in Bringup, and then keeps the SAs fresh in Run — the initiator drives rekeys on a timer, the responder serves them from an accept loop. Bringup then Run are called once each, in that order, on a single goroutine; only Close may be called concurrently (e.g. from a shutdown path), and it interrupts an in-flight Bringup/Run.

func NewTunnel

func NewTunnel(cfg TunnelConfig, install SAInstaller) (*Tunnel, error)

NewTunnel validates the config, elects the canonical role, and returns a Tunnel ready for Bringup. It does no I/O.

func (*Tunnel) Bringup

func (t *Tunnel) Bringup(ctx context.Context) (err error)

Bringup establishes the session and performs the first SA negotiation and install. It is synchronous and FAIL-CLOSED: it returns an error (and installs nothing) if the handshake, negotiation, or install fails, so the caller must not start the data plane until Bringup succeeds.

func (*Tunnel) Close

func (t *Tunnel) Close() error

Close releases the session and (responder) the listener. It is idempotent and safe to call concurrently with an in-flight Bringup/Run, which it interrupts.

func (*Tunnel) Initiator

func (t *Tunnel) Initiator() bool

Initiator reports the elected role (true = this node dials).

func (*Tunnel) Run

func (t *Tunnel) Run(ctx context.Context) error

Run keeps the SAs fresh until ctx is cancelled. The initiator rekeys on its timer (and reacts promptly to session loss via the QUIC connection context); the responder serves rekeys from a blocking accept loop. A failed negotiation is session-fatal: the session is torn down and re-established (fresh, aligned allocators) rather than retried on a dead session. Control-plane failures are NOT returned: they drive reconnect-with-backoff indefinitely, so Run effectively returns only when ctx is cancelled (clean shutdown). If the control plane cannot re-establish, the data plane fails closed when the installed keys expire — Run does not proactively tear it down. Bringup must have succeeded first.

type TunnelConfig

type TunnelConfig struct {
	// Local is this node's long-term identity (its private key).
	Local *Identity
	// PeerPub is the pinned public key of the single expected peer.
	PeerPub *ecdsa.PublicKey
	// Conn is the bound control-plane UDP socket (separate from the Geneve data port).
	Conn net.PacketConn
	// PeerAddr is the peer's control-plane address (peer IP + control port).
	PeerAddr net.Addr
	// RekeyInterval is how often the initiator negotiates a fresh SA generation.
	RekeyInterval time.Duration
}

TunnelConfig is the immutable configuration for a Tunnel.

type VNIAllocator

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

VNIAllocator hands out VNIs from the 24-bit Geneve space with a quarantine window on release: a released VNI cannot be re-minted until the grace period elapses. This closes the slot-reuse race — return frames sealed under a dead network's still-unexpired SA must never demux to a successor holding the same VNI — so grace must cover the released SA's remaining lifetime. All methods are safe for concurrent use.

func NewVNIAllocator

func NewVNIAllocator(grace time.Duration) *VNIAllocator

NewVNIAllocator returns an allocator whose released VNIs stay unmintable for grace.

func (*VNIAllocator) Allocate

func (a *VNIAllocator) Allocate() (uint32, error)

Allocate returns a free VNI in [1, MaxVNI]. It scans forward from the last allocation (wrapping), skipping live and quarantined VNIs, so beyond the hard quarantine a VNI is also not reused until the rest of the space has cycled.

func (*VNIAllocator) Live

func (a *VNIAllocator) Live() int

Live reports the number of currently allocated VNIs.

func (*VNIAllocator) Release

func (a *VNIAllocator) Release(vni uint32)

Release moves a live VNI into quarantine, (re)starting its grace window. Releasing an unknown VNI still quarantines it (idempotent under the crash-cleanup/explicit-release overlap, and conservative: a double release extends the window rather than shortening it).

Jump to

Keyboard shortcuts

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