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