mirror of
https://git.numenor-labs.us/dsfx.git
synced 2025-04-29 08:10:34 +00:00
implement handshake
This commit is contained in:
parent
dc892f6ac4
commit
9f35fd4025
25
.gitea/workflows/test.yaml
Normal file
25
.gitea/workflows/test.yaml
Normal 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
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
bin
|
21
LICENSE
Normal file
21
LICENSE
Normal 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
101
README.md
@ -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 server’s 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
31
cmd/genid/main.go
Normal 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
6
config.json
Normal 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
29
dsfx-client/config.go
Normal 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
98
dsfx-client/main.go
Normal 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
6
dsfx-client/masterkey
Normal 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
52
dsfx-server/config.go
Normal 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
100
dsfx-server/main.go
Normal 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
|
||||
}
|
9
main.go
9
main.go
@ -1,9 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
)
|
||||
|
||||
func main() {
|
||||
log.Println("Hello, World!")
|
||||
}
|
8
pkg/assert/assert.go
Normal file
8
pkg/assert/assert.go
Normal 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)
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
@ -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
24
shared/dlog/dlog.go
Normal 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
97
shared/dnet/addr.go
Normal 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
91
shared/dnet/conn.go
Normal 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
46
shared/dnet/dnet.go
Normal 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
91
shared/dnet/frame.go
Normal 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
71
shared/dnet/frame_test.go
Normal 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
|
||||
}
|
||||
}
|
@ -1,68 +1,62 @@
|
||||
package handshake
|
||||
package dnet
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ecdh"
|
||||
"crypto/ecdsa"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"log/slog"
|
||||
|
||||
"koti.casa/numenor-labs/dsfx/pkg/dcrypto"
|
||||
"koti.casa/numenor-labs/dsfx/pkg/encoding/vwb"
|
||||
"koti.casa/numenor-labs/dsfx/shared/dcrypto"
|
||||
"koti.casa/numenor-labs/dsfx/shared/dlog"
|
||||
)
|
||||
|
||||
func debugf(msg string, args ...any) {
|
||||
fmt.Printf("(debug): "+msg, args...)
|
||||
type Hand struct {
|
||||
State byte
|
||||
Identity *ecdsa.PrivateKey
|
||||
AuthMessage []byte
|
||||
}
|
||||
|
||||
// An Actor represents an entity that can participate in the handshake process.
|
||||
// In this model, there are two actors: Alice and Bob. Alice represents the
|
||||
// client role and Bob represents the server role. Each actor has a ecdsa private
|
||||
// key used for identification, and a tcp address that they are reachable at.
|
||||
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
|
||||
func NewHandshake(identity *ecdsa.PrivateKey) *Hand {
|
||||
return &Hand{
|
||||
State: 0,
|
||||
Identity: identity,
|
||||
}
|
||||
}
|
||||
|
||||
// State represents the state of the handshake process. It should be passed to
|
||||
// and from the various functions in the handshake process.
|
||||
type State struct {
|
||||
EphemeralKey *ecdh.PrivateKey
|
||||
}
|
||||
|
||||
// InitiateHandshake initiates the handshake process between the given actor
|
||||
// Handshake initiates the handshake process between the given 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
|
||||
|
||||
debugf("client: creating dh key\n")
|
||||
logger.DebugContext(ctx, "creating dh key")
|
||||
// Create a new ECDH private key for the actor.
|
||||
ourDHKey, err := dcrypto.GenerateDHKey()
|
||||
if err != nil {
|
||||
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.
|
||||
ourDHKeyRaw, err := dcrypto.ExportDHPublicKey(ourDHKey.PublicKey())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
debugf("client: sending dh key\n")
|
||||
// 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 {
|
||||
return nil, err
|
||||
}
|
||||
@ -70,16 +64,17 @@ func InitiateHandshake(actor Actor, conn io.ReadWriter, rpub *ecdsa.PublicKey) (
|
||||
// ------------------------------------------------------------------------
|
||||
// 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.
|
||||
remoteDHKeyRaw, err := vwb.Decode(conn)
|
||||
logger.DebugContext(ctx, "waiting for server's dh key")
|
||||
remoteDHKeyFrame := NewFrame(nil)
|
||||
_, err = remoteDHKeyFrame.ReadFrom(conn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
debugf("client: importing server's dh key\n")
|
||||
// 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 {
|
||||
return nil, err
|
||||
}
|
||||
@ -87,20 +82,19 @@ func InitiateHandshake(actor Actor, conn io.ReadWriter, rpub *ecdsa.PublicKey) (
|
||||
// ------------------------------------------------------------------------
|
||||
// Step 3: Client Authentication
|
||||
|
||||
debugf("client: exporting public signing key\n")
|
||||
// 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 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
debugf("client: exporting remote public signing key\n")
|
||||
remotePublicKeyRaw, err := dcrypto.ExportPublicSigningKey(rpub)
|
||||
logger.DebugContext(ctx, "exporting remote public signing key")
|
||||
remotePublicKeyRaw, err := dcrypto.ExportPublicSigningKey(rPubKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
debugf("client: creating authentication message\n")
|
||||
// Construct the message that will be signed by the client.
|
||||
// This message is formatted as follows:
|
||||
// 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
|
||||
// verifiable link between both parties and the ephemeral keys used to
|
||||
// establish the shared secret.
|
||||
logger.DebugContext(ctx, "building authentication message")
|
||||
authMessage, err := buildMessage(ourDHKey.PublicKey(), remoteDHKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
debugf("client: signing authentication message\n")
|
||||
// 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 {
|
||||
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.
|
||||
logger.DebugContext(ctx, "computing shared secret")
|
||||
sharedSecret, err := dcrypto.ComputeDHSecret(ourDHKey, remoteDHKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
debugf("client: deriving key from shared secret\n")
|
||||
// Derive a key from the shared secret using HKDF.
|
||||
logger.DebugContext(ctx, "deriving key from shared secret")
|
||||
derivedKey, err := dcrypto.Key(sharedSecret, nil)
|
||||
if err != nil {
|
||||
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.
|
||||
logger.DebugContext(ctx, "encrypting authentication message")
|
||||
boxedMsg, err := dcrypto.Encrypt(derivedKey, plaintext)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
debugf("client: sending authentication message\n")
|
||||
// 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 {
|
||||
return nil, err
|
||||
}
|
||||
@ -158,122 +150,136 @@ func InitiateHandshake(actor Actor, conn io.ReadWriter, rpub *ecdsa.PublicKey) (
|
||||
// ------------------------------------------------------------------------
|
||||
// Step 4: Server Authentication
|
||||
|
||||
debugf("client: waiting for server's authentication message\n")
|
||||
// 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 {
|
||||
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.
|
||||
plaintext, err = dcrypto.Decrypt(derivedKey, authMessageRaw)
|
||||
logger.DebugContext(ctx, "decrypting authentication message")
|
||||
plaintext, err = dcrypto.Decrypt(derivedKey, authMessageFrame.Contents())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
debugf("client: importing server's signing key\n")
|
||||
// The server authentication is just verifying the signature it created of
|
||||
// the client authentication message.
|
||||
logger.DebugContext(ctx, "importing server's public signing key")
|
||||
remotePublicKey, err := dcrypto.ImportPublicSigningKey(remotePublicKeyRaw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
debugf("client: verifying server's signature\n")
|
||||
logger.DebugContext(ctx, "verifying server's signature")
|
||||
if !dcrypto.Verify(remotePublicKey, authMessage, plaintext) {
|
||||
return nil, errors.New("failed to verify server's signature")
|
||||
}
|
||||
|
||||
debugf("client: handshake complete\n")
|
||||
return sharedSecret, nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
// Finally, we need to let the server know that the handshake is complete.
|
||||
logger.DebugContext(ctx, "sending handshake complete message")
|
||||
handshakeCompleteMsg := []byte{0x01}
|
||||
_, err = NewFrame(handshakeCompleteMsg).WriteTo(conn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
debugf("server: importing client's dh key\n")
|
||||
// Import the remote actor's public key.
|
||||
remoteDHKey, err := dcrypto.ImportDHPublicKey(remoteDHKeyRaw)
|
||||
logger.DebugContext(ctx, "handshake complete")
|
||||
return derivedKey, nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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
|
||||
|
||||
debugf("server: creating dh key\n")
|
||||
// Create a new ECDH private key for the actor.
|
||||
logger.DebugContext(ctx, "creating dh key")
|
||||
ourDHKey, err := dcrypto.GenerateDHKey()
|
||||
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.
|
||||
logger.DebugContext(ctx, "exporting dh key")
|
||||
ourDHKeyRaw, err := dcrypto.ExportDHPublicKey(ourDHKey.PublicKey())
|
||||
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.
|
||||
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 {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// Step 3: Server Authentication
|
||||
|
||||
debugf("server: waiting for client's authentication message\n")
|
||||
// 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 {
|
||||
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.
|
||||
logger.DebugContext(ctx, "computing shared secret")
|
||||
sharedSecret, err := dcrypto.ComputeDHSecret(ourDHKey, remoteDHKey)
|
||||
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.
|
||||
logger.DebugContext(ctx, "deriving key from shared secret")
|
||||
derivedKey, err := dcrypto.Key(sharedSecret, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
debugf("server: decrypting authentication message\n")
|
||||
plaintext, err := dcrypto.Decrypt(derivedKey, authMessageRaw)
|
||||
logger.DebugContext(ctx, "decrypting authentication message")
|
||||
plaintext, err := dcrypto.Decrypt(derivedKey, authMessageFrame.Contents())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
clientPublicKeyRaw := plaintext[:222]
|
||||
signature := plaintext[222:]
|
||||
|
||||
debugf("server: importing client's public signing key\n")
|
||||
// Verify the client's public key and signature.
|
||||
logger.DebugContext(ctx, "importing client's public signing key")
|
||||
clientPublicKey, err := dcrypto.ImportPublicSigningKey(clientPublicKeyRaw)
|
||||
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.
|
||||
// This message is formatted as follows:
|
||||
// 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
|
||||
// verifiable link between both parties and the ephemeral keys used to
|
||||
// establish the shared secret.
|
||||
logger.DebugContext(ctx, "building authentication message")
|
||||
authMessage, err := buildMessage(remoteDHKey, ourDHKey.PublicKey())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
logger.DebugContext(ctx, "verifying client's 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
|
||||
// key. This will be sent back to the client in the next step to authenticate
|
||||
// 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 {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
logger.DebugContext(ctx, "encrypting server's signature")
|
||||
boxedMsg, err := dcrypto.Encrypt(derivedKey, serverSignature)
|
||||
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.
|
||||
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 {
|
||||
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
|
||||
|
||||
debugf("server: handshake complete\n")
|
||||
return sharedSecret, nil
|
||||
logger.DebugContext(ctx, "handshake complete")
|
||||
return clientPublicKey, derivedKey, nil
|
||||
}
|
||||
|
||||
func buildMessage(clientPubKey *ecdh.PublicKey, serverPubKey *ecdh.PublicKey) ([]byte, error) {
|
81
shared/dnet/handshake_test.go
Normal file
81
shared/dnet/handshake_test.go
Normal 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
44
shared/dnet/listener.go
Normal 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
156
tigerstyle.md
Normal 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 Go’s 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._
|
Loading…
x
Reference in New Issue
Block a user