handshake

package
v0.8.1 Latest Latest
Warning

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

Go to latest
Published: Jun 6, 2026 License: BSD-3-Clause Imports: 17 Imported by: 0

Documentation

Overview

Package handshake implements SPEC-ZAP-PQ-v1: the native post-quantum handshake and AEAD framing for ZAP. See docs/SPEC-ZAP-PQ-v1.md for the authoritative wire specification.

The package decomposes the protocol along value/behaviour lines:

  • Identity, Profile, SuiteID : values (data, no behaviour)
  • Transcript, SessionKeys : derived values
  • Frame{Hello, KEMInit, ...} : wire codecs
  • Initiator, Responder : state machines
  • Session : the post-handshake AEAD stream
  • ReplayCache, PSKStore : independent storage policies

Nothing in this package imports net.Conn — it works against any io.ReadWriter so it can be exercised over in-memory pipes in tests.

Index

Constants

View Source
const (
	RekeyReasonCounterLimit uint8 = 0x01
	RekeyReasonTimeLimit    uint8 = 0x02
	RekeyReasonBytesLimit   uint8 = 0x03
	RekeyReasonExplicit     uint8 = 0x04
)
View Source
const (
	MagicLen      = 4
	ClientRandLen = 16
	IDLen         = 32 // SHA3-256 output, also the client_id / VM ID length
	TimestampLen  = 8
	PSKIDLen      = 16
	PSKKeyLen     = 32

	X25519PubLen    = 32
	X25519SecLen    = 32
	X25519SharedLen = 32

	MLKEM768PubLen    = 1184
	MLKEM768CTLen     = 1088
	MLKEM768SharedLen = 32

	MLDSA65PubLen = 1952
	MLDSA65SigLen = 3309

	AEADKeyLen   = 32
	AEADNonceLen = 12
	AEADTagLen   = 16
	NonceSaltLen = 4
	NonceCtrLen  = 8

	TranscriptLen = 32 // SHA3-256 digest size

	MaxFrameBody = 1 << 24 // §5 — 16 MiB hard cap

	HandshakeTimeoutSec = 5
	ReplayWindowNS      = 30 * 1_000_000_000 // 30s in nanoseconds
	ReplayCacheTTLSec   = 60
	PSKLifetimeSec      = 3600
	RekeyTimeSec        = 3600
	RekeyFrameCap       = 1 << 31
	RekeyBytesCap       = 100 * (1 << 30)
)

§3 Constants. Each field is sized per the spec; the literals are the only place these numbers appear so a future ciphersuite addition can introduce its own constants without touching call sites.

Variables

View Source
var (
	ErrDecodeError        = errors.New("zap-pq: decode_error")
	ErrUnsupportedSuite   = errors.New("zap-pq: unsupported_ciphersuite")
	ErrAuthFailed         = errors.New("zap-pq: auth_failed")
	ErrReplayDetected     = errors.New("zap-pq: replay_detected")
	ErrDowngradeRefused   = errors.New("zap-pq: downgrade_refused")
	ErrHandshakeTimeout   = errors.New("zap-pq: handshake_timeout")
	ErrNonceViolation     = errors.New("zap-pq: nonce_violation")
	ErrPSKUnknown         = errors.New("zap-pq: psk_unknown")
	ErrVMIdentityMismatch = errors.New("zap-pq: vm_identity_mismatch")
	ErrAuthoritySigFailed = errors.New("zap-pq: authority_sig_failed")
	ErrPolicyRefused      = errors.New("zap-pq: policy_refused")

	// Non-wire local errors.
	ErrMagicMismatch  = errors.New("zap-pq: magic prefix mismatch")
	ErrSessionClosed  = errors.New("zap-pq: session closed")
	ErrEpochExhausted = errors.New("zap-pq: epoch wrap forbidden, reconnect required")
)

Sentinel errors — one per §14 code, plus a few non-wire local conditions (magic mismatch, handshake-state misuse).

