implement handshake

This commit is contained in:
Dustin Stiles 2025-03-08 15:07:27 -05:00
parent dc892f6ac4
commit 9f35fd4025
Signed by: duwstiles
GPG Key ID: BCD9912EC231FC87
30 changed files with 1309 additions and 266 deletions

View File

@ -0,0 +1,25 @@
name: test
on:
workflow_dispatch:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: 1.24
- name: Run Go Vet
run: go vet ./...
- name: Run Go Test
run: go test -v -race ./...

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
bin

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Dustin Stiles
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

101
README.md
View File

@ -1 +1,100 @@
# DSFX # dsfx: Distributed Secure File Exchange
dsfx is a robust, secure, and distributed file exchange system written in Go. Designed from the ground up for safety, performance, and ease of maintenance, dsfx enables encrypted file transfers between nodes in a distributed network. Its streamlined architecture ensures that file exchanges are both secure and efficient.
---
## Features
- **End-to-End Security:** Uses modern cryptographic primitives to ensure that all file exchanges are encrypted and authenticated.
- **Distributed Architecture:** Designed for secure file exchange across multiple nodes with built-in support for key-based authentication.
- **High Performance:** Optimized for low latency and high throughput, with a focus on reliable and predictable behavior.
- **Administrative and Metrics Interface:** The dsfx client is not only used for file exchange but also to obtain real-time metrics and perform administrative actions against the dsfx server.
- **Easy Integration:** Built in Go with minimal external dependencies, dsfx is simple to deploy and integrate into existing systems.
---
## Installation
### Prerequisites
- Go 1.24 or later is required.
- Git
### Build from Source
Clone the repository:
```bash
git clone https://koti.casa/numenor-labs/dsfx.git
cd dsfx
```
Build the project:
```bash
go build -o build-path-here ./...
```
Install dsfx on your system (optional):
```bash
go install ./...
```
---
## Usage
### Starting the Server
To start a dsfx server, run:
```bash
dsfx-server -config /path/to/server/config.yaml
```
The server listens for incoming secure file exchange requests on the defined TCP port. Configuration options (such as server keys, port, and logging settings) can be adjusted in the configuration file.
### Running the Client
The dsfx client is used to obtain metrics and perform administrative actions against the server. For example:
```bash
dsfx-client -config /path/to/client/config.yaml -action metrics
dsfx-client -config /path/to/client/config.yaml -action admin -command "restart"
```
The client uses the servers public key to verify its identity before initiating any admin actions or retrieving metrics.
### Command-Line Options
Both the server and client come with a set of command-line flags. Use the help flag for detailed options:
```bash
./dsfx-server -h
./dsfx-client -h
```
---
## Contributing
Contributions to dsfx are welcome and encouraged!
### How to Contribute
1. **Fork the Repository:** Create your own branch from the latest code in `main`.
2. **Make Your Changes:** Follow the [TigerStyle for Go](./TIGERSTYLE.md) guidelines to ensure code consistency and safety.
3. **Write Tests:** Ensure new features and bug fixes include proper tests.
4. **Submit a Pull Request:** Document your changes clearly in your pull request.
### Reporting Issues
Please use the Git repository's issue tracker to report bugs or suggest new features. Include as much detail as possible to help reproduce and address the issue.
---
## License
dsfx is distributed under the MIT License. See [LICENSE](LICENSE) for details.

31
cmd/genid/main.go Normal file
View File

@ -0,0 +1,31 @@
package main
import (
"crypto/x509"
"encoding/pem"
"fmt"
"os"
"koti.casa/numenor-labs/dsfx/shared/dcrypto"
)
func main() {
key, err := dcrypto.GenerateSigningKey()
if err != nil {
panic(err)
}
// Encode the private key to der
der, err := x509.MarshalECPrivateKey(key)
if err != nil {
panic(err)
}
// Encode the private key to PEM
pem := pem.EncodeToMemory(&pem.Block{
Type: "PRIVATE KEY",
Bytes: der,
})
fmt.Fprint(os.Stdout, string(pem))
}

6
config.json Normal file
View File

@ -0,0 +1,6 @@
{
"host": "127.0.0.1",
"port": 8000,
"dataDirectory": "/home/dustin/.dsfx/data",
"masterKeyPath": "/home/dustin/.dsfx/masterkey"
}

29
dsfx-client/config.go Normal file
View File

@ -0,0 +1,29 @@
package main
import (
"crypto/ecdsa"
"crypto/x509"
"encoding/pem"
"fmt"
"os"
)
func LoadMasterKey(path string) (*ecdsa.PrivateKey, error) {
masterKeyFile, err := os.ReadFile(path)
if err != nil {
return nil, err
}
// The second argument is not an error.
derEncoded, _ := pem.Decode(masterKeyFile)
if derEncoded == nil {
return nil, fmt.Errorf("failed to decode master key file")
}
masterKey, err := x509.ParseECPrivateKey(derEncoded.Bytes)
if err != nil {
return nil, err
}
return masterKey, nil
}

98
dsfx-client/main.go Normal file
View File

@ -0,0 +1,98 @@
package main
import (
"context"
"crypto/ecdsa"
"flag"
"fmt"
"log/slog"
"net"
"os"
"koti.casa/numenor-labs/dsfx/shared/dlog"
"koti.casa/numenor-labs/dsfx/shared/dnet"
)
func main() {
ctx := context.Background()
// ---------------------------------------------------------------------------
// Logger
opts := &slog.HandlerOptions{
AddSource: false,
Level: slog.LevelDebug,
}
logger := slog.New(slog.NewTextHandler(os.Stdout, opts))
// Everything in the application will attempt to use the logger in stored in
// the context, but we also set the default with slog as a fallback. In cases
// where the context is not available, or the context is not a child of the
// context with the logger, the default logger will be used.
slog.SetDefault(logger)
ctx = dlog.WithContext(ctx, logger)
// ---------------------------------------------------------------------------
// Commands
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage: %s [command] [args]\n", os.Args[0])
fmt.Fprintf(os.Stderr, "Commands:\n")
fmt.Fprintf(os.Stderr, " test <remote_addr> Test the connection to the server\n")
fmt.Fprintf(os.Stderr, "Flags:\n")
flag.PrintDefaults()
}
flagKey := flag.String("key", "", "the path to the private key file")
flag.Parse()
if *flagKey == "" {
logger.ErrorContext(ctx, "private key path is required")
os.Exit(1)
}
masterKey, err := LoadMasterKey(*flagKey)
if err != nil {
logger.ErrorContext(ctx, "failed to load private key", slog.Any("error", err))
os.Exit(1)
}
switch flag.Arg(0) {
case "test":
raddr := flag.Arg(1)
if raddr == "" {
logger.ErrorContext(ctx, "no remote address provided")
os.Exit(1)
}
testConnection(ctx, raddr, masterKey)
case "":
logger.InfoContext(ctx, "no command provided")
os.Exit(1)
default:
logger.InfoContext(ctx, "unknown command")
os.Exit(1)
}
}
func testConnection(ctx context.Context, raddr string, clientPrivateKey *ecdsa.PrivateKey) {
logger := dlog.FromContext(context.Background())
serverAddr, err := dnet.ParseAddr(raddr)
if err != nil {
logger.ErrorContext(ctx, "failed to parse server address", slog.Any("error", err))
return
}
tcpAddr := &net.TCPAddr{
IP: serverAddr.IP(),
Port: serverAddr.Port(),
}
conn, err := dnet.Dial(ctx, nil, tcpAddr, clientPrivateKey, serverAddr.PublicKey())
if err != nil {
logger.ErrorContext(ctx, "failed to connect", slog.Any("error", err))
return
}
defer conn.Close()
}

