Files
Reticulum-Go/pkg/identity/identity.go
Sudo-Ivan a2476c9551 0.2.2
2024-12-30 02:43:35 -06:00

438 lines
10 KiB
Go

package identity
import (
"crypto/aes"
"crypto/cipher"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"errors"
"io"
"os"
"sync"
"time"
"encoding/hex"
"fmt"
"path/filepath"
"bytes"
"golang.org/x/crypto/curve25519"
"golang.org/x/crypto/hkdf"
"github.com/Sudo-Ivan/reticulum-go/pkg/common"
)
const (
KeySize = 512 // Combined size of encryption and signing keys
RatchetSize = 256
RatchetExpiry = 2592000 // 30 days in seconds
TruncatedHashLen = 128 // bits
)
type Identity struct {
privateKey []byte
publicKey []byte
signingKey ed25519.PrivateKey
verificationKey ed25519.PublicKey
ratchets map[string][]byte
ratchetExpiry map[string]int64
mutex sync.RWMutex
}
func New() (*Identity, error) {
i := &Identity{
ratchets: make(map[string][]byte),
ratchetExpiry: make(map[string]int64),
}
// Generate X25519 key pair
var err error
i.privateKey = make([]byte, curve25519.ScalarSize)
if _, err = io.ReadFull(rand.Reader, i.privateKey); err != nil {
return nil, err
}
// Generate public key
i.publicKey, err = curve25519.X25519(i.privateKey, curve25519.Basepoint)
if err != nil {
return nil, err
}
// Generate Ed25519 signing keypair
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, err
}
i.signingKey = privateKey
i.verificationKey = publicKey
return i, nil
}
func FromBytes(data []byte) (*Identity, error) {
if len(data) != KeySize/8 {
return nil, errors.New("invalid key size")
}
i := &Identity{
ratchets: make(map[string][]byte),
ratchetExpiry: make(map[string]int64),
}
// First 32 bytes are X25519 private key
i.privateKey = data[:32]
var err error
i.publicKey, err = curve25519.X25519(i.privateKey, curve25519.Basepoint)
if err != nil {
return nil, err
}
// Next 32 bytes are Ed25519 private key
i.signingKey = ed25519.PrivateKey(data[32:])
i.verificationKey = i.signingKey.Public().(ed25519.PublicKey)
return i, nil
}
func (i *Identity) ToBytes() []byte {
data := make([]byte, KeySize/8)
copy(data[:32], i.privateKey)
copy(data[32:], i.signingKey)
return data
}
func (i *Identity) SaveToFile(path string) error {
return os.WriteFile(path, i.ToBytes(), 0600)
}
func LoadFromFile(path string) (*Identity, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return FromBytes(data)
}
func (i *Identity) Encrypt(plaintext []byte, ratchets []byte) ([]byte, error) {
// Generate ephemeral key pair
ephemeralPrivate := make([]byte, curve25519.ScalarSize)
if _, err := io.ReadFull(rand.Reader, ephemeralPrivate); err != nil {
return nil, err
}
ephemeralPublic, err := curve25519.X25519(ephemeralPrivate, curve25519.Basepoint)
if err != nil {
return nil, err
}
// Perform key exchange
sharedSecret, err := curve25519.X25519(ephemeralPrivate, i.publicKey)
if err != nil {
return nil, err
}
// Generate AES key from shared secret using HKDF
hash := sha256.New
hkdf := hkdf.New(hash, sharedSecret, nil, nil)
aesKey := make([]byte, 32)
if _, err := io.ReadFull(hkdf, aesKey); err != nil {
return nil, err
}
// Create AES-GCM cipher
block, err := aes.NewCipher(aesKey)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
// Generate nonce
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, err
}
// Encrypt plaintext
ciphertext := gcm.Seal(nil, nonce, plaintext, nil)
// Combine ephemeral public key, nonce and ciphertext
result := make([]byte, len(ephemeralPublic)+len(nonce)+len(ciphertext))
copy(result, ephemeralPublic)
copy(result[len(ephemeralPublic):], nonce)
copy(result[len(ephemeralPublic)+len(nonce):], ciphertext)
return result, nil
}
func (i *Identity) Decrypt(ciphertext []byte, ratchets []byte) ([]byte, error) {
if len(ciphertext) <= curve25519.ScalarSize {
return nil, errors.New("invalid ciphertext")
}
// Extract ephemeral public key
ephemeralPublic := ciphertext[:curve25519.ScalarSize]
// Perform key exchange
sharedSecret, err := curve25519.X25519(i.privateKey, ephemeralPublic)
if err != nil {
return nil, err
}
// Generate AES key from shared secret using HKDF
hash := sha256.New
hkdf := hkdf.New(hash, sharedSecret, nil, nil)
aesKey := make([]byte, 32)
if _, err := io.ReadFull(hkdf, aesKey); err != nil {
return nil, err
}
// Create AES-GCM cipher
block, err := aes.NewCipher(aesKey)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
// Extract nonce and encrypted data
nonceSize := gcm.NonceSize()
if len(ciphertext) < curve25519.ScalarSize+nonceSize {
return nil, errors.New("invalid ciphertext")
}
nonce := ciphertext[curve25519.ScalarSize : curve25519.ScalarSize+nonceSize]
encryptedData := ciphertext[curve25519.ScalarSize+nonceSize:]
// Decrypt data
plaintext, err := gcm.Open(nil, nonce, encryptedData, nil)
if err != nil {
return nil, err
}
return plaintext, nil
}
func (i *Identity) Sign(message []byte) []byte {
return ed25519.Sign(i.signingKey, message)
}
func (i *Identity) Verify(message, signature []byte) bool {
return ed25519.Verify(i.verificationKey, message, signature)
}
func (i *Identity) GetPublicKey() []byte {
return append([]byte{}, i.publicKey...)
}
func (i *Identity) AddRatchet(ratchetID string, ratchetKey []byte) {
i.mutex.Lock()
defer i.mutex.Unlock()
i.ratchets[ratchetID] = ratchetKey
i.ratchetExpiry[ratchetID] = time.Now().Unix() + RatchetExpiry
}
func (i *Identity) GetRatchet(ratchetID string) []byte {
i.mutex.RLock()
defer i.mutex.RUnlock()
if expiry, ok := i.ratchetExpiry[ratchetID]; ok {
if time.Now().Unix() < expiry {
return i.ratchets[ratchetID]
}
// Cleanup expired ratchet
delete(i.ratchets, ratchetID)
delete(i.ratchetExpiry, ratchetID)
}
return nil
}
// Helper functions
func TruncatedHash(data []byte) []byte {
hash := sha256.Sum256(data)
return hash[:TruncatedHashLen/8]
}
func FullHash(data []byte) []byte {
hash := sha256.Sum256(data)
return hash[:]
}
func HashFromHex(hexHash string) ([]byte, error) {
if len(hexHash) != TruncatedHashLen/4 { // hex string is twice the length of bytes
return nil, errors.New("invalid hash length")
}
hash := make([]byte, TruncatedHashLen/8)
_, err := hex.Decode(hash, []byte(hexHash))
if err != nil {
return nil, err
}
return hash, nil
}
func Recall(hash []byte) (*Identity, error) {
// Get config path from environment or default location
configDir := os.Getenv("RETICULUM_CONFIG_DIR")
if configDir == "" {
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("failed to get home directory: %w", err)
}
configDir = filepath.Join(homeDir, ".reticulum-go")
}
// Create identities directory if it doesn't exist
identitiesPath := filepath.Join(configDir, "identities")
if err := os.MkdirAll(identitiesPath, 0755); err != nil {
return nil, fmt.Errorf("failed to create identities directory: %w", err)
}
// Convert hash to hex for filename
hashHex := hex.EncodeToString(hash)
identityPath := filepath.Join(identitiesPath, hashHex)
// Check if identity file exists
if _, err := os.Stat(identityPath); os.IsNotExist(err) {
return nil, errors.New("identity not found")
}
// Load identity from file
identity, err := LoadFromFile(identityPath)
if err != nil {
return nil, fmt.Errorf("failed to load identity: %w", err)
}
// Verify the loaded identity matches the requested hash
if !bytes.Equal(TruncatedHash(identity.GetPublicKey()), hash) {
return nil, errors.New("identity hash mismatch")
}
return identity, nil
}
func LoadIdentity(cfg *common.ReticulumConfig) (*Identity, error) {
if cfg == nil {
return nil, errors.New("config cannot be nil")
}
// Try to load existing identity
identityPath := filepath.Join(filepath.Dir(cfg.ConfigPath), "identity")
if _, err := os.Stat(identityPath); err == nil {
// Identity exists, load it
return LoadFromFile(identityPath)
}
// Create new identity
identity, err := New()
if err != nil {
return nil, fmt.Errorf("failed to create new identity: %w", err)
}
// Save the new identity
if err := identity.SaveToFile(identityPath); err != nil {
return nil, fmt.Errorf("failed to save new identity: %w", err)
}
return identity, nil
}
func (i *Identity) GetCurrentRatchetKey() []byte {
i.mutex.RLock()
defer i.mutex.RUnlock()
// Generate new ratchet key if none exists
if len(i.ratchets) == 0 {
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
return nil
}
ratchetID := fmt.Sprintf("%d", time.Now().Unix())
i.AddRatchet(ratchetID, key)
return key
}
// Return most recent ratchet key
var latestTime int64
var latestKey []byte
for id, key := range i.ratchets {
if expiry, ok := i.ratchetExpiry[id]; ok {
if expiry > latestTime {
latestTime = expiry
latestKey = key
}
}
}
return latestKey
}
func (i *Identity) EncryptSymmetric(plaintext []byte) ([]byte, error) {
key := i.GetCurrentRatchetKey()
if key == nil {
return nil, errors.New("no ratchet key available")
}
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
}
return gcm.Seal(nonce, nonce, plaintext, nil), nil
}
func (i *Identity) DecryptSymmetric(ciphertext []byte) ([]byte, error) {
key := i.GetCurrentRatchetKey()
if key == nil {
return nil, errors.New("no ratchet key available")
}
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, errors.New("ciphertext too short")
}
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, fmt.Errorf("decryption failed: %w", err)
}
return plaintext, nil
}
func (i *Identity) Hash() []byte {
return TruncatedHash(i.publicKey)
}
func (i *Identity) Hex() string {
return hex.EncodeToString(i.Hash())
}