View Source
var (
	LblProtocol   = []byte("ZAP-PQ-v1")
	LblX25519     = []byte("X25519")
	LblMLKEM      = []byte("ML-KEM-768")
	LblSessionI2R = []byte("ZAP-PQ-v1 i->r")
	LblSessionR2I = []byte("ZAP-PQ-v1 r->i")
	LblSaltI2R    = []byte("ZAP-PQ-v1 nonce-salt i->r")
	LblSaltR2I    = []byte("ZAP-PQ-v1 nonce-salt r->i")
	LblResumption = []byte("ZAP-PQ-v1 resumption")
	LblRekey      = []byte("ZAP-PQ-v1 rekey")
	LblAuthI      = []byte("ZAP-PQ-v1 auth initiator")
	LblAuthR      = []byte("ZAP-PQ-v1 auth responder")
)

§3.1 wire labels. ASCII, no NUL terminator. Identical on both sides.

View Source
var Magic = [MagicLen]byte{0x5A, 0x50, 0x51, 0x31}

§3 Magic prefix "ZPQ1".

View Source
var SignCtx = []byte("lux-zap-pq-v1")

SignCtx is the ML-DSA-65 context string applied to every AUTH signature (§6.4). Pinned in code so a future change forces a ciphersuite bump per §18.

Functions

func PSKID

func PSKID(psk [PSKKeyLen]byte) [PSKIDLen]byte

PSKID derives the §12.1 psk_id (16-byte truncation of SHA3-256 of the resumption_psk). Pinned in code so issuance and lookup agree.

func Ratchet

func Ratchet(kPrev [AEADKeyLen]byte, epoch uint8) (kNext [AEADKeyLen]byte, saltNext [NonceSaltLen]byte)

Ratchet implements §13 — derive the next per-direction key and nonce salt from the current key.

info_key  = LBL_REKEY ∥ 0x00 ∥ epoch_n ∥ 0x00
info_salt = LBL_REKEY ∥ 0x00 ∥ epoch_n ∥ 0x01
k_{n+1}    = HKDF-Expand(k_n, info_key,  32)
salt_{n+1} = HKDF-Expand(k_n, info_salt, 4)

Two distinct Expand calls (not a single 36-byte read) — the info bytes differ so the output streams are independent.

The caller is responsible for zeroising the old k_n / salt_n.

Types

type AlertCode

type AlertCode uint8

AlertCode is the §14 error code carried in ALERT frame bodies. Each code maps 1:1 to a sentinel error so callers can branch with errors.Is on the named sentinel rather than parsing the byte.

const (
	AlertNone               AlertCode = 0x00 // reserved
	AlertDecodeError        AlertCode = 0x01
	AlertUnsupportedSuite   AlertCode = 0x02
	AlertAuthFailed         AlertCode = 0x03
	AlertReplayDetected     AlertCode = 0x04
	AlertDowngradeRefused   AlertCode = 0x05
	AlertHandshakeTimeout   AlertCode = 0x06
	AlertNonceViolation     AlertCode = 0x07
	AlertPSKUnknown         AlertCode = 0x08
	AlertVMIdentityMismatch AlertCode = 0x09
	AlertAuthoritySigFailed AlertCode = 0x0A
	AlertPolicyRefused      AlertCode = 0x0B
)

func (AlertCode) String

func (c AlertCode) String() string

String returns the canonical §14 name. Audit pipelines match on these strings; renaming here breaks every downstream parser.

type AlertFrame

type AlertFrame struct {
	Code   AlertCode
	Detail []byte
}

func DecodeAlert

func DecodeAlert(body []byte) (*AlertFrame, error)

func (*AlertFrame) Encode

func (a *AlertFrame) Encode() []byte

type AuthFrame

type AuthFrame struct {
	Role      AuthRole
	Signature []byte // MLDSA65SigLen
}

func DecodeAuth

func DecodeAuth(body []byte) (*AuthFrame, error)

func (*AuthFrame) Encode

func (a *AuthFrame) Encode() ([]byte, error)

type AuthRole

type AuthRole uint8

AuthRole is the §6.4 role byte signed by each side.

const (
	RoleInitiator AuthRole = 0x49 // 'I'
	RoleResponder AuthRole = 0x52 // 'R'
)

func (AuthRole) Label

func (r AuthRole) Label() []byte

authLabel returns the per-role binding string for §6.4 sign_input.

type ClientPSK

type ClientPSK struct {
	ID     [PSKIDLen]byte
	PSK    [PSKKeyLen]byte
	PeerID [IDLen]byte
	Until  time.Time
}