6
dsfx-client/masterkey Normal file
View File

@ -0,0 +1,6 @@
-----BEGIN PRIVATE KEY-----
MIGkAgEBBDAyJe83G1ZRqJ+kdngI9bwCnTJLrby+1sHIC+aB7OLuuIZXBHkA4fPs
1owMaoDSZ66gBwYFK4EEACKhZANiAAR0D64K3NuL8+pLnfKJex9aBd9xWlzCdpCI
C3IyrunWIIeXzcIPCfD4OiMtIkBD6jjOmJUKHMIzVQYr4isUa3z5j5va0n0if0+I
1P1X2FU27eVK1AvUxR7OUI1OaJX23GQ=
-----END PRIVATE KEY-----

52
dsfx-server/config.go Normal file
View File

@ -0,0 +1,52 @@
package main
import (
"crypto/ecdsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"os"
)
type Config struct {
Host string `json:"host"`
Port int `json:"port"`
MasterKeyPath string `json:"masterKeyPath"`
DataDirectory string `json:"dataDirectory"`
}
// LoadConfig loads the configuration from the given path.
func LoadConfig(path string) (*Config, error) {
var config Config
configFile, err := os.Open(path)
if err != nil {
return nil, err
}
defer configFile.Close()
err = json.NewDecoder(configFile).Decode(&config)
if err != nil {
return nil, err
}
return &config, nil
}
func LoadMasterKey(path string) (*ecdsa.PrivateKey, error) {
masterKeyFile, err := os.ReadFile(path)
if err != nil {
return nil, err
}
// The second argument is not an error.
derEncoded, _ := pem.Decode(masterKeyFile)
if derEncoded == nil {
return nil, fmt.Errorf("failed to decode master key file")
}
masterKey, err := x509.ParseECPrivateKey(derEncoded.Bytes)
if err != nil {
return nil, err
}
return masterKey, nil
}

100
dsfx-server/main.go Normal file
View File

@ -0,0 +1,100 @@
package main
import (
"context"
"flag"
"fmt"
"log/slog"
"net"
"os"
"koti.casa/numenor-labs/dsfx/shared/dlog"
"koti.casa/numenor-labs/dsfx/shared/dnet"
)
var (
flagHost = flag.String("host", "localhost", "the host to listen on")
flagPort = flag.Int("port", 8000, "the port to listen on")
flagMasterKey = flag.String("masterKey", "", "the path to the master key file")
)
func main() {
ctx := context.Background()
// ---------------------------------------------------------------------------
// Logger
opts := &slog.HandlerOptions{
AddSource: false,
Level: slog.LevelDebug,
}
logger := slog.New(slog.NewTextHandler(os.Stdout, opts))
// Everything in the application will attempt to use the logger in stored in
// the context, but we also set the default with slog as a fallback. In cases
// where the context is not available, or the context is not a child of the
// context with the logger, the default logger will be used.
slog.SetDefault(logger)
ctx = dlog.WithContext(ctx, logger)
// ---------------------------------------------------------------------------
// Flags
flag.Parse()
addrRaw := net.JoinHostPort(*flagHost, fmt.Sprint(*flagPort))
addr, err := net.ResolveTCPAddr("tcp", addrRaw)
if err != nil {
slog.ErrorContext(ctx, "invalid host or port")
os.Exit(1)
}
if *flagMasterKey == "" {
slog.ErrorContext(ctx, "master key path is required")
os.Exit(1)
}
masterKey, err := LoadMasterKey(*flagMasterKey)
if err != nil {
logger.ErrorContext(ctx, "failed to load master key", slog.Any("error", err))
os.Exit(1)
}
// ---------------------------------------------------------------------------
// Listener
listener, err := dnet.Listen(ctx, addr, masterKey)
if err != nil {
logger.ErrorContext(ctx, "listener error", slog.Any("error", err))
os.Exit(1)
}
logger.InfoContext(ctx, "listener created", slog.String("address", listener.Addr().String()))
for {
conn, err := listener.Accept()
if err != nil {
logger.ErrorContext(ctx, "accept failure", slog.Any("error", err))
continue
}
go handleConnection(ctx, conn)
}
}
func handleConnection(ctx context.Context, conn net.Conn) error {
defer conn.Close()
logger := dlog.FromContext(ctx)
msg := make([]byte, 1024)
n, err := conn.Read(msg)
if err != nil {
logger.ErrorContext(ctx, "failed to read from connection", slog.Any("error", err))
return err
}
logger.InfoContext(ctx, "received msg", slog.Int("bytes", n))
return nil
}

View File

@ -1,9 +0,0 @@
package main
import (
"log"
)
func main() {
log.Println("Hello, World!")
}

8
pkg/assert/assert.go Normal file
View File

@ -0,0 +1,8 @@
package assert
// Assert panics if the given truth value is false, with the given message.
func Assert(truth bool, msg string) {
if !truth {
panic(msg)
}
}

View File

@ -1,55 +0,0 @@
// Package vwb provides utilities for working with variable-width byte slices,
// usually in the context of network requests.
package vwb
import (
"fmt"
"io"
)
func debugf(msg string, args ...any) {
fmt.Printf("(encoding/vwb): "+msg, args...)
}
// Encode ...
func Encode(w io.Writer, input []byte) error {
debugf("encoding %d bytes\n", len(input))
buf := make([]byte, 0, len(input)+2)
// Write the length of the input
buf = append(buf, byte(len(input)>>8), byte(len(input)))
// Write the input
buf = append(buf, input...)
debugf("writing %d bytes\n", len(buf))
// Write the buffer to the writer
if _, err := w.Write(buf); err != nil {
return err
}
return nil
}
// Decode ...
func Decode(r io.Reader) ([]byte, error) {
debugf("beginning decode\n")
var lenbuf [2]byte
// Read the length of the input
if _, err := io.ReadFull(r, lenbuf[:]); err != nil {
return nil, err
}
debugf("read %d bytes for length\n", lenbuf)
// Calculate the length of the input
length := int(lenbuf[0])<<8 | int(lenbuf[1])
debugf("reading %d byte sized message\n", length)
// Read the input
data := make([]byte, length)
if _, err := io.ReadFull(r, data); err != nil {
return nil, err
}
debugf("done\n")
return data, nil
}

View File

