From 6b509096179aebedc00e32d0ab54d5a6cc725fcc Mon Sep 17 00:00:00 2001 From: wkgit Date: Wed, 23 Sep 2026 14:35:30 +0200 Subject: [PATCH] Initial commit --- .gitattributes | 2 + .gitignore | 25 ++ .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 | 87 +++++ errors.go | 17 + go.mod | 7 + go.sum | 4 + handshake.go | 295 +++++++++++++++ identity.go | 76 ++++ keys.go | 61 ++++ record.go | 73 ++++ replay.go | 79 ++++ session.go | 335 +++++++++++++++++ session_bench_test.go | 359 +++++++++++++++++++ session_test.go | 139 +++++++ 20 files changed, 1621 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore 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 README.md 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 new file mode 100644 index 0000000..6f72f89 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# 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 +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work +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 new file mode 100644 index 0000000..88b598c --- /dev/null +++ b/README.md @@ -0,0 +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 +wkgit.wk19.lol/wkgit/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..a62a07b --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module wkgit.wk19.lol/wkgit/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..29f913f --- /dev/null +++ b/handshake.go @@ -0,0 +1,295 @@ +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 + + helloMsgLen = 1 + 2 + 2 + ephPubLen + idPubLen + sigLen + + finishedMsgLen = 1 + sigLen + + transcriptCap = helloMsgLen*2 + finishedMsgLen*2 +) + +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 + hashBuf [32]byte + labelBuf [1 + 32]byte + msgBuf []byte + + 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 { + return h.hasher.Sum(h.hashBuf[:0]) +} + +func (h *handshakeState) writeTranscript(p []byte) { + h.transcript = append(h.transcript, p...) + _, _ = h.hasher.Write(p) +} + +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 + 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] + 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 + + 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] + 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..5a99c85 --- /dev/null +++ b/identity.go @@ -0,0 +1,76 @@ +package lafsession + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" +) + +type Identity struct { + Private ed25519.PrivateKey + Public ed25519.PublicKey +} + +func GenerateIdentity() (*Identity, error) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, err + } + return &Identity{Private: priv, Public: pub}, nil +} + +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 +} + +func (id *Identity) PublicBytes() []byte { + return append([]byte(nil), id.Public...) +} + +func (id *Identity) Fingerprint() string { + sum := sha256.Sum256(id.Public) + return hex.EncodeToString(sum[:]) +} + +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) +} + +func (id *Identity) Sign(msg []byte) []byte { + return ed25519.Sign(id.Private, msg) +} + +func (id *Identity) Verify(msg, sig []byte) bool { + return ed25519.Verify(id.Public, msg, sig) +} + +func VerifyPeer(peerPub, msg, sig []byte) bool { + if len(peerPub) != ed25519.PublicKeySize { + return false + } + return ed25519.Verify(peerPub, msg, sig) +} + +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..00855fb --- /dev/null +++ b/keys.go @@ -0,0 +1,61 @@ +package lafsession + +import ( + "crypto/sha256" + "io" + + "golang.org/x/crypto/chacha20poly1305" + "golang.org/x/crypto/hkdf" +) + +const ( + keyLen = chacha20poly1305.KeySize + nonceLen = chacha20poly1305.NonceSize + tagLen = 16 +) + +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..5bf1a5e --- /dev/null +++ b/record.go @@ -0,0 +1,73 @@ +package lafsession + +import ( + "crypto/cipher" + "encoding/binary" + + "golang.org/x/crypto/chacha20poly1305" +) + +const ( + recordHeaderLen = 1 + 8 + + recordTypeData byte = 0x10 + recordTypeClose byte = 0x11 +) + +func newAEAD(key []byte) (cipher.AEAD, error) { + return chacha20poly1305.New(key) +} + +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..0abd8ba --- /dev/null +++ b/replay.go @@ -0,0 +1,79 @@ +package lafsession + +type replayWindow struct { + maxSeq uint64 + size uint64 + bits []uint64 +} + +func newReplayWindow(size uint64) *replayWindow { + if size < 64 { + size = 64 + } + words := (size + 63) / 64 + return &replayWindow{ + size: size, + bits: make([]uint64, words), + } +} + +func (w *replayWindow) accept(seq uint64) bool { + if seq+w.size <= w.maxSeq { + return false + } + if seq > w.maxSeq { + shift := seq - w.maxSeq + if shift >= w.size { + 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 + } + 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 + } + for i := uint64(len(w.bits)); i > words; i-- { + } + 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..b7e3212 --- /dev/null +++ b/session.go @@ -0,0 +1,335 @@ +package lafsession + +import ( + "crypto/cipher" + "encoding/binary" + "sync" +) + +type Config struct { + Identity *Identity + + Role HandshakeRole + + AcceptPeer func(peerPub []byte) bool + + ReplayWindowSize uint64 +} + +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 +} + +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 +} + +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() +} + +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, err := s.hs.deriveKeys() + if err != nil { + return nil, err + } + if err := s.installKeys(keys); err != nil { + return nil, err + } + 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 + } +} + +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 +} + +func (s *Session) Established() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.established && !s.closed +} + +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 +} + +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 + } +} + +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..5112436 --- /dev/null +++ b/session_bench_test.go @@ -0,0 +1,359 @@ +package lafsession + +import ( + "testing" +) + +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 +} + +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) + } + } +} + +func BenchmarkSeal(b *testing.B) { + cli, _ := 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++ { + if _, err := cli.Seal(payload); err != nil { + b.Fatal(err) + } + } +} + +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) + } + } +} + +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) + } + } +} + +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) + } + } + }) + } +} + +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") + } + } +} + +func BenchmarkReplayWindowWithGaps(b *testing.B) { + w := newReplayWindow(1024) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + seq := uint64(i) + if i%2 == 0 { + if !w.accept(seq + 1) { + } + } + _ = w.accept(seq) + } +} + +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:]) +} + +func BenchmarkSealToOpenTo(b *testing.B) { + cli, srv := setupPair(b) + payload := make([]byte, 256) + for i := range payload { + payload[i] = byte(i) + } + 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) + } + pt, err := srv.OpenTo(openDst[:0], frame) + if err != nil { + b.Fatal(err) + } + if len(pt) != len(payload) { + b.Fatal("len") + } + _ = frame + } +} + +func BenchmarkSealSteady(b *testing.B) { + cli, _ := setupPair(b) + payload := make([]byte, 256) + 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) + } + } +} + +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...) + } + if _, err := srv.Open(frames[0]); err != nil && err != ErrReplay { + } + 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...) + } + if _, err := srv.Open(append([]byte(nil), frames[0]...)); err == nil { + } + 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..b059f65 --- /dev/null +++ b/session_test.go @@ -0,0 +1,139 @@ +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) + } + + ch, err := cli.StartHandshake() + if err != nil { + t.Fatal(err) + } + + sh, err := srv.HandleHandshake(ch) + if err != nil { + t.Fatal(err) + } + if sh == nil { + t.Fatal("expected ServerHello") + } + + 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") + } + + 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") + } + + 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") + } + + _, 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 + }, + }) + 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) + } +}