ClientPSK is the value the initiator caches after a successful full handshake. It is the §12.1 record minus the server-side state.

PeerID is the verified responder identity from the ORIGINAL full handshake. The resumed handshake re-derives session keys but does NOT re-verify the responder's static_pk (possession of the PSK is the authentication, §12.2), so the trust anchor must be carried forward from when the responder's signature was last checked.

Callers reading Session.PeerID after a resumed handshake see this value, ensuring authorization decisions remain anchored to the identity that the initiator originally pinned.

Fields are exported ONLY to support persistence / serialization (KMS round-trip, sticky-session cache). Do NOT construct ClientPSK literals by hand — populating PeerID with a value that wasn't verified during the original handshake silently corrupts the resumed Session.PeerID() return. Always go through MakeClientPSK or copy a struct returned by Session.ResumptionPSK().

func MakeClientPSK

func MakeClientPSK(psk [PSKKeyLen]byte, peerID [IDLen]byte, now time.Time) ClientPSK

MakeClientPSK packages a freshly-derived resumption_psk for the initiator to cache, applying §3's 3600s lifetime. peerID is the verified responder identity from the just-completed handshake.

type DataFrame

type DataFrame struct {
	NonceCounter uint64
	Ciphertext   []byte
}

func DecodeData

func DecodeData(body []byte) (*DataFrame, error)

func (*DataFrame) Encode

func (d *DataFrame) Encode() []byte

type FrameType

type FrameType uint8

FrameType encodes the outer envelope type byte (§5, §6).

const (
	FrameHello    FrameType = 0x01
	FrameKEMInit  FrameType = 0x02
	FrameKEMReply FrameType = 0x03
	FrameAuth     FrameType = 0x04
	FrameData     FrameType = 0x05
	FrameRekey    FrameType = 0x06
	FrameAlert    FrameType = 0x07
	FrameHelloPSK FrameType = 0x08
)

type HelloFrame

type HelloFrame struct {
	Suite             SuiteID
	PQMode            PQMode
	ClientRandom      [ClientRandLen]byte
	TimestampNS       uint64
	ClientID          [IDLen]byte
	OfferedSchemes    []SuiteID
	StaticPKInitiator []byte // MLDSA65PubLen
}

func DecodeHello

func DecodeHello(body []byte) (*HelloFrame, error)

DecodeHello parses a HELLO body. Validates lengths but does not check cryptographic facts (those belong to the Responder).

func (*HelloFrame) Encode

func (h *HelloFrame) Encode() ([]byte, error)

Encode returns the wire-encoded HELLO body (no outer type/length).

type HelloPSKFrame

type HelloPSKFrame struct {
	Suite        SuiteID
	PQMode       PQMode
	ClientRandom [ClientRandLen]byte
	TimestampNS  uint64
	PSKID        [PSKIDLen]byte
	X25519EphPub [X25519PubLen]byte
}

func DecodeHelloPSK

func DecodeHelloPSK(body []byte) (*HelloPSKFrame, error)

func (*HelloPSKFrame) Encode

func (h *HelloPSKFrame) Encode() []byte

type Identity

type Identity struct {
	PublicKey  *mldsa.PublicKey
	PrivateKey *mldsa.PrivateKey // nil for peer-only identities
}

Identity holds a node's or VM's static ML-DSA-65 keypair.

The PrivateKey field is non-nil for our own identity; for a pinned peer identity (Initiator.Expected, Responder.Local), PrivateKey is nil and only PublicKey is consulted.

func GenerateIdentity

func GenerateIdentity() (*Identity, error)

GenerateIdentity returns a fresh ML-DSA-65 keypair using crypto/rand. Test code MAY pass a deterministic reader to GenerateIdentityFrom.

func GenerateIdentityFrom

func GenerateIdentityFrom(r io.Reader) (*Identity, error)

GenerateIdentityFrom is GenerateIdentity but lets the caller supply the entropy source — KAT vectors need this for reproducibility.

func IdentityFromPrivate

func IdentityFromPrivate(priv *mldsa.PrivateKey) (*Identity, error)

IdentityFromPrivate wraps an existing ML-DSA-65 private key.

func IdentityFromPublicBytes

func IdentityFromPublicBytes(pub []byte) (*Identity, error)