@ -1,90 +0,0 @@
package handshake_test
import (
"bytes"
"crypto/ecdsa"
"log"
"net"
"sync"
"testing"
"koti.casa/numenor-labs/dsfx/pkg/dcrypto"
"koti.casa/numenor-labs/dsfx/pkg/handshake"
)
func TestHandshake(t *testing.T) {
var wg sync.WaitGroup
client, server := net.Pipe()
defer client.Close()
defer server.Close()
alice := newActor()
bob := newActor()
var aliceSharedSecret []byte
var aliceErr error
var bobSharedSecret []byte
var bobErr error
wg.Add(2)
go func() {
aliceSharedSecret, aliceErr = handshake.InitiateHandshake(alice, client, &bob.IdentityKey().PublicKey)
wg.Done()
}()
go func() {
bobSharedSecret, bobErr = handshake.AcceptHandshake(bob, server)
wg.Done()
}()
wg.Wait()
if aliceErr != nil || bobErr != nil {
if aliceErr != nil {
t.Errorf("alice error: %v", aliceErr)
return
}
t.Errorf("bob error: %v", bobErr)
return
}
if aliceSharedSecret == nil || bobSharedSecret == nil {
t.Errorf("handshake failed: shared secret is nil")
return
}
if len(aliceSharedSecret) == 0 || len(bobSharedSecret) == 0 {
t.Errorf("handshake failed: shared secret is empty")
}
if !bytes.Equal(aliceSharedSecret, bobSharedSecret) {
t.Errorf("handshake failed: shared secrets do not match")
return
}
}
// An actor represents an entity that can participate in the handshake process.
type actor struct {
identity *ecdsa.PrivateKey
}
// newActor creates a new actor with a random identity key.
func newActor() *actor {
key, err := dcrypto.GenerateSigningKey()
if err != nil {
log.Fatal(err)
}
return &actor{
identity: key,
}
}
// IdentityKey ...
func (a *actor) IdentityKey() *ecdsa.PrivateKey {
return a.identity
}
// Address returns the address of the actor. This is used when listening for
// incoming connections, and when dialing out to other actors.
func (a *actor) Address() *net.TCPAddr {
return nil
}

24
shared/dlog/dlog.go Normal file
View File

@ -0,0 +1,24 @@
package dlog
import (
"context"
"log/slog"
)
type contextKey int
var ctxKey contextKey = 1
// FromContext retrieves the slog.Logger from the context.
func FromContext(ctx context.Context) *slog.Logger {
logger, ok := ctx.Value(ctxKey).(*slog.Logger)
if !ok {
return slog.Default()
}
return logger
}
// WithContext ...
func WithContext(ctx context.Context, logger *slog.Logger) context.Context {
return context.WithValue(ctx, ctxKey, logger)
}

97
shared/dnet/addr.go Normal file
View File

@ -0,0 +1,97 @@
package dnet
import (
"crypto/ecdsa"
"encoding/base64"
"errors"
"fmt"
"net"
"strings"
"koti.casa/numenor-labs/dsfx/shared/dcrypto"
)
var (
ErrInvalidFormat = errors.New("address must be in the format 'dsfx://<ip>:<port>#<publicKey>'")
)
// Addr is a wrapper around net.Addr that adds a public key to the address. This
// means that all connections between two nodes can be verified by their public
// keys.
type Addr struct {
network string
ip net.IP
port int
publicKey *ecdsa.PublicKey
}
// NewAddr creates a new Addr.
func NewAddr(ip net.IP, port int, publicKey *ecdsa.PublicKey) *Addr {
network := "dsfx"
return &Addr{network, ip, port, publicKey}
}
func ParseAddr(addrRaw string) (*Addr, error) {
addrWoNet := strings.ReplaceAll(addrRaw, "dsfx://", "")
parts := strings.Split(addrWoNet, "#")
if len(parts) != 2 {
return nil, ErrInvalidFormat
}
addr := parts[0]
publicKeyBase64 := parts[1]
parts = strings.Split(addr, ":")
if len(parts) != 2 {
return nil, ErrInvalidFormat
}
ip := net.ParseIP(parts[0])
if ip == nil {
return nil, ErrInvalidFormat
}
port, err := net.LookupPort("tcp", parts[1])
if err != nil {
return nil, ErrInvalidFormat
}
publicKeyBytes, err := base64.StdEncoding.DecodeString(publicKeyBase64)
if err != nil {
return nil, ErrInvalidFormat
}
publicKey, err := dcrypto.ImportPublicSigningKey(publicKeyBytes)
if err != nil {
return nil, ErrInvalidFormat
}
network := "dsfx"
return &Addr{network, ip, port, publicKey}, nil
}
// Network implements net.Addr.
func (a *Addr) Network() string {
return a.network
}
// String implements net.Addr.
func (a *Addr) String() string {
exported, _ := dcrypto.ExportPublicSigningKey(a.publicKey)
exportedBase64 := base64.StdEncoding.EncodeToString(exported)
return fmt.Sprintf("%s://%s:%d#%s", a.network, a.ip, a.port, exportedBase64)
}
// IP returns the IP address of the Addr.
func (a *Addr) IP() net.IP {
return a.ip
}
// Port returns the port of the Addr.
func (a *Addr) Port() int {
return a.port
}
// PublicKey returns the public key of the Addr.
func (a *Addr) PublicKey() *ecdsa.PublicKey {
return a.publicKey
}

91
shared/dnet/conn.go Normal file
View File

@ -0,0 +1,91 @@
package dnet
import (
"crypto/ecdsa"
"net"
"time"
"koti.casa/numenor-labs/dsfx/shared/dcrypto"
)
// Conn is a wrapper around net.TCPConn that encrypts and decrypts data as it is
// read and written. Conn is a valid implementation of net.Conn.
type Conn struct {
conn *net.TCPConn
sessionKey []byte
localIdentity *ecdsa.PublicKey
remoteIdentity *ecdsa.PublicKey
}
// NewConn creates a new Conn.
func NewConn(conn *net.TCPConn, sessionKey []byte, localIdentity, remoteIdentity *ecdsa.PublicKey) *Conn {
return &Conn{conn, sessionKey, localIdentity, remoteIdentity}
}
// Read implements io.Reader.
// Please note that number of bytes returned is the length of the decrypted data.
// The ciphertext that is actually transferred over the network is larger, so you
// should not rely on this number as an indication of network metrics.
func (c *Conn) Read(b []byte) (int, error) {
f := NewFrame(nil)
_, err := f.ReadFrom(c.conn)
if err != nil {
return 0, err
}
plaintext, err := dcrypto.Decrypt(c.sessionKey, f.Contents())
if err != nil {
return 0, err
}
copy(b, plaintext)
return len(plaintext), nil
}
// Write implements io.Writer.
func (c *Conn) Write(b []byte) (int, error) {
ciphertext, err := dcrypto.Encrypt(c.sessionKey, b)
if err != nil {
return 0, err
}
_, err = NewFrame(ciphertext).WriteTo(c.conn)
if err != nil {
return 0, err
}
return len(b), nil
}
// Close implements io.Closer.
func (c *Conn) Close() error {
// x-security: clear key to mitigate memory attacks
c.sessionKey = nil
c.localIdentity = nil
c.remoteIdentity = nil
return c.conn.Close()
}
// LocalAddr implements net.Conn.
func (c *Conn) LocalAddr() net.Addr {
raddr := c.conn.RemoteAddr().(*net.TCPAddr)
return NewAddr(raddr.IP, raddr.Port, c.localIdentity)
}
// RemoteAddr implements net.Conn.
func (c *Conn) RemoteAddr() net.Addr {
raddr := c.conn.RemoteAddr().(*net.TCPAddr)
return NewAddr(raddr.IP, raddr.Port, c.remoteIdentity)
}
// SetDeadline implements net.Conn.
func (c *Conn) SetDeadline(t time.Time) error {
return c.conn.SetDeadline(t)
}
// SetReadDeadline implements net.Conn.
func (c *Conn) SetReadDeadline(t time.Time) error {
return c.conn.SetReadDeadline(t)
}
// SetWriteDeadline implements net.Conn.
func (c *Conn) SetWriteDeadline(t time.Time) error {
return c.conn.SetWriteDeadline(t)
}

