This commit is contained in:
wuka
2026-09-23 16:09:17 +02:00
parent 801346ebd5
commit bb1e3cf140
20 changed files with 1692 additions and 2 deletions

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto

2
.gitignore vendored
View File

@@ -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

5
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,5 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/

10
.idea/go.imports.xml generated Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GoImports">
<option name="excludedPackages">
<array>
<option value="golang.org/x/net/context" />
</array>
</option>
</component>
</project>

View File

@@ -0,0 +1,24 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="CssNonIntegerLengthInPixels" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
<inspection_tool class="GoUnhandledErrorResult" enabled="false" level="WARNING" enabled_by_default="false">
<methods>
<method importPath="strings" receiver="*Builder" name="WriteByte" />
<method importPath="bytes" receiver="*Buffer" name="WriteRune" />
<method importPath="strings" receiver="*Builder" name="WriteString" />
<method importPath="math/rand" receiver="*Rand" name="Read" />
<method importPath="strings" receiver="*Builder" name="Write" />
<method importPath="bytes" receiver="*Buffer" name="WriteByte" />
<method importPath="bytes" receiver="*Buffer" name="Write" />
<method importPath="strings" receiver="*Builder" name="WriteRune" />
<method importPath="bytes" receiver="*Buffer" name="WriteString" />
<method importPath="hash" receiver="Hash" name="Write" />
<method importPath="encoding/json" receiver="*Encoder" name="Encode" />
</methods>
</inspection_tool>
<inspection_tool class="HtmlFormInputWithoutLabel" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="JSUnresolvedReference" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
<inspection_tool class="SqlNoDataSourceInspection" enabled="false" level="WARNING" enabled_by_default="false" />
</profile>
</component>

9
.idea/lafsession.iml generated Normal file
View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" />
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/lafsession.iml" filepath="$PROJECT_DIR$/.idea/lafsession.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View File

@@ -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.

17
errors.go Normal file
View File

@@ -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")
)

7
go.mod Normal file
View File

@@ -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

4
go.sum Normal file
View File

@@ -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=

304
handshake.go Normal file
View File

@@ -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[:]
}

86
identity.go Normal file
View File

@@ -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[:])
}

62
keys.go Normal file
View File

@@ -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[:])
}

76
record.go Normal file
View File

@@ -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
}

85
replay.go Normal file
View File

@@ -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
}
}

373
session.go Normal file
View File

@@ -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
}

384
session_bench_test.go Normal file
View File

@@ -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
}

145
session_test.go Normal file
View File

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