IdentityFromPublicBytes wraps an exported ML-DSA-65 public key. The returned Identity has a nil PrivateKey and is only usable as a pinned peer identity.

func (*Identity) ID

func (id *Identity) ID() [IDLen]byte

ID returns the §10.1 client_id / VM ID: SHA3-256 of the wire encoding of the public key. The result is also what the spec calls VMID when the identity belongs to a VM plugin.

func (*Identity) PublicBytes

func (id *Identity) PublicBytes() []byte

PublicBytes returns the wire encoding of the public key. Always exactly MLDSA65PubLen bytes for a valid identity.

func (*Identity) Sign

func (id *Identity) Sign(rand io.Reader, h2 [TranscriptLen]byte, role AuthRole, suite SuiteID) ([]byte, error)

Sign produces the §6.4 AUTH signature for the supplied transcript hash and role using the FIPS 204 hedged (randomized) variant. Returns an error if the identity has no private key.

rand is reserved for future use; the underlying ML-DSA-65 implementation reads randomness from crypto/rand internally. The parameter is preserved on the signature to keep call sites compatible with a future deterministic-rand plumbing without another API break.

func (*Identity) SignDeterministic

func (id *Identity) SignDeterministic(h2 [TranscriptLen]byte, role AuthRole, suite SuiteID) ([]byte, error)

SignDeterministic produces the §6.4 AUTH signature using FIPS 204 §5.2 deterministic ML-DSA-65 (no randomness; sig is a pure function of (sk, h2, role, suite)).

Production handshakes call Sign; SignDeterministic exists for KAT vectors and reproducible test fixtures. Same wire format, same verification path — only the per-signature entropy source differs.

SECURITY NOTE: the deterministic variant is secure under standard ML-DSA assumptions but loses the side-channel defense-in-depth that the hedged (randomized) variant provides. Production node identities SHOULD use Sign; only test code should call this.

func (*Identity) VerifyAuth

func (id *Identity) VerifyAuth(
	h2 [TranscriptLen]byte,
	role AuthRole,
	suite SuiteID,
	sig []byte,
) error

VerifyAuth checks a §6.4 AUTH signature against this identity's public key. Returns ErrAuthFailed on any verification failure so callers can map cleanly to ALERT 0x03.

type Initiator

type Initiator struct {
	Local          *Identity
	Expected       *Identity
	Profile        Profile
	PQMode         PQMode
	Suite          SuiteID
	OfferedSchemes []SuiteID
	Resume         *ClientPSK

	Rand io.Reader
	Now  func() time.Time
}

Initiator runs the §4 client side of the handshake.

Required fields:

  • Local: this side's static ML-DSA-65 identity (must have a private key).

Optional fields:

  • Expected: pin the responder's identity. If non-nil, the handshake aborts with ErrVMIdentityMismatch when SHA3-256(responder_static_pk) ≠ Expected.ID().
  • Profile: chain-security stance. Affects only what the wrapper does on magic-prefix mismatch; once the handshake is engaged the profile is enforced through PQMode + OfferedSchemes.
  • PQMode: HELLO.pq_mode byte. Defaults to PQModePQOnly under StrictPQ / FIPS, PQModeClassicalPermitted otherwise.
  • Suite: ciphersuite byte. Defaults to SuiteX25519MLKEM (0x01).
  • OfferedSchemes: HELLO.offered_schemes. Defaults to [Suite].
  • Resume: cached PSK to attempt resumption with. If nil or expired, Initiator runs a full handshake.
  • Rand: entropy source for ephemerals + signing nonces. Defaults to crypto/rand.Reader. KAT tests inject a deterministic reader.
  • Now: clock for HELLO timestamps. Defaults to time.Now.

func (*Initiator) Run

func (i *Initiator) Run(conn io.ReadWriter) (*Session, error)

Run executes §4 over conn and returns a keyed Session on success. On any failure Run emits the appropriate ALERT (§14) before returning and the caller MUST close conn.

type KEMInitFrame

type KEMInitFrame struct {
	X25519EphPub [X25519PubLen]byte
	MLKEMEphPub  [MLKEM768PubLen]byte
}

func DecodeKEMInit

func DecodeKEMInit(body []byte) (*KEMInitFrame, error)

func (*KEMInitFrame) Encode

