From bb1e3cf1409496ae936eb320d930284b06fa01ea Mon Sep 17 00:00:00 2001 From: wuka Date: Wed, 23 Sep 2026 16:09:17 +0200 Subject: [PATCH] 33 --- .gitattributes | 2 + .gitignore | 2 - .idea/.gitignore | 5 + .idea/go.imports.xml | 10 + .idea/inspectionProfiles/Project_Default.xml | 24 ++ .idea/lafsession.iml | 9 + .idea/modules.xml | 8 + .idea/vcs.xml | 6 + README.md | 85 ++++ errors.go | 17 + go.mod | 7 + go.sum | 4 + handshake.go | 304 +++++++++++++++ identity.go | 86 +++++ keys.go | 62 +++ record.go | 76 ++++ replay.go | 85 ++++ session.go | 373 ++++++++++++++++++ session_bench_test.go | 384 +++++++++++++++++++ session_test.go | 145 +++++++ 20 files changed, 1692 insertions(+), 2 deletions(-) create mode 100644 .gitattributes create mode 100644 .idea/.gitignore create mode 100644 .idea/go.imports.xml create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/lafsession.iml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 errors.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 handshake.go create mode 100644 identity.go create mode 100644 keys.go create mode 100644 record.go create mode 100644 replay.go create mode 100644 session.go create mode 100644 session_bench_test.go create mode 100644 session_test.go diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.gitignore b/.gitignore index 5b90e79..6f72f89 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -# ---> Go # If you prefer the allow list template instead of the deny list, see community template: # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore # @@ -24,4 +23,3 @@ go.work.sum # env file .env - diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..b58b603 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,5 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/go.imports.xml b/.idea/go.imports.xml new file mode 100644 index 0000000..644cdf0 --- /dev/null +++ b/.idea/go.imports.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..793e407 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,24 @@ + + + + \ No newline at end of file diff --git a/.idea/lafsession.iml b/.idea/lafsession.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/lafsession.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..6ec5962 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index 94d9534..55c809d 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,87 @@ # lafsession +Go package: mutually authenticated AEAD session for opaque binary payloads (LAF envelopes) over a caller-provided transport. + +## Algorithms + +| Role | Algorithm | +|------|-----------| +| Identity | Ed25519 | +| Ephemeral ECDH | X25519 | +| KDF | HKDF-SHA256 | +| Record AEAD | ChaCha20-Poly1305 | +| Anti-replay | sliding bit window (default 1024) | + +Protocol version `1`, suite `X25519 + ChaCha20-Poly1305`. + +## Handshake + +``` +ClientHello → ServerHello → ClientFinished → ServerFinished +``` + +Transcript binds both ephemeral and identity public keys. HKDF info includes protocol label, version, suite, and transcript hash. Keys are derived at ClientFinished; ServerFinished is identity confirmation only. + +## Record + +``` +type (1) || seq (8 BE) || ChaCha20-Poly1305(ciphertext ‖ tag) +``` + +Nonce = `0x00000000 ‖ seq`. AAD = `type ‖ seq`. Payload is opaque. + +## Server + +```go +id, err := lafsession.GenerateIdentity() + +s, err := lafsession.New(lafsession.Config{ + Identity: id, + Role: lafsession.RoleServer, + AcceptPeer: func(pub []byte) bool { + return pinned[lafsession.FingerprintOf(pub)] + }, +}) + +serverHello, err := s.HandleHandshake(clientHello) +_, err = s.HandleHandshake(clientFinished) +sf, err := s.SendFinished() + +frame, err := s.Seal(lafBytes) // frame valid until next Seal/Close +laf, err := s.Open(frame) // laf valid until next Open +// zero-alloc when caller owns buffers: +// frame, err = s.SealTo(dst[:0], lafBytes) +// laf, err = s.OpenTo(out[:0], frame) +closeFrame, err := s.Close() +``` + +## Client + +```go +c, err := lafsession.New(lafsession.Config{ + Identity: clientID, + Role: lafsession.RoleClient, + AcceptPeer: func(pub []byte) bool { + return lafsession.FingerprintOf(pub) == expectedServerFP + }, +}) + +ch, err := c.StartHandshake() +cf, err := c.HandleHandshake(serverHello) +_, err = c.HandleHandshake(serverFinished) + +frame, err := c.Seal(lafBytes) +laf, err := c.Open(frame) +``` + +## Errors + +`ErrNotEstablished`, `ErrAlreadyEstablished`, `ErrIdentityRejected`, `ErrBadVersion`, `ErrBadCipher`, `ErrBadSignature`, `ErrBadTag`, `ErrReplay`, `ErrHandshakeState`, `ErrShortMessage`, `ErrClosed`. + +## Module + +```text +github.com/example/lafsession +``` + +Replace the module path in `go.mod` when publishing under your own import path. diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..431b927 --- /dev/null +++ b/errors.go @@ -0,0 +1,17 @@ +package lafsession + +import "errors" + +var ( + ErrNotEstablished = errors.New("lafsession: session not established") + ErrAlreadyEstablished = errors.New("lafsession: session already established") + ErrIdentityRejected = errors.New("lafsession: peer identity rejected") + ErrBadVersion = errors.New("lafsession: unsupported protocol version") + ErrBadCipher = errors.New("lafsession: unsupported cipher suite") + ErrBadSignature = errors.New("lafsession: invalid identity signature") + ErrBadTag = errors.New("lafsession: authentication failed") + ErrReplay = errors.New("lafsession: replay or out-of-window sequence") + ErrHandshakeState = errors.New("lafsession: unexpected handshake message") + ErrShortMessage = errors.New("lafsession: message too short") + ErrClosed = errors.New("lafsession: session closed") +) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6893663 --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module github.com/example/lafsession + +go 1.22.2 + +require golang.org/x/crypto v0.28.0 + +require golang.org/x/sys v0.26.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..25fba75 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= +golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/handshake.go b/handshake.go new file mode 100644 index 0000000..e74f05c --- /dev/null +++ b/handshake.go @@ -0,0 +1,304 @@ +package lafsession + +import ( + "crypto/ecdh" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/binary" + "hash" +) + +const ( + ProtocolVersion uint16 = 1 + CipherSuiteX25519ChaCha20Poly1305 uint16 = 1 + + msgClientHello byte = 1 + msgServerHello byte = 2 + msgClientFinished byte = 3 + msgServerFinished byte = 4 + + ephPubLen = 32 + idPubLen = 32 + sigLen = ed25519.SignatureSize // 64 + + // type|ver|suite|eph|id|sig + helloMsgLen = 1 + 2 + 2 + ephPubLen + idPubLen + sigLen // 133 + // type|sig + finishedMsgLen = 1 + sigLen // 65 + + // CH+SH+CF+SF upper bound + transcriptCap = helloMsgLen*2 + finishedMsgLen*2 // 396 +) + +// HandshakeRole is which side we act as. +type HandshakeRole int + +const ( + RoleClient HandshakeRole = iota + RoleServer +) + +type peerAcceptFunc func(peerPub []byte) bool + +type handshakeState struct { + role HandshakeRole + identity *Identity + acceptPeer peerAcceptFunc + + ephPriv *ecdh.PrivateKey + ephPub [ephPubLen]byte + + peerEphPub [ephPubLen]byte + peerIDPub [idPubLen]byte + hasPeer bool + + transcript []byte + hasher hash.Hash // running SHA-256 over transcript; kept in sync with transcript + hashBuf [32]byte + labelBuf [1 + 32]byte // finished label: type || transcriptHash + msgBuf []byte // reusable outbound message buffer + + version uint16 + suite uint16 +} + +func newHandshake(role HandshakeRole, id *Identity, accept peerAcceptFunc) (*handshakeState, error) { + curve := ecdh.X25519() + eph, err := curve.GenerateKey(rand.Reader) + if err != nil { + return nil, err + } + h := &handshakeState{ + role: role, + identity: id, + acceptPeer: accept, + ephPriv: eph, + transcript: make([]byte, 0, transcriptCap), + hasher: sha256.New(), + msgBuf: make([]byte, 0, helloMsgLen), + version: ProtocolVersion, + suite: CipherSuiteX25519ChaCha20Poly1305, + } + copy(h.ephPub[:], eph.PublicKey().Bytes()) + return h, nil +} + +func (h *handshakeState) resetHasherFromTranscript() { + h.hasher.Reset() + _, _ = h.hasher.Write(h.transcript) +} + +func (h *handshakeState) transcriptSum(dst []byte) []byte { + // Sum into hashBuf then copy to dst if needed; Sum allocates if dst too small + return h.hasher.Sum(h.hashBuf[:0]) +} + +func (h *handshakeState) writeTranscript(p []byte) { + h.transcript = append(h.transcript, p...) + _, _ = h.hasher.Write(p) +} + +// buildClientHello: type | ver | suite | ephPub | idPub | sig +func (h *handshakeState) buildClientHello() ([]byte, error) { + if h.role != RoleClient { + return nil, ErrHandshakeState + } + msg := h.msgBuf[:0] + msg = append(msg, msgClientHello) + msg = binary.BigEndian.AppendUint16(msg, h.version) + msg = binary.BigEndian.AppendUint16(msg, h.suite) + msg = append(msg, h.ephPub[:]...) + msg = append(msg, h.identity.Public...) + + baseLen := len(msg) + h.writeTranscript(msg) + sum := h.transcriptSum(nil) + sig := h.identity.Sign(sum) + msg = append(msg, sig...) + h.writeTranscript(sig) + h.msgBuf = msg + // return a copy so caller's network write is independent of msgBuf reuse + out := make([]byte, len(msg)) + copy(out, msg) + _ = baseLen + return out, nil +} + +func (h *handshakeState) processClientHello(msg []byte) error { + if h.role != RoleServer { + return ErrHandshakeState + } + if len(msg) < helloMsgLen { + return ErrShortMessage + } + if msg[0] != msgClientHello { + return ErrHandshakeState + } + ver := binary.BigEndian.Uint16(msg[1:3]) + suite := binary.BigEndian.Uint16(msg[3:5]) + if ver != ProtocolVersion { + return ErrBadVersion + } + if suite != CipherSuiteX25519ChaCha20Poly1305 { + return ErrBadCipher + } + off := 5 + peerEph := msg[off : off+ephPubLen] + off += ephPubLen + peerID := msg[off : off+idPubLen] + off += idPubLen + sig := msg[off : off+sigLen] + + if h.acceptPeer != nil && !h.acceptPeer(peerID) { + return ErrIdentityRejected + } + + base := msg[:5+ephPubLen+idPubLen] + // Verify against hash(base) without permanently writing sig yet + h.hasher.Reset() + _, _ = h.hasher.Write(base) + sum := h.transcriptSum(nil) + if !VerifyPeer(peerID, sum, sig) { + h.hasher.Reset() + return ErrBadSignature + } + + h.version = ver + h.suite = suite + copy(h.peerEphPub[:], peerEph) + copy(h.peerIDPub[:], peerID) + h.hasPeer = true + + // hasher already holds base from verify; only append sig + h.transcript = append(h.transcript[:0], base...) + h.transcript = append(h.transcript, sig...) + _, _ = h.hasher.Write(sig) + return nil +} + +func (h *handshakeState) buildServerHello() ([]byte, error) { + if h.role != RoleServer { + return nil, ErrHandshakeState + } + msg := h.msgBuf[:0] + msg = append(msg, msgServerHello) + msg = binary.BigEndian.AppendUint16(msg, h.version) + msg = binary.BigEndian.AppendUint16(msg, h.suite) + msg = append(msg, h.ephPub[:]...) + msg = append(msg, h.identity.Public...) + + h.writeTranscript(msg) + sum := h.transcriptSum(nil) + sig := h.identity.Sign(sum) + msg = append(msg, sig...) + h.writeTranscript(sig) + h.msgBuf = msg + + out := make([]byte, len(msg)) + copy(out, msg) + return out, nil +} + +func (h *handshakeState) processServerHello(msg []byte) error { + if h.role != RoleClient { + return ErrHandshakeState + } + if len(msg) < helloMsgLen { + return ErrShortMessage + } + if msg[0] != msgServerHello { + return ErrHandshakeState + } + ver := binary.BigEndian.Uint16(msg[1:3]) + suite := binary.BigEndian.Uint16(msg[3:5]) + if ver != ProtocolVersion { + return ErrBadVersion + } + if suite != CipherSuiteX25519ChaCha20Poly1305 { + return ErrBadCipher + } + off := 5 + peerEph := msg[off : off+ephPubLen] + off += ephPubLen + peerID := msg[off : off+idPubLen] + off += idPubLen + sig := msg[off : off+sigLen] + + if h.acceptPeer != nil && !h.acceptPeer(peerID) { + return ErrIdentityRejected + } + + base := msg[:5+ephPubLen+idPubLen] + // Temporarily extend transcript for verify; roll back on failure + tLen := len(h.transcript) + h.writeTranscript(base) + sum := h.transcriptSum(nil) + if !VerifyPeer(peerID, sum, sig) { + h.transcript = h.transcript[:tLen] + h.resetHasherFromTranscript() + return ErrBadSignature + } + + copy(h.peerEphPub[:], peerEph) + copy(h.peerIDPub[:], peerID) + h.hasPeer = true + h.writeTranscript(sig) + return nil +} + +func (h *handshakeState) buildFinished(msgType byte) ([]byte, error) { + sum := h.transcriptSum(nil) + h.labelBuf[0] = msgType + copy(h.labelBuf[1:], sum) + sig := h.identity.Sign(h.labelBuf[:]) + msg := h.msgBuf[:0] + msg = append(msg, msgType) + msg = append(msg, sig...) + h.writeTranscript(msg) + h.msgBuf = msg + + out := make([]byte, len(msg)) + copy(out, msg) + return out, nil +} + +func (h *handshakeState) processFinished(msg []byte, expectedType byte, peerID []byte) error { + if len(msg) < finishedMsgLen { + return ErrShortMessage + } + if msg[0] != expectedType { + return ErrHandshakeState + } + sig := msg[1 : 1+sigLen] + sum := h.transcriptSum(nil) + h.labelBuf[0] = expectedType + copy(h.labelBuf[1:], sum) + if !VerifyPeer(peerID, h.labelBuf[:], sig) { + return ErrBadSignature + } + h.writeTranscript(msg[:finishedMsgLen]) + return nil +} + +func (h *handshakeState) deriveKeys() (*trafficKeys, error) { + curve := ecdh.X25519() + peerPub, err := curve.NewPublicKey(h.peerEphPub[:]) + if err != nil { + return nil, err + } + shared, err := h.ephPriv.ECDH(peerPub) + if err != nil { + return nil, err + } + defer zero(shared) + sum := h.transcriptSum(nil) + return deriveTrafficKeys(shared, sum, h.version, h.suite) +} + +func (h *handshakeState) peerIDSlice() []byte { + if !h.hasPeer { + return nil + } + return h.peerIDPub[:] +} diff --git a/identity.go b/identity.go new file mode 100644 index 0000000..726dc82 --- /dev/null +++ b/identity.go @@ -0,0 +1,86 @@ +package lafsession + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" +) + +// Identity is a long-term Ed25519 key pair used for mutual authentication. +type Identity struct { + Private ed25519.PrivateKey + Public ed25519.PublicKey +} + +// GenerateIdentity creates a new random identity key pair. +func GenerateIdentity() (*Identity, error) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, err + } + return &Identity{Private: priv, Public: pub}, nil +} + +// IdentityFromSeed derives an identity from a 32-byte seed (deterministic). +func IdentityFromSeed(seed []byte) (*Identity, error) { + if len(seed) != ed25519.SeedSize { + return nil, fmt.Errorf("lafsession: seed must be %d bytes", ed25519.SeedSize) + } + priv := ed25519.NewKeyFromSeed(seed) + return &Identity{Private: priv, Public: priv.Public().(ed25519.PublicKey)}, nil +} + +// PublicBytes returns the 32-byte public key. +func (id *Identity) PublicBytes() []byte { + return append([]byte(nil), id.Public...) +} + +// Fingerprint returns a SHA-256 hex fingerprint of the public key (full 32 bytes hex). +func (id *Identity) Fingerprint() string { + sum := sha256.Sum256(id.Public) + return hex.EncodeToString(sum[:]) +} + +// ShortFingerprint returns the first 20 bytes of SHA-256 as spaced hex groups (display). +func (id *Identity) ShortFingerprint() string { + sum := sha256.Sum256(id.Public) + h := hex.EncodeToString(sum[:20]) + out := make([]byte, 0, 24+5) + for i := 0; i < len(h); i += 4 { + if i > 0 { + out = append(out, ' ') + } + end := i + 4 + if end > len(h) { + end = len(h) + } + out = append(out, h[i:end]...) + } + return string(out) +} + +// Sign signs a transcript with the identity private key. +func (id *Identity) Sign(msg []byte) []byte { + return ed25519.Sign(id.Private, msg) +} + +// Verify checks a signature under this identity public key. +func (id *Identity) Verify(msg, sig []byte) bool { + return ed25519.Verify(id.Public, msg, sig) +} + +// VerifyPeer checks a signature under an arbitrary peer public key. +func VerifyPeer(peerPub, msg, sig []byte) bool { + if len(peerPub) != ed25519.PublicKeySize { + return false + } + return ed25519.Verify(ed25519.PublicKey(peerPub), msg, sig) +} + +// FingerprintOf returns SHA-256 hex of a raw public key. +func FingerprintOf(pub []byte) string { + sum := sha256.Sum256(pub) + return hex.EncodeToString(sum[:]) +} diff --git a/keys.go b/keys.go new file mode 100644 index 0000000..11c86cf --- /dev/null +++ b/keys.go @@ -0,0 +1,62 @@ +package lafsession + +import ( + "crypto/sha256" + "io" + + "golang.org/x/crypto/chacha20poly1305" + "golang.org/x/crypto/hkdf" +) + +const ( + keyLen = chacha20poly1305.KeySize // 32 + nonceLen = chacha20poly1305.NonceSize // 12 + tagLen = 16 +) + +// trafficKeys holds direction-specific AEAD keys after handshake. +type trafficKeys struct { + c2s [keyLen]byte + s2c [keyLen]byte +} + +func deriveTrafficKeys(sharedSecret, transcriptHash []byte, version uint16, suite uint16) (*trafficKeys, error) { + return deriveTrafficKeysImpl(sharedSecret, transcriptHash, version, suite) +} + +func deriveTrafficKeysImpl(sharedSecret, transcriptHash []byte, version uint16, suite uint16) (*trafficKeys, error) { + info := buildHKDFInfo(version, suite, transcriptHash) + r := hkdf.New(sha256.New, sharedSecret, nil, info) + var tk trafficKeys + if _, err := io.ReadFull(r, tk.c2s[:]); err != nil { + return nil, err + } + if _, err := io.ReadFull(r, tk.s2c[:]); err != nil { + return nil, err + } + return &tk, nil +} + +func buildHKDFInfo(version, suite uint16, transcriptHash []byte) []byte { + const label = "lafsession ecdh-aead v1\x00" + info := make([]byte, 0, len(label)+2+2+len(transcriptHash)) + info = append(info, label...) + info = append(info, byte(version>>8), byte(version)) + info = append(info, byte(suite>>8), byte(suite)) + info = append(info, transcriptHash...) + return info +} + +func zero(b []byte) { + for i := range b { + b[i] = 0 + } +} + +func (tk *trafficKeys) wipe() { + if tk == nil { + return + } + zero(tk.c2s[:]) + zero(tk.s2c[:]) +} diff --git a/record.go b/record.go new file mode 100644 index 0000000..42cb8a5 --- /dev/null +++ b/record.go @@ -0,0 +1,76 @@ +package lafsession + +import ( + "crypto/cipher" + "encoding/binary" + + "golang.org/x/crypto/chacha20poly1305" +) + +const ( + recordHeaderLen = 1 + 8 // type + seq + + recordTypeData byte = 0x10 + recordTypeClose byte = 0x11 +) + +func newAEAD(key []byte) (cipher.AEAD, error) { + return chacha20poly1305.New(key) +} + +// sealRecord builds: type || seq(8 BE) || ciphertext||tag +// Nonce = 0x00000000 || seq. AAD = type || seq. +// dst is reused as output buffer when capacity is sufficient. +func sealRecord(aead cipher.AEAD, seq uint64, recType byte, plaintext, dst []byte, nonce *[nonceLen]byte, aad *[recordHeaderLen]byte) []byte { + aad[0] = recType + binary.BigEndian.PutUint64(aad[1:], seq) + + for i := 0; i < 4; i++ { + nonce[i] = 0 + } + binary.BigEndian.PutUint64(nonce[4:], seq) + + need := recordHeaderLen + len(plaintext) + aead.Overhead() + if cap(dst) < need { + dst = make([]byte, need) + } else { + dst = dst[:need] + } + dst[0] = recType + binary.BigEndian.PutUint64(dst[1:9], seq) + _ = aead.Seal(dst[recordHeaderLen:recordHeaderLen], nonce[:], plaintext, aad[:]) + return dst +} + +// openRecord decrypts a full frame. Returns record type and plaintext (reusing dst when possible). +func openRecord(aead cipher.AEAD, frame, dst []byte, nonce *[nonceLen]byte, aad *[recordHeaderLen]byte) (recType byte, plaintext []byte, err error) { + if len(frame) < recordHeaderLen+aead.Overhead() { + return 0, nil, ErrShortMessage + } + recType = frame[0] + seq := binary.BigEndian.Uint64(frame[1:9]) + + aad[0] = recType + binary.BigEndian.PutUint64(aad[1:], seq) + + for i := 0; i < 4; i++ { + nonce[i] = 0 + } + binary.BigEndian.PutUint64(nonce[4:], seq) + + ct := frame[recordHeaderLen:] + ptLen := len(ct) - aead.Overhead() + if ptLen < 0 { + return 0, nil, ErrShortMessage + } + if cap(dst) < ptLen { + dst = make([]byte, ptLen) + } else { + dst = dst[:ptLen] + } + out, err := aead.Open(dst[:0], nonce[:], ct, aad[:]) + if err != nil { + return 0, nil, ErrBadTag + } + return recType, out, nil +} diff --git a/replay.go b/replay.go new file mode 100644 index 0000000..9236db1 --- /dev/null +++ b/replay.go @@ -0,0 +1,85 @@ +package lafsession + +// replayWindow is a sliding bit-window anti-replay tracker for a single direction. +type replayWindow struct { + maxSeq uint64 + size uint64 // bits + bits []uint64 +} + +func newReplayWindow(size uint64) *replayWindow { + if size < 64 { + size = 64 + } + words := (size + 63) / 64 + return &replayWindow{ + size: size, + bits: make([]uint64, words), + } +} + +// accept returns true if seq is new and within window; marks it seen. +func (w *replayWindow) accept(seq uint64) bool { + if seq+w.size <= w.maxSeq { + // too old + return false + } + if seq > w.maxSeq { + shift := seq - w.maxSeq + if shift >= w.size { + // clear entire window + for i := range w.bits { + w.bits[i] = 0 + } + } else { + w.shift(shift) + } + w.maxSeq = seq + } + idx := w.maxSeq - seq + word := idx / 64 + bit := idx % 64 + if word >= uint64(len(w.bits)) { + return false + } + mask := uint64(1) << bit + if w.bits[word]&mask != 0 { + return false // already seen + } + w.bits[word] |= mask + return true +} + +func (w *replayWindow) shift(n uint64) { + words := n / 64 + bits := n % 64 + if words > 0 { + copy(w.bits[words:], w.bits) + for i := uint64(0); i < words && i < uint64(len(w.bits)); i++ { + w.bits[i] = 0 + } + // zero the tail that was shifted out conceptually + for i := uint64(len(w.bits)); i > words; i-- { + // already copied + } + if words < uint64(len(w.bits)) { + for i := 0; i < int(words); i++ { + w.bits[i] = 0 + } + } else { + for i := range w.bits { + w.bits[i] = 0 + } + return + } + } + if bits == 0 { + return + } + var carry uint64 + for i := len(w.bits) - 1; i >= 0; i-- { + newCarry := w.bits[i] >> (64 - bits) + w.bits[i] = (w.bits[i] << bits) | carry + carry = newCarry + } +} diff --git a/session.go b/session.go new file mode 100644 index 0000000..403d93c --- /dev/null +++ b/session.go @@ -0,0 +1,373 @@ +package lafsession + +import ( + "crypto/cipher" + "encoding/binary" + "sync" +) + +// Config configures a Session before handshake. +type Config struct { + // Identity is this side's long-term key. Required. + Identity *Identity + + // Role is RoleServer or RoleClient. + Role HandshakeRole + + // AcceptPeer is called with the peer's 32-byte identity public key during + // handshake. Return false to reject (ErrIdentityRejected). + // If nil, any peer identity is accepted (not recommended for production). + AcceptPeer func(peerPub []byte) bool + + // ReplayWindowSize is the anti-replay window in packets (default 1024). + ReplayWindowSize uint64 +} + +// Session is a mutually authenticated AEAD channel that carries opaque LAF payloads. +// +// Typical server flow: +// +// s, _ := New(Config{Identity: serverID, Role: RoleServer, AcceptPeer: pinCheck}) +// // read clientHello from network +// serverHello, _ := s.HandleHandshake(clientHello) +// // write serverHello +// // read clientFinished +// _, _ = s.HandleHandshake(clientFinished) +// // optional: write serverFinished +// sf, _ := s.SendFinished() +// // write sf +// // now Established() +// frame, _ := s.Seal(lafBytes) +// laf, _ := s.Open(frame) +type Session struct { + mu sync.Mutex + + cfg Config + hs *handshakeState + + keys *trafficKeys + sendAEAD cipher.AEAD + recvAEAD cipher.AEAD + + sendSeq uint64 + recvWin *replayWindow + + sealBuf []byte + openBuf []byte + nonce [nonceLen]byte + aad [recordHeaderLen]byte + + established bool + closed bool + pendingServerFinished bool +} + +// New creates a Session in pre-handshake state. +func New(cfg Config) (*Session, error) { + if cfg.Identity == nil { + return nil, ErrIdentityRejected + } + if cfg.ReplayWindowSize == 0 { + cfg.ReplayWindowSize = 1024 + } + hs, err := newHandshake(cfg.Role, cfg.Identity, cfg.AcceptPeer) + if err != nil { + return nil, err + } + return &Session{ + cfg: cfg, + hs: hs, + recvWin: newReplayWindow(cfg.ReplayWindowSize), + }, nil +} + +// StartHandshake is called by the client to produce ClientHello. +func (s *Session) StartHandshake() ([]byte, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return nil, ErrClosed + } + if s.established { + return nil, ErrAlreadyEstablished + } + if s.cfg.Role != RoleClient { + return nil, ErrHandshakeState + } + return s.hs.buildClientHello() +} + +// HandleHandshake processes one inbound handshake message and returns an +// optional outbound response (may be nil). +// +// Server: ClientHello → ServerHello; ClientFinished → (nil, establishes keys). +// Client: ServerHello → ClientFinished; ServerFinished → (nil, establishes keys). +func (s *Session) HandleHandshake(msg []byte) (response []byte, err error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return nil, ErrClosed + } + if s.established { + return nil, ErrAlreadyEstablished + } + if len(msg) < 1 { + return nil, ErrShortMessage + } + + switch s.cfg.Role { + case RoleServer: + return s.handleServer(msg) + case RoleClient: + return s.handleClient(msg) + default: + return nil, ErrHandshakeState + } +} + +func (s *Session) handleServer(msg []byte) ([]byte, error) { + switch msg[0] { + case msgClientHello: + if err := s.hs.processClientHello(msg); err != nil { + return nil, err + } + return s.hs.buildServerHello() + case msgClientFinished: + if err := s.hs.processFinished(msg, msgClientFinished, s.hs.peerIDSlice()); err != nil { + return nil, err + } + keys, err := s.hs.deriveKeys() + if err != nil { + return nil, err + } + if err := s.installKeys(keys); err != nil { + return nil, err + } + s.pendingServerFinished = true + s.established = true + return nil, nil + default: + return nil, ErrHandshakeState + } +} + +func (s *Session) handleClient(msg []byte) ([]byte, error) { + switch msg[0] { + case msgServerHello: + if err := s.hs.processServerHello(msg); err != nil { + return nil, err + } + cf, err := s.hs.buildFinished(msgClientFinished) + if err != nil { + return nil, err + } + // Keys from transcript through ClientFinished — same point the server uses. + keys, err := s.hs.deriveKeys() + if err != nil { + return nil, err + } + if err := s.installKeys(keys); err != nil { + return nil, err + } + // Established only after ServerFinished confirms the peer finished. + return cf, nil + case msgServerFinished: + if err := s.hs.processFinished(msg, msgServerFinished, s.hs.peerIDSlice()); err != nil { + return nil, err + } + if s.keys == nil { + return nil, ErrHandshakeState + } + s.established = true + return nil, nil + default: + return nil, ErrHandshakeState + } +} + +// SendFinished returns ServerFinished (server only, after ClientFinished processed). +func (s *Session) SendFinished() ([]byte, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return nil, ErrClosed + } + if s.cfg.Role != RoleServer || !s.pendingServerFinished { + return nil, ErrHandshakeState + } + msg, err := s.hs.buildFinished(msgServerFinished) + if err != nil { + return nil, err + } + s.pendingServerFinished = false + return msg, nil +} + +func (s *Session) installKeys(keys *trafficKeys) error { + var sendKey, recvKey []byte + if s.cfg.Role == RoleClient { + sendKey, recvKey = keys.c2s[:], keys.s2c[:] + } else { + sendKey, recvKey = keys.s2c[:], keys.c2s[:] + } + sa, err := newAEAD(sendKey) + if err != nil { + return err + } + ra, err := newAEAD(recvKey) + if err != nil { + return err + } + if s.keys != nil { + s.keys.wipe() + } + s.keys = keys + s.sendAEAD = sa + s.recvAEAD = ra + s.sendSeq = 0 + s.recvWin = newReplayWindow(s.cfg.ReplayWindowSize) + return nil +} + +// Established reports whether application Seal/Open are allowed. +func (s *Session) Established() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.established && !s.closed +} + +// PeerPublicKey returns the peer identity public key after it has been received. +func (s *Session) PeerPublicKey() []byte { + s.mu.Lock() + defer s.mu.Unlock() + if s.hs == nil || !s.hs.hasPeer { + return nil + } + out := make([]byte, idPubLen) + copy(out, s.hs.peerIDPub[:]) + return out +} + +func (s *Session) Seal(lafPayload []byte) ([]byte, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return nil, ErrClosed + } + if !s.established || s.sendAEAD == nil { + return nil, ErrNotEstablished + } + seq := s.sendSeq + s.sendSeq++ + s.sealBuf = sealRecord(s.sendAEAD, seq, recordTypeData, lafPayload, s.sealBuf, &s.nonce, &s.aad) + return s.sealBuf, nil +} + +func (s *Session) SealTo(dst, lafPayload []byte) ([]byte, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return nil, ErrClosed + } + if !s.established || s.sendAEAD == nil { + return nil, ErrNotEstablished + } + seq := s.sendSeq + s.sendSeq++ + return sealRecord(s.sendAEAD, seq, recordTypeData, lafPayload, dst, &s.nonce, &s.aad), nil +} + +// Open decrypts and verifies a record frame. +func (s *Session) Open(frame []byte) ([]byte, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return nil, ErrClosed + } + if !s.established || s.recvAEAD == nil { + return nil, ErrNotEstablished + } + if len(frame) < recordHeaderLen+tagLen { + return nil, ErrShortMessage + } + seq := binary.BigEndian.Uint64(frame[1:9]) + if !s.recvWin.accept(seq) { + return nil, ErrReplay + } + recType, pt, err := openRecord(s.recvAEAD, frame, s.openBuf, &s.nonce, &s.aad) + if err != nil { + return nil, err + } + s.openBuf = pt + switch recType { + case recordTypeData: + return pt, nil + case recordTypeClose: + s.closed = true + s.wipeKeys() + return nil, ErrClosed + default: + return nil, ErrBadTag + } +} + +func (s *Session) OpenTo(dst, frame []byte) ([]byte, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return nil, ErrClosed + } + if !s.established || s.recvAEAD == nil { + return nil, ErrNotEstablished + } + if len(frame) < recordHeaderLen+tagLen { + return nil, ErrShortMessage + } + seq := binary.BigEndian.Uint64(frame[1:9]) + if !s.recvWin.accept(seq) { + return nil, ErrReplay + } + recType, pt, err := openRecord(s.recvAEAD, frame, dst, &s.nonce, &s.aad) + if err != nil { + return nil, err + } + switch recType { + case recordTypeData: + return pt, nil + case recordTypeClose: + s.closed = true + s.wipeKeys() + return nil, ErrClosed + default: + return nil, ErrBadTag + } +} + +// Close sends an authenticated close record (if established) and wipes keys. +// Write the returned frame to the peer when non-nil. +func (s *Session) Close() (frame []byte, err error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return nil, nil + } + if s.established && s.sendAEAD != nil { + seq := s.sendSeq + s.sendSeq++ + s.sealBuf = sealRecord(s.sendAEAD, seq, recordTypeClose, nil, s.sealBuf, &s.nonce, &s.aad) + frame = s.sealBuf + } + s.closed = true + s.established = false + s.wipeKeys() + return frame, err +} + +func (s *Session) wipeKeys() { + if s.keys != nil { + s.keys.wipe() + s.keys = nil + } + s.sendAEAD = nil + s.recvAEAD = nil +} diff --git a/session_bench_test.go b/session_bench_test.go new file mode 100644 index 0000000..e33d420 --- /dev/null +++ b/session_bench_test.go @@ -0,0 +1,384 @@ +package lafsession + +import ( + "testing" +) + +// setupPair runs a full handshake and returns established client and server sessions. +func setupPair(b *testing.B) (cli, srv *Session) { + b.Helper() + serverID, err := GenerateIdentity() + if err != nil { + b.Fatal(err) + } + clientID, err := GenerateIdentity() + if err != nil { + b.Fatal(err) + } + clientPub := clientID.PublicBytes() + serverPub := serverID.PublicBytes() + srv, err = New(Config{ + Identity: serverID, + Role: RoleServer, + AcceptPeer: func(pub []byte) bool { + return bytesEqual32(pub, clientPub) + }, + }) + if err != nil { + b.Fatal(err) + } + cli, err = New(Config{ + Identity: clientID, + Role: RoleClient, + AcceptPeer: func(pub []byte) bool { + return bytesEqual32(pub, serverPub) + }, + }) + if err != nil { + b.Fatal(err) + } + + ch, err := cli.StartHandshake() + if err != nil { + b.Fatal(err) + } + sh, err := srv.HandleHandshake(ch) + if err != nil { + b.Fatal(err) + } + cf, err := cli.HandleHandshake(sh) + if err != nil { + b.Fatal(err) + } + if _, err = srv.HandleHandshake(cf); err != nil { + b.Fatal(err) + } + sf, err := srv.SendFinished() + if err != nil { + b.Fatal(err) + } + if _, err = cli.HandleHandshake(sf); err != nil { + b.Fatal(err) + } + if !cli.Established() || !srv.Established() { + b.Fatal("not established") + } + return cli, srv +} + +// BenchmarkHandshake measures the full mutual-auth handshake state machine +// (message build/parse, transcript, AcceptPeer, key install). Crypto ops are +// inherent to the flow but not isolated. +func BenchmarkHandshake(b *testing.B) { + serverID, err := GenerateIdentity() + if err != nil { + b.Fatal(err) + } + clientID, err := GenerateIdentity() + if err != nil { + b.Fatal(err) + } + clientPub := clientID.PublicBytes() + serverPub := serverID.PublicBytes() + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + srv, err := New(Config{ + Identity: serverID, + Role: RoleServer, + AcceptPeer: func(pub []byte) bool { return bytesEqual32(pub, clientPub) }, + }) + if err != nil { + b.Fatal(err) + } + cli, err := New(Config{ + Identity: clientID, + Role: RoleClient, + AcceptPeer: func(pub []byte) bool { return bytesEqual32(pub, serverPub) }, + }) + if err != nil { + b.Fatal(err) + } + ch, err := cli.StartHandshake() + if err != nil { + b.Fatal(err) + } + sh, err := srv.HandleHandshake(ch) + if err != nil { + b.Fatal(err) + } + cf, err := cli.HandleHandshake(sh) + if err != nil { + b.Fatal(err) + } + if _, err = srv.HandleHandshake(cf); err != nil { + b.Fatal(err) + } + sf, err := srv.SendFinished() + if err != nil { + b.Fatal(err) + } + if _, err = cli.HandleHandshake(sf); err != nil { + b.Fatal(err) + } + } +} + +// BenchmarkSeal measures session record framing + seal path for a fixed LAF-sized payload. +func BenchmarkSeal(b *testing.B) { + cli, _ := setupPair(b) + payload := make([]byte, 256) // typical small LAF envelope + for i := range payload { + payload[i] = byte(i) + } + + b.SetBytes(int64(len(payload))) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := cli.Seal(payload); err != nil { + b.Fatal(err) + } + } +} + +// BenchmarkOpen measures open + anti-replay accept for sequential records. +func BenchmarkOpen(b *testing.B) { + cli, srv := setupPair(b) + payload := make([]byte, 256) + for i := range payload { + payload[i] = byte(i) + } + + frames := make([][]byte, b.N) + for i := 0; i < b.N; i++ { + f, err := cli.Seal(payload) + if err != nil { + b.Fatal(err) + } + frames[i] = append([]byte(nil), f...) + } + + b.SetBytes(int64(len(payload))) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := srv.Open(frames[i]); err != nil { + b.Fatal(err) + } + } +} + +// BenchmarkSealOpenRoundTrip is client Seal → server Open for one payload size. +func BenchmarkSealOpenRoundTrip(b *testing.B) { + cli, srv := setupPair(b) + payload := make([]byte, 256) + for i := range payload { + payload[i] = byte(i) + } + + b.SetBytes(int64(len(payload))) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + frame, err := cli.Seal(payload) + if err != nil { + b.Fatal(err) + } + if _, err := srv.Open(frame); err != nil { + b.Fatal(err) + } + } +} + +// BenchmarkSealOpenSizes exercises framing logic across payload sizes. +func BenchmarkSealOpenSizes(b *testing.B) { + sizes := []int{64, 256, 1024, 4096} + for _, sz := range sizes { + sz := sz + b.Run(itoa(sz), func(b *testing.B) { + cli, srv := setupPair(b) + payload := make([]byte, sz) + b.SetBytes(int64(sz)) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + frame, err := cli.Seal(payload) + if err != nil { + b.Fatal(err) + } + if _, err := srv.Open(frame); err != nil { + b.Fatal(err) + } + } + }) + } +} + +// BenchmarkReplayWindow measures only the anti-replay window accept path. +func BenchmarkReplayWindow(b *testing.B) { + w := newReplayWindow(1024) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if !w.accept(uint64(i)) { + b.Fatal("unexpected reject") + } + } +} + +// BenchmarkReplayWindowWithGaps simulates out-of-order accepts within the window. +func BenchmarkReplayWindowWithGaps(b *testing.B) { + w := newReplayWindow(1024) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + seq := uint64(i) + // deliver even first, then the preceding odd on next iteration pattern + if i%2 == 0 { + if !w.accept(seq + 1) { + // may already be past; ignore + } + } + _ = w.accept(seq) + } +} + +// BenchmarkFingerprint is package helper logic (hash + hex), not a crypto lib microbench. +func BenchmarkFingerprint(b *testing.B) { + id, err := GenerateIdentity() + if err != nil { + b.Fatal(err) + } + pub := id.PublicBytes() + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = FingerprintOf(pub) + } +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + var buf [16]byte + i := len(buf) + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + return string(buf[i:]) +} + +// BenchmarkSealToOpenTo measures steady-state 0-alloc path with caller-owned buffers. +func BenchmarkSealToOpenTo(b *testing.B) { + cli, srv := setupPair(b) + payload := make([]byte, 256) + for i := range payload { + payload[i] = byte(i) + } + // capacity: header + pt + tag + sealDst := make([]byte, 0, recordHeaderLen+len(payload)+tagLen) + openDst := make([]byte, 0, len(payload)) + + b.SetBytes(int64(len(payload))) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + frame, err := cli.SealTo(sealDst[:0], payload) + if err != nil { + b.Fatal(err) + } + // must copy frame before OpenTo if we reuse sealDst; use independent buffer + pt, err := srv.OpenTo(openDst[:0], frame) + if err != nil { + b.Fatal(err) + } + if len(pt) != len(payload) { + b.Fatal("len") + } + // frame still points into sealDst; next SealTo with [:0] reuses same cap + _ = frame + } +} + +// BenchmarkSealSteady uses Seal after warm-up; internal scratch avoids allocs. +func BenchmarkSealSteady(b *testing.B) { + cli, _ := setupPair(b) + payload := make([]byte, 256) + // warm-up grow + if _, err := cli.Seal(payload); err != nil { + b.Fatal(err) + } + b.SetBytes(int64(len(payload))) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := cli.Seal(payload); err != nil { + b.Fatal(err) + } + } +} + +// BenchmarkOpenSteady uses Open after warm-up with pre-copied frames. +func BenchmarkOpenSteady(b *testing.B) { + cli, srv := setupPair(b) + payload := make([]byte, 256) + frames := make([][]byte, b.N) + for i := 0; i < b.N; i++ { + f, err := cli.Seal(payload) + if err != nil { + b.Fatal(err) + } + frames[i] = append([]byte(nil), f...) + } + // warm openBuf + if _, err := srv.Open(frames[0]); err != nil && err != ErrReplay { + // first frame opens; remaining still valid if seq advances + } + // re-setup pair for clean window + cli, srv = setupPair(b) + frames = make([][]byte, b.N) + for i := 0; i < b.N; i++ { + f, err := cli.Seal(payload) + if err != nil { + b.Fatal(err) + } + frames[i] = append([]byte(nil), f...) + } + // warm + if _, err := srv.Open(append([]byte(nil), frames[0]...)); err == nil { + // consumed seq 0; rebuild + } + cli, srv = setupPair(b) + frames = make([][]byte, b.N) + for i := 0; i < b.N; i++ { + f, _ := cli.Seal(payload) + frames[i] = append([]byte(nil), f...) + } + if _, err := srv.Open(frames[0]); err != nil { + b.Fatal(err) + } + b.SetBytes(int64(len(payload))) + b.ReportAllocs() + b.ResetTimer() + for i := 1; i < b.N; i++ { + if _, err := srv.Open(frames[i]); err != nil { + b.Fatal(err) + } + } +} + +func bytesEqual32(a, b []byte) bool { + if len(a) != 32 || len(b) != 32 { + return false + } + var v byte + for i := 0; i < 32; i++ { + v |= a[i] ^ b[i] + } + return v == 0 +} diff --git a/session_test.go b/session_test.go new file mode 100644 index 0000000..b4c83e1 --- /dev/null +++ b/session_test.go @@ -0,0 +1,145 @@ +package lafsession + +import ( + "bytes" + "testing" +) + +func TestHandshakeAndSealOpen(t *testing.T) { + serverID, err := GenerateIdentity() + if err != nil { + t.Fatal(err) + } + clientID, err := GenerateIdentity() + if err != nil { + t.Fatal(err) + } + + serverPin := make(map[string]bool) + serverPin[FingerprintOf(clientID.PublicBytes())] = true + clientPin := make(map[string]bool) + clientPin[FingerprintOf(serverID.PublicBytes())] = true + + srv, err := New(Config{ + Identity: serverID, + Role: RoleServer, + AcceptPeer: func(pub []byte) bool { + return serverPin[FingerprintOf(pub)] + }, + }) + if err != nil { + t.Fatal(err) + } + cli, err := New(Config{ + Identity: clientID, + Role: RoleClient, + AcceptPeer: func(pub []byte) bool { + return clientPin[FingerprintOf(pub)] + }, + }) + if err != nil { + t.Fatal(err) + } + + // Client → Server: ClientHello + ch, err := cli.StartHandshake() + if err != nil { + t.Fatal(err) + } + + // Server → Client: ServerHello + sh, err := srv.HandleHandshake(ch) + if err != nil { + t.Fatal(err) + } + if sh == nil { + t.Fatal("expected ServerHello") + } + + // Client → Server: ClientFinished + cf, err := cli.HandleHandshake(sh) + if err != nil { + t.Fatal(err) + } + if cf == nil { + t.Fatal("expected ClientFinished") + } + + _, err = srv.HandleHandshake(cf) + if err != nil { + t.Fatal(err) + } + if !srv.Established() { + t.Fatal("server should be established after ClientFinished") + } + + // Server → Client: ServerFinished + sf, err := srv.SendFinished() + if err != nil { + t.Fatal(err) + } + _, err = cli.HandleHandshake(sf) + if err != nil { + t.Fatal(err) + } + if !cli.Established() { + t.Fatal("client should be established after ServerFinished") + } + + // Application data: LAF payload (opaque) + laf := []byte{0x05, 0x80, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0xde, 0xad, 0xbe, 0xef} + + frame, err := cli.Seal(laf) + if err != nil { + t.Fatal(err) + } + got, err := srv.Open(frame) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, laf) { + t.Fatalf("payload mismatch: got %x want %x", got, laf) + } + + frame2, err := srv.Seal(laf) + if err != nil { + t.Fatal(err) + } + got2, err := cli.Open(frame2) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got2, laf) { + t.Fatalf("payload mismatch c2s") + } + + // Replay must fail + _, err = srv.Open(frame) + if err != ErrReplay { + t.Fatalf("expected ErrReplay, got %v", err) + } +} + +func TestRejectUnknownPeer(t *testing.T) { + serverID, _ := GenerateIdentity() + clientID, _ := GenerateIdentity() + + srv, _ := New(Config{ + Identity: serverID, + Role: RoleServer, + AcceptPeer: func(pub []byte) bool { + return false // reject all + }, + }) + cli, _ := New(Config{ + Identity: clientID, + Role: RoleClient, + AcceptPeer: func(pub []byte) bool { return true }, + }) + + ch, _ := cli.StartHandshake() + _, err := srv.HandleHandshake(ch) + if err != ErrIdentityRejected { + t.Fatalf("expected ErrIdentityRejected, got %v", err) + } +}