This commit is contained in:
Sudo-Ivan
2024-12-31 15:15:06 -06:00
parent 99d8e44182
commit f3d22dfcd4
8 changed files with 489 additions and 169 deletions

View File

@@ -1,6 +1,6 @@
# Reticulum-Go
[Reticulum Network](https://github.com/markqvist/Reticulum) implementation in Go
[Reticulum Network](https://github.com/markqvist/Reticulum) implementation in Go.
Aiming for full spec compatibility with the Python version 0.8.8+.
@@ -8,9 +8,7 @@ Aiming for full spec compatibility with the Python version 0.8.8+.
Packages:
- `github.com/pelletier/go-toml`
- `golang.org/x/crypto`
- `gopkg.in/yaml.v3`
## To-Do List
@@ -108,7 +106,9 @@ Packages:
- [x] Cleanup routines
### Compatibility
- [ ] RNS Utilities Compatibility
- [ ] RNS Utilities.
- [ ] Reticulum config.
### Testing & Validation
- [ ] Unit tests for all components (Link, Resource, Destination, Identity, Packet, Transport, Interface)
@@ -124,7 +124,7 @@ Packages:
### Cleanup
- [ ] Seperate Cryptography from identity.go to their own files.
- [ ] Move constants to their own files.
- [ ] Remove TOML stuff, use reticulum config.
- [ ] Remove default community interfaces in default config creation after testing.
### Other
- [ ] Rate limiting

30
SECURITY.md Normal file
View File

@@ -0,0 +1,30 @@
# Security Policy
I use [Socket](https://socket.dev/) and [Deepsource](https://deepsource.com/) for this project.
## Cryptography Dependencies
- golang.org/x/crypto for core cryptographic primitives
- hkdf
- curve25519
- go/crypto
- ed25519
- sha256
- rand
- aes
- cipher
- hmac
## Reporting a Vulnerability
Please report any security vulnerabilities to [rns@quad4.io](mailto:rns@quad4.io)
**PGP Key:**
```
-----BEGIN PGP PUBLIC KEY BLOCK-----
xjMEZ3RaxBYJKwYBBAHaRw8BAQdAcW8OFXyQ6KuqoTWKVbULYgakD

2
go.mod
View File

@@ -3,7 +3,5 @@ module github.com/Sudo-Ivan/reticulum-go
go 1.23.4
require (
github.com/pelletier/go-toml v1.9.5
golang.org/x/crypto v0.31.0
gopkg.in/yaml.v3 v3.0.1
)

View File

@@ -1,11 +1,14 @@
package config
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/Sudo-Ivan/reticulum-go/pkg/common"
"github.com/pelletier/go-toml"
)
const (
@@ -44,35 +47,168 @@ func EnsureConfigDir() error {
return os.MkdirAll(configDir, 0755)
}
// parseValue parses string values into appropriate types
func parseValue(value string) interface{} {
value = strings.TrimSpace(value)
// Try bool
if value == "true" {
return true
}
if value == "false" {
return false
}
// Try int
if i, err := strconv.Atoi(value); err == nil {
return i
}
// Return as string
return value
}
// LoadConfig loads the configuration from the specified path
func LoadConfig(path string) (*common.ReticulumConfig, error) {
data, err := os.ReadFile(path)
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
cfg := DefaultConfig()
if err := toml.Unmarshal(data, cfg); err != nil {
return nil, err
cfg.ConfigPath = path
scanner := bufio.NewScanner(file)
var currentInterface *common.InterfaceConfig
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// Skip comments and empty lines
if line == "" || strings.HasPrefix(line, "#") {
continue
}
// Handle interface sections
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
name := strings.Trim(line, "[]")
currentInterface = &common.InterfaceConfig{Name: name}
cfg.Interfaces[name] = currentInterface
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
if currentInterface != nil {
// Parse interface config
switch key {
case "type":
currentInterface.Type = value
case "enabled":
currentInterface.Enabled = value == "true"
case "address":
currentInterface.Address = value
case "port":
currentInterface.Port, _ = strconv.Atoi(value)
case "target_host":
currentInterface.TargetHost = value
case "target_port":
currentInterface.TargetPort, _ = strconv.Atoi(value)
case "discovery_port":
currentInterface.DiscoveryPort, _ = strconv.Atoi(value)
case "data_port":
currentInterface.DataPort, _ = strconv.Atoi(value)
case "discovery_scope":
currentInterface.DiscoveryScope = value
case "group_id":
currentInterface.GroupID = value
}
} else {
// Parse global config
switch key {
case "enable_transport":
cfg.EnableTransport = value == "true"
case "share_instance":
cfg.ShareInstance = value == "true"
case "shared_instance_port":
cfg.SharedInstancePort, _ = strconv.Atoi(value)
case "instance_control_port":
cfg.InstanceControlPort, _ = strconv.Atoi(value)
case "panic_on_interface_error":
cfg.PanicOnInterfaceErr = value == "true"
case "loglevel":
cfg.LogLevel, _ = strconv.Atoi(value)
}
}
}
cfg.ConfigPath = path
return cfg, nil
}
// SaveConfig saves the configuration to the specified path
func SaveConfig(cfg *common.ReticulumConfig) error {
data, err := toml.Marshal(cfg)
if err != nil {
return err
if cfg.ConfigPath == "" {
return fmt.Errorf("config path not set")
}
return os.WriteFile(cfg.ConfigPath, data, 0644)
var builder strings.Builder
// Write global config
builder.WriteString("# Reticulum Configuration\n")
builder.WriteString(fmt.Sprintf("enable_transport = %v\n", cfg.EnableTransport))
builder.WriteString(fmt.Sprintf("share_instance = %v\n", cfg.ShareInstance))
builder.WriteString(fmt.Sprintf("shared_instance_port = %d\n", cfg.SharedInstancePort))
builder.WriteString(fmt.Sprintf("instance_control_port = %d\n", cfg.InstanceControlPort))
builder.WriteString(fmt.Sprintf("panic_on_interface_error = %v\n", cfg.PanicOnInterfaceErr))
builder.WriteString(fmt.Sprintf("loglevel = %d\n\n", cfg.LogLevel))
// Write interface configs
for name, iface := range cfg.Interfaces {
builder.WriteString(fmt.Sprintf("[%s]\n", name))
builder.WriteString(fmt.Sprintf("type = %s\n", iface.Type))
builder.WriteString(fmt.Sprintf("enabled = %v\n", iface.Enabled))
if iface.Address != "" {
builder.WriteString(fmt.Sprintf("address = %s\n", iface.Address))
}
if iface.Port != 0 {
builder.WriteString(fmt.Sprintf("port = %d\n", iface.Port))
}
if iface.TargetHost != "" {
builder.WriteString(fmt.Sprintf("target_host = %s\n", iface.TargetHost))
}
if iface.TargetPort != 0 {
builder.WriteString(fmt.Sprintf("target_port = %d\n", iface.TargetPort))
}
if iface.DiscoveryPort != 0 {
builder.WriteString(fmt.Sprintf("discovery_port = %d\n", iface.DiscoveryPort))
}
if iface.DataPort != 0 {
builder.WriteString(fmt.Sprintf("data_port = %d\n", iface.DataPort))
}
if iface.DiscoveryScope != "" {
builder.WriteString(fmt.Sprintf("discovery_scope = %s\n", iface.DiscoveryScope))
}
if iface.GroupID != "" {
builder.WriteString(fmt.Sprintf("group_id = %s\n", iface.GroupID))
}
builder.WriteString("\n")
}
return os.WriteFile(cfg.ConfigPath, []byte(builder.String()), 0644)
}
// CreateDefaultConfig creates a default configuration file
func CreateDefaultConfig(path string) error {
cfg := DefaultConfig()
cfg.ConfigPath = path
// Add Auto Interface
cfg.Interfaces["Auto Discovery"] = &common.InterfaceConfig{
@@ -84,7 +220,7 @@ func CreateDefaultConfig(path string) error {
DataPort: 42671,
}
// Add RNS Amsterdam Testnet interface
// Add default interfaces
cfg.Interfaces["RNS Testnet Amsterdam"] = &common.InterfaceConfig{
Type: "TCPClientInterface",
Enabled: true,
@@ -92,7 +228,6 @@ func CreateDefaultConfig(path string) error {
TargetPort: 4965,
}
// Add RNS BetweenTheBorders Testnet interface
cfg.Interfaces["RNS Testnet BetweenTheBorders"] = &common.InterfaceConfig{
Type: "TCPClientInterface",
Enabled: true,
@@ -100,7 +235,6 @@ func CreateDefaultConfig(path string) error {
TargetPort: 4242,
}
// Add local UDP interface
cfg.Interfaces["Local UDP"] = &common.InterfaceConfig{
Type: "UDPInterface",
Enabled: true,
@@ -108,16 +242,11 @@ func CreateDefaultConfig(path string) error {
Port: 37696,
}
data, err := toml.Marshal(cfg)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
return os.WriteFile(path, data, 0644)
return SaveConfig(cfg)
}
// InitConfig initializes the configuration system

View File

@@ -1,77 +1,77 @@
package common
import (
"fmt"
"fmt"
)
const (
DEFAULT_SHARED_INSTANCE_PORT = 37428
DEFAULT_INSTANCE_CONTROL_PORT = 37429
DEFAULT_LOG_LEVEL = 20
DEFAULT_SHARED_INSTANCE_PORT = 37428
DEFAULT_INSTANCE_CONTROL_PORT = 37429
DEFAULT_LOG_LEVEL = 20
)
// ConfigProvider interface for accessing configuration
type ConfigProvider interface {
GetConfigPath() string
GetLogLevel() int
GetInterfaces() map[string]InterfaceConfig
GetConfigPath() string
GetLogLevel() int
GetInterfaces() map[string]InterfaceConfig
}
// InterfaceConfig represents interface configuration
type InterfaceConfig struct {
Name string `toml:"name"`
Type string `toml:"type"`
Enabled bool `toml:"enabled"`
Address string `toml:"address"`
Port int `toml:"port"`
TargetHost string `toml:"target_host"`
TargetPort int `toml:"target_port"`
TargetAddress string `toml:"target_address"`
Interface string `toml:"interface"`
KISSFraming bool `toml:"kiss_framing"`
I2PTunneled bool `toml:"i2p_tunneled"`
PreferIPv6 bool `toml:"prefer_ipv6"`
MaxReconnTries int `toml:"max_reconnect_tries"`
Bitrate int64 `toml:"bitrate"`
MTU int `toml:"mtu"`
GroupID string
DiscoveryScope string
Name string
Type string
Enabled bool
Address string
Port int
TargetHost string
TargetPort int
TargetAddress string
Interface string
KISSFraming bool
I2PTunneled bool
PreferIPv6 bool
MaxReconnTries int
Bitrate int64
MTU int
GroupID string
DiscoveryScope string
DiscoveryPort int
DataPort int
}
// ReticulumConfig represents the main configuration structure
type ReticulumConfig struct {
ConfigPath string `toml:"-"`
EnableTransport bool `toml:"enable_transport"`
ShareInstance bool `toml:"share_instance"`
SharedInstancePort int `toml:"shared_instance_port"`
InstanceControlPort int `toml:"instance_control_port"`
PanicOnInterfaceErr bool `toml:"panic_on_interface_error"`
LogLevel int `toml:"loglevel"`
Interfaces map[string]*InterfaceConfig `toml:"interfaces"`
ConfigPath string
EnableTransport bool
ShareInstance bool
SharedInstancePort int
InstanceControlPort int
PanicOnInterfaceErr bool
LogLevel int
Interfaces map[string]*InterfaceConfig
}
// NewReticulumConfig creates a new ReticulumConfig with default values
func NewReticulumConfig() *ReticulumConfig {
return &ReticulumConfig{
EnableTransport: true,
ShareInstance: false,
SharedInstancePort: DEFAULT_SHARED_INSTANCE_PORT,
InstanceControlPort: DEFAULT_INSTANCE_CONTROL_PORT,
PanicOnInterfaceErr: false,
LogLevel: DEFAULT_LOG_LEVEL,
Interfaces: make(map[string]*InterfaceConfig),
}
return &ReticulumConfig{
EnableTransport: true,
ShareInstance: false,
SharedInstancePort: DEFAULT_SHARED_INSTANCE_PORT,
InstanceControlPort: DEFAULT_INSTANCE_CONTROL_PORT,
PanicOnInterfaceErr: false,
LogLevel: DEFAULT_LOG_LEVEL,
Interfaces: make(map[string]*InterfaceConfig),
}
}
// Validate checks if the configuration is valid
func (c *ReticulumConfig) Validate() error {
if c.SharedInstancePort < 1 || c.SharedInstancePort > 65535 {
return fmt.Errorf("invalid shared instance port: %d", c.SharedInstancePort)
}
if c.InstanceControlPort < 1 || c.InstanceControlPort > 65535 {
return fmt.Errorf("invalid instance control port: %d", c.InstanceControlPort)
}
return nil
}
if c.SharedInstancePort < 1 || c.SharedInstancePort > 65535 {
return fmt.Errorf("invalid shared instance port: %d", c.SharedInstancePort)
}
if c.InstanceControlPort < 1 || c.InstanceControlPort > 65535 {
return fmt.Errorf("invalid instance control port: %d", c.InstanceControlPort)
}
return nil
}

View File

@@ -1,49 +1,270 @@
package config
import (
"io/ioutil"
"gopkg.in/yaml.v3"
"bufio"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)
type Config struct {
Identity struct {
Name string `yaml:"name"`
StoragePath string `yaml:"storage_path"`
} `yaml:"identity"`
Identity struct {
Name string
StoragePath string
}
Interfaces []struct {
Name string `yaml:"name"`
Type string `yaml:"type"`
Enabled bool `yaml:"enabled"`
ListenPort int `yaml:"listen_port"`
ListenIP string `yaml:"listen_ip"`
KissFraming bool `yaml:"kiss_framing"`
I2PTunneled bool `yaml:"i2p_tunneled"`
} `yaml:"interfaces"`
Interfaces []struct {
Name string
Type string
Enabled bool
ListenPort int
ListenIP string
KissFraming bool
I2PTunneled bool
}
Transport struct {
AnnounceInterval int `yaml:"announce_interval"`
PathRequestTimeout int `yaml:"path_request_timeout"`
MaxHops int `yaml:"max_hops"`
BitrateLimit int64 `yaml:"bitrate_limit"`
} `yaml:"transport"`
Transport struct {
AnnounceInterval int
PathRequestTimeout int
MaxHops int
BitrateLimit int64
}
Logging struct {
Level string `yaml:"level"`
File string `yaml:"file"`
} `yaml:"logging"`
Logging struct {
Level string
File string
}
}
func LoadConfig(path string) (*Config, error) {
data, err := ioutil.ReadFile(path)
if err != nil {
return nil, err
}
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, err
}
cfg := &Config{}
scanner := bufio.NewScanner(file)
var currentSection string
return &cfg, nil
}
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// Skip comments and empty lines
if line == "" || strings.HasPrefix(line, "#") {
continue
}
// Handle sections
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
currentSection = strings.Trim(line, "[]")
// If this is an interface section, append new interface
if strings.HasPrefix(currentSection, "interface ") {
cfg.Interfaces = append(cfg.Interfaces, struct {
Name string
Type string
Enabled bool
ListenPort int
ListenIP string
KissFraming bool
I2PTunneled bool
}{})
}
continue
}
// Parse key-value pairs
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
switch currentSection {
case "identity":
switch key {
case "name":
cfg.Identity.Name = value
case "storage_path":
cfg.Identity.StoragePath = value
}
case "transport":
switch key {
case "announce_interval":
cfg.Transport.AnnounceInterval, _ = strconv.Atoi(value)
case "path_request_timeout":
cfg.Transport.PathRequestTimeout, _ = strconv.Atoi(value)
case "max_hops":
cfg.Transport.MaxHops, _ = strconv.Atoi(value)
case "bitrate_limit":
cfg.Transport.BitrateLimit, _ = strconv.ParseInt(value, 10, 64)
}
case "logging":
switch key {
case "level":
cfg.Logging.Level = value
case "file":
cfg.Logging.File = value
}
default:
// Handle interface sections
if strings.HasPrefix(currentSection, "interface ") && len(cfg.Interfaces) > 0 {
iface := &cfg.Interfaces[len(cfg.Interfaces)-1]
switch key {
case "name":
iface.Name = value
case "type":
iface.Type = value
case "enabled":
iface.Enabled = value == "true"
case "listen_port":
iface.ListenPort, _ = strconv.Atoi(value)
case "listen_ip":
iface.ListenIP = value
case "kiss_framing":
iface.KissFraming = value == "true"
case "i2p_tunneled":
iface.I2PTunneled = value == "true"
}
}
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
return cfg, nil
}
func SaveConfig(cfg *Config, path string) error {
var builder strings.Builder
// Write Identity section
builder.WriteString("[identity]\n")
builder.WriteString(fmt.Sprintf("name = %s\n", cfg.Identity.Name))
builder.WriteString(fmt.Sprintf("storage_path = %s\n\n", cfg.Identity.StoragePath))
// Write Transport section
builder.WriteString("[transport]\n")
builder.WriteString(fmt.Sprintf("announce_interval = %d\n", cfg.Transport.AnnounceInterval))
builder.WriteString(fmt.Sprintf("path_request_timeout = %d\n", cfg.Transport.PathRequestTimeout))
builder.WriteString(fmt.Sprintf("max_hops = %d\n", cfg.Transport.MaxHops))
builder.WriteString(fmt.Sprintf("bitrate_limit = %d\n\n", cfg.Transport.BitrateLimit))
// Write Logging section
builder.WriteString("[logging]\n")
builder.WriteString(fmt.Sprintf("level = %s\n", cfg.Logging.Level))
builder.WriteString(fmt.Sprintf("file = %s\n\n", cfg.Logging.File))
// Write Interface sections
for _, iface := range cfg.Interfaces {
builder.WriteString(fmt.Sprintf("[interface %s]\n", iface.Name))
builder.WriteString(fmt.Sprintf("type = %s\n", iface.Type))
builder.WriteString(fmt.Sprintf("enabled = %v\n", iface.Enabled))
builder.WriteString(fmt.Sprintf("listen_port = %d\n", iface.ListenPort))
builder.WriteString(fmt.Sprintf("listen_ip = %s\n", iface.ListenIP))
builder.WriteString(fmt.Sprintf("kiss_framing = %v\n", iface.KissFraming))
builder.WriteString(fmt.Sprintf("i2p_tunneled = %v\n\n", iface.I2PTunneled))
}
return os.WriteFile(path, []byte(builder.String()), 0644)
}
func GetConfigDir() string {
homeDir, err := os.UserHomeDir()
if err != nil {
// Fallback to current directory if home directory cannot be determined
return ".reticulum-go"
}
return filepath.Join(homeDir, ".reticulum-go")
}
func GetDefaultConfigPath() string {
return filepath.Join(GetConfigDir(), "config")
}
func EnsureConfigDir() error {
configDir := GetConfigDir()
return os.MkdirAll(configDir, 0755)
}
func InitConfig() (*Config, error) {
// Ensure config directory exists
if err := EnsureConfigDir(); err != nil {
return nil, fmt.Errorf("failed to create config directory: %v", err)
}
configPath := GetDefaultConfigPath()
// Check if config file exists
if _, err := os.Stat(configPath); os.IsNotExist(err) {
// Create default config
cfg := &Config{}
// Set default values
cfg.Identity.Name = "reticulum-node"
cfg.Identity.StoragePath = filepath.Join(GetConfigDir(), "storage")
cfg.Transport.AnnounceInterval = 300
cfg.Transport.PathRequestTimeout = 15
cfg.Transport.MaxHops = 8
cfg.Transport.BitrateLimit = 1000000
cfg.Logging.Level = "info"
cfg.Logging.File = filepath.Join(GetConfigDir(), "reticulum.log")
// Add default interfaces
cfg.Interfaces = append(cfg.Interfaces, struct {
Name string
Type string
Enabled bool
ListenPort int
ListenIP string
KissFraming bool
I2PTunneled bool
}{
Name: "Local UDP",
Type: "UDPInterface",
Enabled: true,
ListenPort: 37697,
ListenIP: "0.0.0.0",
})
cfg.Interfaces = append(cfg.Interfaces, struct {
Name string
Type string
Enabled bool
ListenPort int
ListenIP string
KissFraming bool
I2PTunneled bool
}{
Name: "Auto Discovery",
Type: "AutoInterface",
Enabled: true,
ListenPort: 29717,
})
// Save default config
if err := SaveConfig(cfg, configPath); err != nil {
return nil, fmt.Errorf("failed to save default config: %v", err)
}
}
// Load config
cfg, err := LoadConfig(configPath)
if err != nil {
return nil, fmt.Errorf("failed to load config: %v", err)
}
return cfg, nil
}

View File

@@ -322,7 +322,8 @@ func (d *Destination) Encrypt(plaintext []byte) ([]byte, error) {
if key == nil {
return nil, errors.New("no ratchet key available")
}
return d.identity.EncryptSymmetric(plaintext, key)
// CBC encryption with HMAC for group messages
return d.identity.EncryptWithHMAC(plaintext, key)
default:
return nil, errors.New("unsupported destination type for encryption")
}

View File

@@ -57,51 +57,6 @@ var (
ratchetPersistLock sync.Mutex
)
func encryptAESGCM(key, plaintext []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, err
}
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
return ciphertext, nil
}
func decryptAESGCM(key, ciphertext []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonceSize := gcm.NonceSize()
if len(ciphertext) < nonceSize {
return nil, err
}
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, err
}
return plaintext, nil
}
func encryptAESCBC(key, plaintext []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
@@ -412,20 +367,6 @@ func (i *Identity) GetCurrentRatchetKey() []byte {
return latestKey
}
func (i *Identity) EncryptSymmetric(plaintext []byte, key []byte) ([]byte, error) {
if len(key) != 32 {
return nil, errors.New("invalid key length")
}
return encryptAESGCM(key, plaintext)
}
func (i *Identity) DecryptSymmetric(ciphertext []byte, key []byte) ([]byte, error) {
if len(key) != 32 {
return nil, errors.New("invalid key length")
}
return decryptAESGCM(key, ciphertext)
}
func (i *Identity) Decrypt(ciphertextToken []byte, ratchets [][]byte, enforceRatchets bool, ratchetIDReceiver *common.RatchetIDReceiver) ([]byte, error) {
if i.privateKey == nil {
return nil, errors.New("decryption failed because identity does not hold a private key")