func (k *KEMInitFrame) Encode() []byte

type KEMReplyFrame

type KEMReplyFrame struct {
	X25519EphPub      [X25519PubLen]byte
	MLKEMCiphertext   [MLKEM768CTLen]byte
	StaticPKResponder []byte // MLDSA65PubLen
}

func DecodeKEMReply

func DecodeKEMReply(body []byte) (*KEMReplyFrame, error)

func (*KEMReplyFrame) Encode

func (k *KEMReplyFrame) Encode() ([]byte, error)

type PQMode

type PQMode uint8

PQMode encodes HELLO.pq_mode (§6.1).

const (
	PQModeClassicalPermitted PQMode = 0x00
	PQModePQRequired         PQMode = 0x01
	PQModePQOnly             PQMode = 0x02
)

type PSKStore

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

PSKStore implements §12 PSK issuance and lookup on the responder.

  • Issue records a fresh (psk_id, resumption_psk, client_id) at the end of a full handshake.
  • Redeem looks up a psk_id presented in HELLO_PSK; on hit it atomically marks the entry consumed (single-use, §12.2).

PSKs expire after PSKLifetimeSec. Expired or unknown lookups return (nil, false) and the caller MUST send ALERT 0x08.

An ABSENT store (PSKStore == nil) disables resumption. The responder treats every HELLO_PSK as ErrPSKUnknown.

func NewPSKStore

func NewPSKStore() *PSKStore

NewPSKStore returns an empty in-memory store with §3's 3600s TTL.

func (*PSKStore) Issue

func (s *PSKStore) Issue(psk [PSKKeyLen]byte, clientID [IDLen]byte) [PSKIDLen]byte

Issue records a PSK derived from the most recent full handshake. The psk_id is `SHA3-256(psk)[:16]` per §12.1. If a previous entry with the same psk_id exists (extremely unlikely — 128-bit ID collision), the new entry overwrites it.

func (*PSKStore) Len

func (s *PSKStore) Len() int

Len reports the current store size. Used by tests and metrics.

func (*PSKStore) Redeem

func (s *PSKStore) Redeem(id [PSKIDLen]byte) (psk [PSKKeyLen]byte, clientID [IDLen]byte, ok bool)

Redeem looks up and atomically consumes a psk_id. Returns the cached resumption_psk and the issuing client_id on hit, or false for unknown / expired / already-redeemed entries.

Single-use per §12.2: redemption deletes the entry whether the resumed handshake completes or not. A failed resumption forces the initiator into a fresh full handshake.

func (*PSKStore) Sweep

func (s *PSKStore) Sweep()

Sweep removes expired entries. Optional housekeeping; Redeem already handles expired lookups, but Sweep keeps memory bounded when many issued PSKs are never redeemed.

type Profile

type Profile uint8

Profile is the chain-security stance applied at the wire boundary (§6.0). It is intentionally local — callers map their richer notions (lux/pq.Mode, ChainConfig) onto this small enum.

const (
	ProfileStrictPQ   Profile = 0x01 // refuse on magic mismatch, no fallback
	ProfilePermissive Profile = 0x02 // fall through to legacy ZAP on mismatch
	ProfileFIPS       Profile = 0x03 // same wire stance as StrictPQ; tagged for audit
)

type RekeyFrame

type RekeyFrame struct {
	Reason uint8
}

func DecodeRekey

func DecodeRekey(body []byte) (*RekeyFrame, error)

func (*RekeyFrame) Encode

func (r *RekeyFrame) Encode() []byte

type ReplayCache

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

ReplayCache holds the §11 nonce-cache state for a Responder.

Two independent gates protect against replay:

  1. Timestamp window: |now - timestamp_ns| ≤ 30s
  2. Nonce cache: dedup on (client_id, client_random)

Implementation: a two-generation map rotated every TTL window. New entries land in `active`; on insert we also probe `frozen` (the previous generation, still inside its TTL). When `now - frozenAt >= ttl`, `frozen` is dropped wholesale and the current `active` becomes the new `frozen`. This is O(1) per insert and O(1) per generation-flip (just rebind the map pointers). The worst-case admission window is between ttl and 2×ttl — any (id, rand) tuple is remembered for AT LEAST ttl seconds after its first appearance, which is what §11 requires.