46
shared/dnet/dnet.go Normal file
View File

@ -0,0 +1,46 @@
package dnet
import (
"context"
"crypto/ecdsa"
"net"
"koti.casa/numenor-labs/dsfx/shared/dlog"
)
// Dial ...
func Dial(
ctx context.Context,
laddr *net.TCPAddr,
raddr *net.TCPAddr,
clientPrivateKey *ecdsa.PrivateKey,
serverPublicKey *ecdsa.PublicKey,
) (*Conn, error) {
conn, err := net.DialTCP("tcp", laddr, raddr)
if err != nil {
return nil, err
}
sessionKey, err := Handshake(ctx, conn, clientPrivateKey, serverPublicKey)
if err != nil {
return nil, err
}
return NewConn(conn, sessionKey, &clientPrivateKey.PublicKey, serverPublicKey), nil
}
// Listen ...
func Listen(
ctx context.Context,
laddr *net.TCPAddr,
identity *ecdsa.PrivateKey,
) (net.Listener, error) {
tcpListener, err := net.ListenTCP("tcp", laddr)
if err != nil {
return nil, err
}
logger := dlog.FromContext(ctx)
return &Listener{logger, tcpListener, identity}, nil
}

91
shared/dnet/frame.go Normal file
View File

@ -0,0 +1,91 @@
package dnet
import (
"encoding/binary"
"errors"
"io"
)
const (
// MaxFrameSize is the maximum size of a frame. It is set to 65534 bytes,
// which is one byte less than the maximum value of a uint16 (65535).
MaxFrameSize uint16 = 65535
)
// Frame is a Frame that uses a length prefix to frame the data.
type Frame struct {
contents []byte
}
// NewFrame creates a new Frame with a length prefix.
func NewFrame(contents []byte) *Frame {
return &Frame{
contents: contents,
}
}
// Contents implements Frame.
func (l *Frame) Contents() []byte {
return l.contents
}
// Len implements Frame.
func (l *Frame) Len() uint16 {
return uint16(len(l.contents))
}
// ReadFrom implements Frame.
func (lpf *Frame) ReadFrom(r io.Reader) (int64, error) {
// LPF expects a 2-byte length prefix followed by the contents.
header := make([]byte, 2)
// Read the header (2 bytes) from the reader. io.ReadFull will return an
// error if it doesn't read exactly 2 bytes.
if _, err := io.ReadFull(r, header); err != nil {
return 0, err
}
// Calculate the length of the payload.
payloadLen := binary.BigEndian.Uint16(header)
// Check if the payload length exceeds the maximum frame size.
if payloadLen >= MaxFrameSize {
return 0, errors.New("payload length exceeds maximum frame size")
}
// Read the payload from the reader.
payload := make([]byte, payloadLen)
if _, err := io.ReadFull(r, payload); err != nil {
return 0, err
}
// Set the contents of the frame.
lpf.contents = payload
// Calculate the total length of the frame.
return int64(2 + len(payload)), nil
}
// WriteTo implements Frame.
func (l *Frame) WriteTo(w io.Writer) (int64, error) {
// Check if the payload length exceeds the maximum frame size.
if uint16(len(l.contents)) >= MaxFrameSize {
return 0, errors.New("payload length exceeds maximum frame size")
}
// Create a buffer to hold the length prefix and the payload.
lenBuf := make([]byte, 2)
binary.BigEndian.PutUint16(lenBuf, uint16(len(l.contents)))
// Write the header to the buffer.
if _, err := w.Write(lenBuf); err != nil {
return 0, errors.New("failed to write frame header")
}
// Write the payload to the buffer.
if _, err := w.Write(l.contents); err != nil {
return 0, errors.New("failed to write frame payload")
}
// Calculate the total length of the frame.
return int64(2 + len(l.contents)), nil
}

71
shared/dnet/frame_test.go Normal file
View File

@ -0,0 +1,71 @@
package dnet_test
import (
"bufio"
"bytes"
"testing"
"koti.casa/numenor-labs/dsfx/shared/dnet"
)
func TestLenPrefixedWriteTo(t *testing.T) {
// Given ...
buf := bytes.NewBuffer(nil)
msg := []byte("Hello, World!")
expectedBytesWritten := len(msg) + 2
// When ...
n, err := dnet.NewFrame(msg).WriteTo(buf)
// Then ...
if err != nil {
t.Fatalf("failed to write frame: %v", err)
return
}
if n != int64(expectedBytesWritten) {
t.Fatalf("expected %d bytes written, got %d", expectedBytesWritten, n)
return
}
if buf.Len() != expectedBytesWritten {
t.Fatalf("expected %d bytes in buffer, got %d", expectedBytesWritten, buf.Len())
return
}
if !bytes.Equal(buf.Bytes()[2:], msg) {
t.Fatalf("expected %q in buffer, got %q", msg, buf.Bytes()[2:])
return
}
}
func TestLenPrefixedReadFrom(t *testing.T) {
// Given ...
msg := []byte{0x00, 0x0d}
msg = append(msg, []byte("Hello, World!")...)
buf := bufio.NewReader(bytes.NewBuffer(msg))
expectedBytesRead := len(msg)
// When ...
f := dnet.NewFrame(nil)
n, err := f.ReadFrom(buf)
// Then ...
if err != nil {
t.Fatalf("failed to write frame: %v", err)
return
}
if n != int64(expectedBytesRead) {
t.Fatalf("expected %d bytes read, got %d", expectedBytesRead, n)
return
}
if !bytes.Equal(f.Contents(), []byte("Hello, World!")) {
t.Fatalf("expected %q, got %q", []byte("Hello, World!"), f.Contents())
return
}
}

View File

