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
- Variables
- func PSKID(psk [PSKKeyLen]byte) [PSKIDLen]byte
- func Ratchet(kPrev [AEADKeyLen]byte, epoch uint8) (kNext [AEADKeyLen]byte, saltNext [NonceSaltLen]byte)
- type AlertCode
- type AlertFrame
- type AuthFrame
- type AuthRole
- type ClientPSK
- type DataFrame
- type FrameType
- type HelloFrame
- type HelloPSKFrame
- type Identity
- func (id *Identity) ID() [IDLen]byte
- func (id *Identity) PublicBytes() []byte
- func (id *Identity) Sign(rand io.Reader, h2 [TranscriptLen]byte, role AuthRole, suite SuiteID) ([]byte, error)
- func (id *Identity) SignDeterministic(h2 [TranscriptLen]byte, role AuthRole, suite SuiteID) ([]byte, error)
- func (id *Identity) VerifyAuth(h2 [TranscriptLen]byte, role AuthRole, suite SuiteID, sig []byte) error
- type Initiator
- type KEMInitFrame
- type KEMReplyFrame
- type PQMode
- type PSKStore
- type Profile
- type RekeyFrame
- type ReplayCache
- type Responder
- type Session
- type SessionKeys
- type SuiteID
- type Transcript
- func (t *Transcript) AbsorbHello(hello []byte)
- func (t *Transcript) AbsorbKEM(init, reply []byte)
- func (t *Transcript) FinishFull(pkI, pkR []byte, schemes []SuiteID) [TranscriptLen]byte
- func (t *Transcript) FinishPSK(serverEphX25519Pub []byte) [TranscriptLen]byte
- func (t *Transcript) H0() [TranscriptLen]byte
- func (t *Transcript) H1() [TranscriptLen]byte
- func (t *Transcript) H2() [TranscriptLen]byte
Constants ¶
const ( RekeyReasonCounterLimit uint8 = 0x01 RekeyReasonTimeLimit uint8 = 0x02 RekeyReasonBytesLimit uint8 = 0x03 RekeyReasonExplicit uint8 = 0x04 )
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 MLKEM768PubLen = 1184 MLKEM768CTLen = 1088 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 ¶
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).
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.
var Magic = [MagicLen]byte{0x5A, 0x50, 0x51, 0x31}
§3 Magic prefix "ZPQ1".
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 ¶
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 )
type AlertFrame ¶
func DecodeAlert ¶
func DecodeAlert(body []byte) (*AlertFrame, error)
func (*AlertFrame) Encode ¶
func (a *AlertFrame) Encode() []byte
type AuthFrame ¶
func DecodeAuth ¶
type ClientPSK ¶
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().
type DataFrame ¶
func DecodeData ¶
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 ¶
GenerateIdentity returns a fresh ML-DSA-65 keypair using crypto/rand. Test code MAY pass a deterministic reader to GenerateIdentityFrom.
func GenerateIdentityFrom ¶
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 ¶
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 ¶
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 ¶
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.
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 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 ¶
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) Redeem ¶
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.
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.
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:
- Timestamp window: |now - timestamp_ns| ≤ 30s
- 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.
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 ¶
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 ¶
Epoch returns the current local send epoch. Used by tests; not part of the wire-visible state.
func (*Session) Recv ¶
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 ¶
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 ¶
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) Send ¶
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 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