Memory bound: 2× maxLen entries (one ttl-window of each generation). At the §3 design budget of 2^20 entries per generation, that's ~6 MiB of `(clientID, clientRandom)` tuples in memory — within the 4 MiB Cuckoo target order-of-magnitude, with the operational advantage of O(1) flush instead of O(N) sweep.

The previous implementation (single map + inline sweep at maxLen) was correct but degraded to O(N) per insert once the cap was reached, which a fuzzer or attacker can drive into the slow path. The two-generation rotation eliminates that.

func NewReplayCache

func NewReplayCache() *ReplayCache

NewReplayCache returns a ReplayCache with the §3 default TTL of 60s and a 2^20 per-generation entry cap.

func (*ReplayCache) CheckTimestamp

func (c *ReplayCache) CheckTimestamp(timestampNS uint64) error

CheckTimestamp implements the §11 ±30s window. Returns nil when inside the window, ErrReplayDetected otherwise.

func (*ReplayCache) Len

func (c *ReplayCache) Len() int

Len reports the current cache size summed across both generations. Used by tests and metrics. Operationally it can briefly exceed maxLen up to 2×maxLen during the overlap window.

func (*ReplayCache) SeenOrAdd

func (c *ReplayCache) SeenOrAdd(clientID [IDLen]byte, clientRandom [ClientRandLen]byte) bool

SeenOrAdd reports whether (clientID, clientRandom) was seen within the TTL window. If not, the tuple is recorded and false is returned. A return of true means the caller MUST refuse the handshake with ErrReplayDetected.

Returning true on cache saturation is fail-closed: if we cannot remember a tuple, we refuse it rather than risk admitting a replay.

func (*ReplayCache) Sweep

func (c *ReplayCache) Sweep()

Sweep is a no-op under the two-generation design. The frozen generation is dropped on the next SeenOrAdd call past its TTL; callers do not need to invoke Sweep explicitly. Retained for API compatibility.

type Responder

type Responder struct {
	Local          *Identity
	Profile        Profile
	AcceptedSuites []SuiteID
	ReplayCache    *ReplayCache
	PSKStore       *PSKStore

	Rand io.Reader
	Now  func() time.Time
}

Responder runs the §4 server side of the handshake.

Required fields:

  • Local: server's static ML-DSA-65 identity (must have a private key).

Optional fields:

  • Profile: chain-security stance. Under StrictPQ / FIPS the responder refuses HELLOs that advertise PQModeClassicalPermitted or offered_schemes lists containing non-PQ suites.
  • AcceptedSuites: server-side ciphersuite allowlist. Empty means {SuiteX25519MLKEM}.
  • ReplayCache: §11 replay state. nil disables cache lookups (timestamp-only protection — production must supply a cache).
  • PSKStore: §12 PSK issuer + redeemer. nil disables resumption.

Rand / Now: deterministic overrides for KAT testing.

func (*Responder) Run

func (rs *Responder) Run(conn io.ReadWriter) (*Session, error)

Run executes §4 over conn and returns a keyed Session on success. Mirrors Initiator.Run: on any failure Run emits the appropriate ALERT and returns the typed error.

type Session

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

Session is the post-handshake AEAD-keyed stream specified by §9, §13.

Send → produces one DATA frame on the wire. Recv → consumes one DATA (or REKEY) frame and returns the

plaintext payload of a DATA frame.

Send and Recv are independently safe to call concurrently against the same Session, each under their own mutex.

A Session is NOT net.Conn directly — the package-level conn_pq.go adapter wraps it with Read/Write semantics for legacy callers.

func (*Session) Close

func (s *Session) Close() error

Close marks the session closed, zeros all key material, and (if the underlying ReadWriter implements io.Closer) closes it.

Ordering matters: we close the underlying conn BEFORE acquiring the per-direction mutexes. A Send / Recv parked inside writeFrame or readFrame is holding its mutex while blocked on the wire — if Close grabbed the mutex first, it would wait for the parked IO while the parked IO waits for somebody (us) to close the conn. Closing first unblocks the parked syscall, the parked goroutine returns an error and releases its mutex, and we then acquire the mutex contention-free to scrub state.

