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)
+ }
+}