@ -1,68 +1,62 @@
package handshake package dnet
import ( import (
"bytes"
"context"
"crypto/ecdh" "crypto/ecdh"
"crypto/ecdsa" "crypto/ecdsa"
"crypto/sha256" "crypto/sha256"
"errors" "errors"
"fmt"
"io" "io"
"net" "log/slog"
"koti.casa/numenor-labs/dsfx/pkg/dcrypto" "koti.casa/numenor-labs/dsfx/shared/dcrypto"
"koti.casa/numenor-labs/dsfx/pkg/encoding/vwb" "koti.casa/numenor-labs/dsfx/shared/dlog"
) )
func debugf(msg string, args ...any) { type Hand struct {
fmt.Printf("(debug): "+msg, args...) State byte
Identity *ecdsa.PrivateKey
AuthMessage []byte
} }
// An Actor represents an entity that can participate in the handshake process. func NewHandshake(identity *ecdsa.PrivateKey) *Hand {
// In this model, there are two actors: Alice and Bob. Alice represents the return &Hand{
// client role and Bob represents the server role. Each actor has a ecdsa private State: 0,
// key used for identification, and a tcp address that they are reachable at. Identity: identity,
type Actor interface { }
// Identity represents the identity of the actor. It is used to sign messages
// on behalf of the actor during the handshake process. The actor may choose
// to use a well known key here in order to be recognized by other actors.
// It is also valid to return an ephemeral key here if the actor wishes to
// remain anonymous with the remote actor.
IdentityKey() *ecdsa.PrivateKey
// Address returns the address of the actor. This is used when listening for
// incoming connections, and when dialing out to other actors.
Address() *net.TCPAddr
} }
// State represents the state of the handshake process. It should be passed to // Handshake initiates the handshake process between the given actor
// and from the various functions in the handshake process.
type State struct {
EphemeralKey *ecdh.PrivateKey
}
// InitiateHandshake initiates the handshake process between the given actor
// and the remote actor. // and the remote actor.
func InitiateHandshake(actor Actor, conn io.ReadWriter, rpub *ecdsa.PublicKey) ([]byte, error) { func Handshake(
ctx context.Context,
conn io.ReadWriteCloser,
lPrivKey *ecdsa.PrivateKey,
rPubKey *ecdsa.PublicKey,
) ([]byte, error) {
logger := dlog.FromContext(ctx).WithGroup("handshake")
// ------------------------------------------------------------------------ // ------------------------------------------------------------------------
// Step 1: Ephemeral Key Exchange To Server // Step 1: Ephemeral Key Exchange To Server
debugf("client: creating dh key\n") logger.DebugContext(ctx, "creating dh key")
// Create a new ECDH private key for the actor. // Create a new ECDH private key for the actor.
ourDHKey, err := dcrypto.GenerateDHKey() ourDHKey, err := dcrypto.GenerateDHKey()
if err != nil { if err != nil {
return nil, err return nil, err
} }
debugf("client: exporting dh key\n") logger.DebugContext(ctx, "exporting dh key")
// Export the public key of the actor's ECDH private key. // Export the public key of the actor's ECDH private key.
ourDHKeyRaw, err := dcrypto.ExportDHPublicKey(ourDHKey.PublicKey()) ourDHKeyRaw, err := dcrypto.ExportDHPublicKey(ourDHKey.PublicKey())
if err != nil { if err != nil {
return nil, err return nil, err
} }
debugf("client: sending dh key\n")
// Write the actor's public key to the connection. // Write the actor's public key to the connection.
err = vwb.Encode(conn, ourDHKeyRaw) logger.DebugContext(ctx, "sending dh key", slog.Int("key.size", len(ourDHKeyRaw)))
_, err = NewFrame(ourDHKeyRaw).WriteTo(conn)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -70,16 +64,17 @@ func InitiateHandshake(actor Actor, conn io.ReadWriter, rpub *ecdsa.PublicKey) (
// ------------------------------------------------------------------------ // ------------------------------------------------------------------------
// Step 2: Ephemeral Key Exchange From Server // Step 2: Ephemeral Key Exchange From Server
debugf("client: waiting for server's dh key\n")
// Read the remote actor's public key from the connection. // Read the remote actor's public key from the connection.
remoteDHKeyRaw, err := vwb.Decode(conn) logger.DebugContext(ctx, "waiting for server's dh key")
remoteDHKeyFrame := NewFrame(nil)
_, err = remoteDHKeyFrame.ReadFrom(conn)
if err != nil { if err != nil {
return nil, err return nil, err
} }
debugf("client: importing server's dh key\n")
// Import the remote actor's public key. // Import the remote actor's public key.
remoteDHKey, err := dcrypto.ImportDHPublicKey(remoteDHKeyRaw) logger.DebugContext(ctx, "importing server's dh key")
remoteDHKey, err := dcrypto.ImportDHPublicKey(remoteDHKeyFrame.Contents())
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -87,20 +82,19 @@ func InitiateHandshake(actor Actor, conn io.ReadWriter, rpub *ecdsa.PublicKey) (
// ------------------------------------------------------------------------ // ------------------------------------------------------------------------
// Step 3: Client Authentication // Step 3: Client Authentication
debugf("client: exporting public signing key\n")
// Export the public key of the actor's signing key. // Export the public key of the actor's signing key.
ourPublicKeyRaw, err := dcrypto.ExportPublicSigningKey(&actor.IdentityKey().PublicKey) logger.DebugContext(ctx, "exporting public signing key")
ourPublicKeyRaw, err := dcrypto.ExportPublicSigningKey(&lPrivKey.PublicKey)
if err != nil { if err != nil {
return nil, err return nil, err
} }
debugf("client: exporting remote public signing key\n") logger.DebugContext(ctx, "exporting remote public signing key")
remotePublicKeyRaw, err := dcrypto.ExportPublicSigningKey(rpub) remotePublicKeyRaw, err := dcrypto.ExportPublicSigningKey(rPubKey)
if err != nil { if err != nil {
return nil, err return nil, err
} }
debugf("client: creating authentication message\n")
// Construct the message that will be signed by the client. // Construct the message that will be signed by the client.
// This message is formatted as follows: // This message is formatted as follows:
// rlt + sha256(ae + be) // rlt + sha256(ae + be)
@ -108,49 +102,47 @@ func InitiateHandshake(actor Actor, conn io.ReadWriter, rpub *ecdsa.PublicKey) (
// ephemeral keys that were exchanged in the previous step. This creates a // ephemeral keys that were exchanged in the previous step. This creates a
// verifiable link between both parties and the ephemeral keys used to // verifiable link between both parties and the ephemeral keys used to
// establish the shared secret. // establish the shared secret.
logger.DebugContext(ctx, "building authentication message")
authMessage, err := buildMessage(ourDHKey.PublicKey(), remoteDHKey) authMessage, err := buildMessage(ourDHKey.PublicKey(), remoteDHKey)
if err != nil { if err != nil {
return nil, err return nil, err
} }
debugf("client: signing authentication message\n")
// Sign the message with the actor's private key. // Sign the message with the actor's private key.
signature, err := dcrypto.Sign(actor.IdentityKey(), authMessage) logger.DebugContext(ctx, "signing authentication message")
signature, err := dcrypto.Sign(lPrivKey, authMessage)
if err != nil { if err != nil {
return nil, err return nil, err
} }
debugf("client: encoding %d bytes of public key\n", len(ourPublicKeyRaw))
debugf("client: encoding %d bytes of signature\n", len(signature))
plaintext := make([]byte, 0, len(ourPublicKeyRaw)+len(signature))
plaintext = append(plaintext, ourPublicKeyRaw...)
plaintext = append(plaintext, signature...)
debugf("client: computing shared secret\n")
// Compute the shared secret between the actor and the remote actor. // Compute the shared secret between the actor and the remote actor.
logger.DebugContext(ctx, "computing shared secret")
sharedSecret, err := dcrypto.ComputeDHSecret(ourDHKey, remoteDHKey) sharedSecret, err := dcrypto.ComputeDHSecret(ourDHKey, remoteDHKey)
if err != nil { if err != nil {
return nil, err return nil, err
} }
debugf("client: deriving key from shared secret\n")
// Derive a key from the shared secret using HKDF. // Derive a key from the shared secret using HKDF.
logger.DebugContext(ctx, "deriving key from shared secret")
derivedKey, err := dcrypto.Key(sharedSecret, nil) derivedKey, err := dcrypto.Key(sharedSecret, nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
debugf("client: encrypting authentication message\n") plaintext := make([]byte, 0, len(ourPublicKeyRaw)+len(signature))
plaintext = append(plaintext, ourPublicKeyRaw...)
plaintext = append(plaintext, signature...)
// Encrypt the message with the derived key. // Encrypt the message with the derived key.
logger.DebugContext(ctx, "encrypting authentication message")
boxedMsg, err := dcrypto.Encrypt(derivedKey, plaintext) boxedMsg, err := dcrypto.Encrypt(derivedKey, plaintext)
if err != nil { if err != nil {
return nil, err return nil, err
} }
debugf("client: sending authentication message\n")
// Write the boxed message to the connection. // Write the boxed message to the connection.
err = vwb.Encode(conn, boxedMsg) logger.DebugContext(ctx, "sending authentication message", slog.Int("message.size", len(boxedMsg)))
_, err = NewFrame(boxedMsg).WriteTo(conn)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -158,122 +150,136 @@ func InitiateHandshake(actor Actor, conn io.ReadWriter, rpub *ecdsa.PublicKey) (
// ------------------------------------------------------------------------ // ------------------------------------------------------------------------
// Step 4: Server Authentication // Step 4: Server Authentication
debugf("client: waiting for server's authentication message\n")
// Read the authentication message from the connection. // Read the authentication message from the connection.
authMessageRaw, err := vwb.Decode(conn) logger.DebugContext(ctx, "waiting for server's authentication message")
authMessageFrame := NewFrame(nil)
n, err := authMessageFrame.ReadFrom(conn)
if err != nil { if err != nil {
return nil, err return nil, err
} }
logger.DebugContext(ctx, "received authentication message", slog.Int("message.size", int(n)))
debugf("client: decrypting authentication message\n")
// Decrypt the authentication message with the derived key. // Decrypt the authentication message with the derived key.
plaintext, err = dcrypto.Decrypt(derivedKey, authMessageRaw) logger.DebugContext(ctx, "decrypting authentication message")
plaintext, err = dcrypto.Decrypt(derivedKey, authMessageFrame.Contents())
if err != nil { if err != nil {
return nil, err return nil, err
} }
debugf("client: importing server's signing key\n")
// The server authentication is just verifying the signature it created of // The server authentication is just verifying the signature it created of
// the client authentication message. // the client authentication message.
logger.DebugContext(ctx, "importing server's public signing key")
remotePublicKey, err := dcrypto.ImportPublicSigningKey(remotePublicKeyRaw) remotePublicKey, err := dcrypto.ImportPublicSigningKey(remotePublicKeyRaw)
if err != nil { if err != nil {
return nil, err return nil, err
} }
debugf("client: verifying server's signature\n") logger.DebugContext(ctx, "verifying server's signature")
if !dcrypto.Verify(remotePublicKey, authMessage, plaintext) { if !dcrypto.Verify(remotePublicKey, authMessage, plaintext) {
return nil, errors.New("failed to verify server's signature") return nil, errors.New("failed to verify server's signature")
} }
debugf("client: handshake complete\n") // Finally, we need to let the server know that the handshake is complete.
return sharedSecret, nil logger.DebugContext(ctx, "sending handshake complete message")
} handshakeCompleteMsg := []byte{0x01}
_, err = NewFrame(handshakeCompleteMsg).WriteTo(conn)
// AcceptHandshake accepts a handshake from the given actor and connection. It
// returns the shared secret between the actor and the remote actor.
func AcceptHandshake(actor Actor, conn io.ReadWriter) ([]byte, error) {
// ------------------------------------------------------------------------
// Step 1: Ephemeral Key Exchange From Client
debugf("server: starting handshake process\n")
// Read the remote actor's public key from the connection.
remoteDHKeyRaw, err := vwb.Decode(conn)
if err != nil { if err != nil {
return nil, err return nil, err
} }
debugf("server: importing client's dh key\n") logger.DebugContext(ctx, "handshake complete")
// Import the remote actor's public key. return derivedKey, nil
remoteDHKey, err := dcrypto.ImportDHPublicKey(remoteDHKeyRaw) }
// AcceptHandshake accepts a handshake from the given actor and connection. It
// returns the shared secret between the actor and the remote actor.
func AcceptHandshake(ctx context.Context, conn io.ReadWriteCloser, lPrivKey *ecdsa.PrivateKey) (*ecdsa.PublicKey, []byte, error) {
logger := dlog.FromContext(ctx).WithGroup("handshake")
// ------------------------------------------------------------------------
// Step 1: Ephemeral Key Exchange From Client
// Read the remote actor's public key from the connection.
logger.DebugContext(ctx, "waiting for client's dh key")
remoteDHKeyFrame := NewFrame(nil)
_, err := remoteDHKeyFrame.ReadFrom(conn)
if err != nil { if err != nil {
return nil, err return nil, nil, err
}
// Import the remote actor's public key.
logger.DebugContext(ctx, "importing client's dh key")
remoteDHKey, err := dcrypto.ImportDHPublicKey(remoteDHKeyFrame.Contents())
if err != nil {
return nil, nil, err
} }
// ------------------------------------------------------------------------ // ------------------------------------------------------------------------
// Step 2: Ephemeral Key Exchange To Client // Step 2: Ephemeral Key Exchange To Client
debugf("server: creating dh key\n")
// Create a new ECDH private key for the actor. // Create a new ECDH private key for the actor.
logger.DebugContext(ctx, "creating dh key")
ourDHKey, err := dcrypto.GenerateDHKey() ourDHKey, err := dcrypto.GenerateDHKey()
if err != nil { if err != nil {
return nil, err return nil, nil, err
} }
debugf("server: exporting dh key\n")
// Export the public key of the actor's ECDH private key. // Export the public key of the actor's ECDH private key.
logger.DebugContext(ctx, "exporting dh key")
ourDHKeyRaw, err := dcrypto.ExportDHPublicKey(ourDHKey.PublicKey()) ourDHKeyRaw, err := dcrypto.ExportDHPublicKey(ourDHKey.PublicKey())
if err != nil { if err != nil {
return nil, err return nil, nil, err
} }
debugf("server: sending dh key\n")
// Write the actor's public key to the connection. // Write the actor's public key to the connection.
err = vwb.Encode(conn, ourDHKeyRaw) logger.DebugContext(ctx, "sending dh key", slog.Int("key.size", len(ourDHKeyRaw)))
_, err = NewFrame(ourDHKeyRaw).WriteTo(conn)
if err != nil { if err != nil {
return nil, err return nil, nil, err
} }
// ------------------------------------------------------------------------ // ------------------------------------------------------------------------
// Step 3: Server Authentication // Step 3: Server Authentication
debugf("server: waiting for client's authentication message\n")
// Read the authentication message from the connection. // Read the authentication message from the connection.
authMessageRaw, err := vwb.Decode(conn) logger.DebugContext(ctx, "waiting for client's authentication message")
authMessageFrame := NewFrame(nil)
n, err := authMessageFrame.ReadFrom(conn)
if err != nil { if err != nil {
return nil, err return nil, nil, err
} }
logger.DebugContext(ctx, "received authentication message", slog.Int("message.size", int(n)))
debugf("server: computing shared secret\n")
// Decrypt the authentication message with the derived key. // Decrypt the authentication message with the derived key.
logger.DebugContext(ctx, "computing shared secret")
sharedSecret, err := dcrypto.ComputeDHSecret(ourDHKey, remoteDHKey) sharedSecret, err := dcrypto.ComputeDHSecret(ourDHKey, remoteDHKey)
if err != nil { if err != nil {
return nil, err return nil, nil, err
} }
debugf("server: deriving key from shared secret\n")
// Derive a key from the shared secret using HKDF. // Derive a key from the shared secret using HKDF.
logger.DebugContext(ctx, "deriving key from shared secret")
derivedKey, err := dcrypto.Key(sharedSecret, nil) derivedKey, err := dcrypto.Key(sharedSecret, nil)
if err != nil { if err != nil {
return nil, err return nil, nil, err
} }
debugf("server: decrypting authentication message\n") logger.DebugContext(ctx, "decrypting authentication message")
plaintext, err := dcrypto.Decrypt(derivedKey, authMessageRaw) plaintext, err := dcrypto.Decrypt(derivedKey, authMessageFrame.Contents())
if err != nil { if err != nil {
return nil, err return nil, nil, err
} }
clientPublicKeyRaw := plaintext[:222] clientPublicKeyRaw := plaintext[:222]
signature := plaintext[222:] signature := plaintext[222:]
debugf("server: importing client's public signing key\n")
// Verify the client's public key and signature. // Verify the client's public key and signature.
logger.DebugContext(ctx, "importing client's public signing key")
clientPublicKey, err := dcrypto.ImportPublicSigningKey(clientPublicKeyRaw) clientPublicKey, err := dcrypto.ImportPublicSigningKey(clientPublicKeyRaw)
if err != nil { if err != nil {
return nil, err return nil, nil, err
} }
debugf("server: verifying client's signature\n")
// Construct the message that was signed by the client. // Construct the message that was signed by the client.
// This message is formatted as follows: // This message is formatted as follows:
// rlt + sha256(ae + be) // rlt + sha256(ae + be)
@ -281,41 +287,55 @@ func AcceptHandshake(actor Actor, conn io.ReadWriter) ([]byte, error) {
// ephemeral keys that were exchanged in the previous step. This creates a // ephemeral keys that were exchanged in the previous step. This creates a
// verifiable link between both parties and the ephemeral keys used to // verifiable link between both parties and the ephemeral keys used to
// establish the shared secret. // establish the shared secret.
logger.DebugContext(ctx, "building authentication message")
authMessage, err := buildMessage(remoteDHKey, ourDHKey.PublicKey()) authMessage, err := buildMessage(remoteDHKey, ourDHKey.PublicKey())
if err != nil { if err != nil {
return nil, err return nil, nil, err
} }
logger.DebugContext(ctx, "verifying client's signature")
if !dcrypto.Verify(clientPublicKey, authMessage, signature) { if !dcrypto.Verify(clientPublicKey, authMessage, signature) {
return nil, errors.New("failed to verify client's signature") return nil, nil, errors.New("failed to verify client's signature")
} }
debugf("server: creating authentication message\n")
// Now we need to sign the authentication message with the server's private // Now we need to sign the authentication message with the server's private
// key. This will be sent back to the client in the next step to authenticate // key. This will be sent back to the client in the next step to authenticate
// the server to the client. // the server to the client.
serverSignature, err := dcrypto.Sign(actor.IdentityKey(), authMessage) logger.DebugContext(ctx, "signing authentication message")
serverSignature, err := dcrypto.Sign(lPrivKey, authMessage)
if err != nil { if err != nil {
return nil, err return nil, nil, err
} }
logger.DebugContext(ctx, "encrypting server's signature")
boxedMsg, err := dcrypto.Encrypt(derivedKey, serverSignature) boxedMsg, err := dcrypto.Encrypt(derivedKey, serverSignature)
if err != nil { if err != nil {
return nil, err return nil, nil, err
} }
debugf("server: sending authentication message\n")
// Send the server's signature back to the client. // Send the server's signature back to the client.
err = vwb.Encode(conn, boxedMsg) logger.DebugContext(ctx, "sending authentication message", slog.Int("message.size", len(boxedMsg)))
_, err = NewFrame(boxedMsg).WriteTo(conn)
if err != nil { if err != nil {
return nil, err return nil, nil, err
}
logger.DebugContext(ctx, "waiting for handshake complete message")
// Read the handshake complete message from the client.
handshakeCompleteFrame := NewFrame(nil)
_, err = handshakeCompleteFrame.ReadFrom(conn)
if err != nil {
return nil, nil, err
}
if !bytes.Equal(handshakeCompleteFrame.Contents(), []byte{0x01}) {
return nil, nil, errors.New("invalid handshake complete message")
} }
// ------------------------------------------------------------------------ // ------------------------------------------------------------------------
// Step 4: Client Authentication // Step 4: Client Authentication
debugf("server: handshake complete\n") logger.DebugContext(ctx, "handshake complete")
return sharedSecret, nil return clientPublicKey, derivedKey, nil
} }
func buildMessage(clientPubKey *ecdh.PublicKey, serverPubKey *ecdh.PublicKey) ([]byte, error) { func buildMessage(clientPubKey *ecdh.PublicKey, serverPubKey *ecdh.PublicKey) ([]byte, error) {

View File

@ -0,0 +1,81 @@
package dnet_test
import (
"bytes"
"context"
"crypto/ecdsa"
"net"
"sync"
"testing"
"koti.casa/numenor-labs/dsfx/shared/dcrypto"
"koti.casa/numenor-labs/dsfx/shared/dnet"
)
func TestHandshake(t *testing.T) {
ctx := context.Background()
// alice, represented by an ecdsa key pair.
alice, _ := dcrypto.GenerateSigningKey()
// bob, also represented by an ecdsa key pair.
bob, _ := dcrypto.GenerateSigningKey()
var (
// the secret that alice should arrive at on her own
aliceSecret []byte
// any errors produce by alice
aliceErr error
// alice's public key as discovered by bob
discoveredAlicePublicKey *ecdsa.PublicKey
// the secret that bob should arrive at on his own
bobSecret []byte
// any errors produce by bob
bobErr error
)
// Create a network pipe to simulate a network connection between alice and bob.
client, server := net.Pipe()
defer client.Close()
defer server.Close()
// Run the handshake in parallel so both sides can proceed concurrently.
// Since they're talking through the network pipe, the entire process should
// be simulated as if it were a real network connection.
var wg sync.WaitGroup
wg.Add(2)
go func() {
aliceSecret, aliceErr = dnet.Handshake(ctx, client, alice, &bob.PublicKey)
wg.Done()
}()
go func() {
discoveredAlicePublicKey, bobSecret, bobErr = dnet.AcceptHandshake(ctx, server, bob)
wg.Done()
}()
wg.Wait()
// Neither alice nor bob should have encountered any errors.
if aliceErr != nil {
t.Errorf("alice error: %v", aliceErr)
return
}
if bobErr != nil {
t.Errorf("bob error: %v", bobErr)
return
}
// Both alice and bob should have arrived at a shared secret.
if aliceSecret == nil || bobSecret == nil {
t.Errorf("handshake failed: sessions are nil")
return
}
// Alice and bob should have arrived at the SAME shared secret.
if !bytes.Equal(aliceSecret, bobSecret) {
t.Errorf("handshake failed: sessions are not equal")
return
}
// Bob should have discovered alice's public key.
if !alice.PublicKey.Equal(discoveredAlicePublicKey) {
t.Errorf("handshake failed: discovered public key is not equal to alice's public key")
return
}
}

44
shared/dnet/listener.go Normal file
View File

@ -0,0 +1,44 @@
package dnet
import (
"context"
"crypto/ecdsa"
"log/slog"
"net"
)
// Listener ...
type Listener struct {
logger *slog.Logger
tcpListener *net.TCPListener
identity *ecdsa.PrivateKey
}
// Accept implements net.Listener.
func (l *Listener) Accept() (net.Conn, error) {
ctx := context.Background()
conn, err := l.tcpListener.AcceptTCP()
if err != nil {
return nil, err
}
clientIdentity, sessionKey, err := AcceptHandshake(ctx, conn, l.identity)
if err != nil {
return nil, err
}
return NewConn(conn, sessionKey, &l.identity.PublicKey, clientIdentity), nil
}
// Close implements net.Listener.
func (l *Listener) Close() error {
return l.tcpListener.Close()
}
// Addr implements net.Listener.
func (l *Listener) Addr() net.Addr {
laddr := l.tcpListener.Addr().(*net.TCPAddr)
return NewAddr(laddr.IP, laddr.Port, &l.identity.PublicKey)
}

156
tigerstyle.md Normal file
View File

@ -0,0 +1,156 @@
# TigerStyle for Go Inspired by the Original TigerStyle
_This guide draws direct inspiration from the original TigerStyle document. Its principles have been adapted to the Go ecosystem, establishing definitive rules for writing safe, high-performance code with an exceptional developer experience._
---
## 1. The Core Principles
Our goal is to write Go code that is:
- **Safe:** No surprises. Every error is caught and handled.
- **High-Performance:** Bottlenecks are identified and efficiently addressed.
- **Easy to Maintain:** Code is clear, well-organized, and thoroughly documented.
---
## 2. Why Strict Style Matters
Adhering to strict rules ensures:
- **Clarity and Consistency:** Every developer on the team can immediately understand the code.
- **Early Error Detection:** Explicit checks and assertions catch mistakes before they become costly.
- **Reduced Technical Debt:** Investing time in clear design saves time later in production fixes and maintenance.
---
## 3. Simplicity and Elegance
- **Simple Designs First:** Strive for solutions that solve multiple issues simultaneously.
- **Iterate to Elegance:** The first solution is rarely the best. Refactor until your code is as simple as possible without sacrificing clarity.
- **Definitive Boundaries:** If something is too complex or too long, it must be split into distinct, manageable components.
---
## 4. Zero Technical Debt Policy
- **Fail Early:** Address potential issues during development—not after deployment.
- **No Compromise on Quality:** Code must fully meet our design standards before it can be merged.
- **Comprehensive Testing:** All functions must be covered by tests that include both valid and invalid cases.
---
## 5. Safety: Rules and Assertions
Safety comes first. Our practices include:
- **Control Flow and Limits:**
- Use clear, linear control structures. No clever hacks that compromise readability.
- Every loop, channel, and buffer **must have an explicit, hard upper limit**. For example, a loop should never exceed a fixed iteration count unless justified.
- **Assertions and Error Handling:**
- Every function **must validate its inputs and expected outputs**.
- Use explicit error checks (`if err != nil { … }`) to handle all non-fatal errors.
- For invariants that must never be violated, use `panic` immediately. Such panics are reserved solely for truly unrecoverable conditions.
- **Minimum Assertion Density:** Every function must include at least **two assertions or validations**—one confirming expected conditions and one ensuring the rejection of impossible states.
- **Resource Management:**
- Preallocate slices and buffers in hot paths to avoid runtime allocation surprises.
- All resources should be allocated once during startup, particularly in performance-sensitive parts of the system.
- **Variable Scoping:**
- Declare variables in the smallest scope possible. Global variables and unnecessarily wide variable scopes are not allowed.
- **Function Size:**
- **Hard limit:** No function may exceed **70 lines of code**. Functions that approach this limit must be refactored immediately into smaller helper functions with well-defined responsibilities.
---
## 6. Performance: Measured and Proven
- **Identify Bottlenecks:** Optimize in order: Network > Disk > Memory > CPU.
- **Profiling is Mandatory:** Use tools like `pprof` to physically measure performance. No assumptions—optimize based on real data.
- **Batch Operations:** Always batch I/O and computational tasks where possible to reduce overhead.
- **Explicit Critical Paths:** Code that is performance-critical must be isolated, clearly documented, and benchmarked. No “magic” will hide inefficient loops or algorithms.
---
## 7. Developer Experience: Consistency and Clarity
### Naming and Organization
- **Naming Conventions:**
- Use **CamelCase** for exported identifiers and **lowerCamelCase** for unexported ones.
- Names must be descriptive and avoid abbreviations unless the abbreviation is well recognized.
- **File and Code Organization:**
- The `main` function and package initialization must appear at the top of the file.
- Group related types and functions together. Consistent ordering enhances readability.
- **Commit Messages and Documentation:**
- Every commit must include a clear explanation of what changed and, more importantly, why the change was made.
- Comments must clarify the rationale behind decisions and describe non-obvious logic.
### Error Handling and Modularity
- **Explicit Error Checks:**
- Use the standard Go idiom (`if err != nil { … }`) without exception.
- Never ignore errors.
- **Function Design:**
- Functions must be single-purpose and maintain a clear separation of concerns.
- Avoid overly complicated signatures—simplicity at the point of use is paramount.
- **Testing:**
- Unit tests are mandatory for every function, covering both expected and edge-case scenarios.
- Tests must also deliberately check for error conditions and validate that assertions are working.
---
## 8. Tools, Dependencies, and Workflow
### Dependencies
- **Minimal External Dependencies:**
- Rely heavily on Gos standard library. External libraries are permitted only when absolutely necessary and must be scrutinized for maintenance, security, and performance impact.
### Tooling
- **Formatting:**
- Code must be formatted with `gofmt`. No exceptions.
- **Static Analysis:**
- Run `go vet`, and other static analyzers with every commit. Fix all warnings immediately.
- **Version Control and CI/CD:**
- Use Go modules for dependency management.
- Integrate linting and testing into the CI pipeline to maintain consistency and catch issues early.
---
## 9. Final Mandates
- **Continuous Improvement:**
- This document is the definitive style guide for our Go code. Adherence is not optional.
- Regular audits, code reviews, and performance benchmarks must be conducted to ensure compliance.
- **No Excuses:**
- If a piece of code violates these rules, it must be refactored immediately.
- Our style ensures that every line of code is safe, efficient, and maintainable.
---
_End of TigerStyle for Go_
_We take inspiration from TigerStyle without compromise. Our rules are definitive and non-negotiable—engineered to produce Go code that stands out for its clarity, safety, and performance._