If the underlying rw does NOT implement io.Closer (the in-memory io.ReadWriter test path), Close proceeds directly to mutex acquisition. Callers using a non-Closer transport must terminate any parked Send / Recv via other means (deadlines on the wrapped object) before invoking Close, or accept that Close will wait for the parked IO to complete naturally.

Best-effort zeroisation: the raw key bytes are wiped, but the derived AES round-key schedule inside cipher.AEAD is not directly accessible from the Go stdlib. We nil the AEAD references so the GC can reclaim them; production with HSM-grade requirements should use a key wrapper that scrubs the round-key state.

func (*Session) Epoch

func (s *Session) Epoch() uint8

Epoch returns the current local send epoch. Used by tests; not part of the wire-visible state.

func (*Session) PeerID

func (s *Session) PeerID() [IDLen]byte

PeerID returns SHA3-256 of the verified peer's static ML-DSA-65 pk.

func (*Session) Recv

func (s *Session) Recv() ([]byte, error)

Recv reads one frame and returns the decrypted payload of a DATA.

REKEY frames are absorbed transparently: Recv ratchets the recv state and continues reading until a DATA frame arrives or the underlying stream errors. ALERT frames are translated to typed errors via errorForAlert.

func (*Session) Rekey

func (s *Session) Rekey() error

Rekey explicitly initiates a local-side rekey. It is safe (and a no-op as far as wire correctness goes) to call at any time.

Closed check inside sendMu mirrors Send: Close becomes a hard barrier for explicit rekeys too. A Rekey that races a Close returns ErrSessionClosed rather than a writeFrame IO error.

func (*Session) ResumptionPSK

func (s *Session) ResumptionPSK() *ClientPSK

ResumptionPSK returns the client-side cached resumption_psk for future HELLO_PSK use. Returns nil on the responder side (the responder's PSK is held in PSKStore instead).

func (*Session) Role

func (s *Session) Role() AuthRole

Role returns the local role (Initiator or Responder).

func (*Session) Send

func (s *Session) Send(payload []byte) error

Send encrypts payload and emits one DATA frame. Returns ErrSessionClosed on a closed session, ErrEpochExhausted if the next REKEY would wrap the epoch byte.

Automatic REKEY: when sending payload would cross any §6.6 threshold (frame count, time, bytes), Send emits a REKEY frame FIRST, ratchets locally, then emits the DATA frame.

type SessionKeys

type SessionKeys struct {
	KInitToResp    [AEADKeyLen]byte
	KRespToInit    [AEADKeyLen]byte
	SaltInitToResp [NonceSaltLen]byte
	SaltRespToInit [NonceSaltLen]byte
	ResumptionPSK  [PSKKeyLen]byte
}

SessionKeys is §8.3's five-expand output — the post-handshake secrets every Session is keyed from.

func DeriveResumed

func DeriveResumed(
	h2psk [TranscriptLen]byte,
	x25519Shared [X25519SharedLen]byte,
	resumptionPSK [PSKKeyLen]byte,
) SessionKeys

DeriveResumed is the §12.2 PSK-resumption KDF. It folds the new X25519 shared secret with the cached resumption_psk into a fresh PRK and re-expands the five session secrets, salted by the resumed transcript hash H_2_psk.

func DeriveSession

func DeriveSession(
	h2 [TranscriptLen]byte,
	x25519Shared [X25519SharedLen]byte,
	mlkemShared [MLKEM768SharedLen]byte,
) SessionKeys

DeriveSession runs §8 over a completed transcript hash and the two hybrid shared secrets.

IKM = u8(len(LblX25519)) ∥ LblX25519 ∥ u8(32) ∥ x25519_shared
    ∥ u8(len(LblMLKEM))  ∥ LblMLKEM  ∥ u8(32) ∥ mlkem_shared
PRK = HKDF-Extract(salt = H_2, IKM)
k_i2r        = HKDF-Expand(PRK, LBL_SESSION_I2R, 32)
k_r2i        = HKDF-Expand(PRK, LBL_SESSION_R2I, 32)
salt_i2r     = HKDF-Expand(PRK, LBL_SALT_I2R,    4)
salt_r2i     = HKDF-Expand(PRK, LBL_SALT_R2I,    4)
resumption   = HKDF-Expand(PRK, LBL_RESUMPTION, 32)

HKDF runs over SHA3-256 per §8.4.

func (*SessionKeys) Zeroize

func (k *SessionKeys) Zeroize()

Zeroize overwrites every key field with zeros. Callers MUST invoke this on the old SessionKeys after a rekey or on session close so stale key material does not linger on the heap.

type SuiteID

type SuiteID uint8

§3 / §3.2 ciphersuite registry. Only 0x01 is wire-callable today.

const (
	SuiteReservedLo  SuiteID = 0x00
	SuiteX25519MLKEM SuiteID = 0x01
	SuiteReservedHi  SuiteID = 0xFF
)

func (SuiteID) IsValid

func (s SuiteID) IsValid() bool

IsValid reports whether s is a callable ciphersuite. Reserved IDs (0x00, 0xFF) and the unallocated mid-range return false.

type Transcript

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

Transcript chains SHA3-256 over every handshake byte (§7).

The state machine:

NewTranscript(suite)
AbsorbHello(helloBody)         -> commits H_0
AbsorbKEM(initBody, replyBody) -> commits H_1
FinishFull(pkI, pkR, schemes)  -> returns H_2          (full handshake)
  -- OR --
FinishPSK(serverEphX25519Pub)  -> returns H_2_psk      (resumed handshake)

Each step replaces the internal SHA3-256 state with the digest of the previous chain ∥ new material. This matches the spec's definition of H_n as `SHA3-256(H_{n-1} ∥ <new bytes>)`.

The encoded body of each frame is whatever is between the outer type/length fields — i.e. the slice the codec returns / consumes. Callers MUST feed exactly those bytes (not the outer envelope) so both sides agree on the transcript without re-running the codec.

func NewTranscript

func NewTranscript(suite SuiteID) *Transcript

NewTranscript creates a transcript pinned to the supplied suite. The suite byte does not enter the state at construction — it enters via the H_0 prefix in AbsorbHello so the resulting H_0 already binds it.

func (*Transcript) AbsorbHello

func (t *Transcript) AbsorbHello(hello []byte)

AbsorbHello commits H_0 = SHA3-256(LBL_PROTOCOL ∥ 0x00 ∥ suite ∥ hello). The hello argument is the wire-encoded HELLO body (everything after the outer type ∥ length envelope), per §7.

func (*Transcript) AbsorbKEM

func (t *Transcript) AbsorbKEM(init, reply []byte)

AbsorbKEM commits H_1 = SHA3-256(H_0 ∥ init ∥ reply).

init = wire-encoded KEM_INIT body, reply = wire-encoded KEM_REPLY body. The two are folded in one digest call because the spec chains them inside a single SHA3 instance, not as two separate H_n steps.

func (*Transcript) FinishFull

func (t *Transcript) FinishFull(pkI, pkR []byte, schemes []SuiteID) [TranscriptLen]byte

FinishFull commits H_2 for the full handshake:

H_2 = SHA3-256(H_1 ∥ static_pk_I ∥ static_pk_R ∥ offered_schemes_encoded)

offered_schemes_encoded is `u32(len) ∥ bytes(schemes)` — the same byte sequence already inside HELLO. It is re-mixed here so that any on-the-wire tamper with the scheme list under the magic-prefix layer (e.g. a downgrader stripping `0x01`) would compute a different H_2 than what the signer signed — AUTH then fails, ALERT 0x03 fires.

func (*Transcript) FinishPSK

func (t *Transcript) FinishPSK(serverEphX25519Pub []byte) [TranscriptLen]byte

FinishPSK is the §7 resumption transcript:

H_2_psk = SHA3-256(H_0_psk ∥ x25519_pk_eph_responder)

Where H_0_psk was committed by AbsorbHello over the HELLO_PSK body. The resumption path does NOT absorb KEM frames — possession of the resumption_psk is the authentication and ML-KEM is skipped.

func (*Transcript) H0

func (t *Transcript) H0() [TranscriptLen]byte

H0, H1, H2 return the most recently committed digest at each stage. They are introspection helpers for tests / KAT vectors; production code chains FinishFull or FinishPSK directly into the KDF.

func (*Transcript) H1

func (t *Transcript) H1() [TranscriptLen]byte

func (*Transcript) H2

func (t *Transcript) H2() [TranscriptLen]byte

Jump to

Keyboard shortcuts

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