331 lines
9.9 KiB
Go
331 lines
9.9 KiB
Go
package dolt
|
|
|
|
import (
|
|
"context"
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/steveyegge/beads/internal/storage"
|
|
)
|
|
|
|
// Credential storage and encryption for federation peers.
|
|
// Enables SQL user authentication when syncing with peer Gas Towns.
|
|
|
|
// federationEnvMutex protects DOLT_REMOTE_USER/PASSWORD env vars from concurrent access.
|
|
// Environment variables are process-global, so we need to serialize federation operations.
|
|
var federationEnvMutex sync.Mutex
|
|
|
|
// validPeerNameRegex matches valid peer names (alphanumeric, hyphens, underscores)
|
|
var validPeerNameRegex = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_-]*$`)
|
|
|
|
// validatePeerName checks that a peer name is safe for use as a Dolt remote name
|
|
func validatePeerName(name string) error {
|
|
if name == "" {
|
|
return fmt.Errorf("peer name cannot be empty")
|
|
}
|
|
if len(name) > 64 {
|
|
return fmt.Errorf("peer name too long (max 64 characters)")
|
|
}
|
|
if !validPeerNameRegex.MatchString(name) {
|
|
return fmt.Errorf("peer name must start with a letter and contain only alphanumeric characters, hyphens, and underscores")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// encryptionKey derives a key from the database path for credential encryption.
|
|
// This provides basic protection - credentials are not stored in plaintext.
|
|
// For production, consider using system keyring or external secret managers.
|
|
func (s *DoltStore) encryptionKey() []byte {
|
|
// Use SHA-256 hash of the database path as the key (32 bytes for AES-256)
|
|
// This ties credentials to this specific database location
|
|
h := sha256.New()
|
|
h.Write([]byte(s.dbPath + "beads-federation-key-v1"))
|
|
return h.Sum(nil)
|
|
}
|
|
|
|
// encryptPassword encrypts a password using AES-GCM
|
|
func (s *DoltStore) encryptPassword(password string) ([]byte, error) {
|
|
if password == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
block, err := aes.NewCipher(s.encryptionKey())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create cipher: %w", err)
|
|
}
|
|
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create GCM: %w", err)
|
|
}
|
|
|
|
nonce := make([]byte, gcm.NonceSize())
|
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
|
return nil, fmt.Errorf("failed to generate nonce: %w", err)
|
|
}
|
|
|
|
ciphertext := gcm.Seal(nonce, nonce, []byte(password), nil)
|
|
return ciphertext, nil
|
|
}
|
|
|
|
// decryptPassword decrypts a password using AES-GCM
|
|
func (s *DoltStore) decryptPassword(encrypted []byte) (string, error) {
|
|
if len(encrypted) == 0 {
|
|
return "", nil
|
|
}
|
|
|
|
block, err := aes.NewCipher(s.encryptionKey())
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create cipher: %w", err)
|
|
}
|
|
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create GCM: %w", err)
|
|
}
|
|
|
|
nonceSize := gcm.NonceSize()
|
|
if len(encrypted) < nonceSize {
|
|
return "", fmt.Errorf("ciphertext too short")
|
|
}
|
|
|
|
nonce, ciphertext := encrypted[:nonceSize], encrypted[nonceSize:]
|
|
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to decrypt: %w", err)
|
|
}
|
|
|
|
return string(plaintext), nil
|
|
}
|
|
|
|
// AddFederationPeer adds or updates a federation peer with credentials.
|
|
// This stores credentials in the database and also adds the Dolt remote.
|
|
func (s *DoltStore) AddFederationPeer(ctx context.Context, peer *storage.FederationPeer) error {
|
|
// Validate peer name
|
|
if err := validatePeerName(peer.Name); err != nil {
|
|
return fmt.Errorf("invalid peer name: %w", err)
|
|
}
|
|
|
|
// Encrypt password before storing
|
|
var encryptedPwd []byte
|
|
var err error
|
|
if peer.Password != "" {
|
|
encryptedPwd, err = s.encryptPassword(peer.Password)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to encrypt password: %w", err)
|
|
}
|
|
}
|
|
|
|
// Upsert the peer credentials
|
|
_, err = s.db.ExecContext(ctx, `
|
|
INSERT INTO federation_peers (name, remote_url, username, password_encrypted, sovereignty)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON DUPLICATE KEY UPDATE
|
|
remote_url = VALUES(remote_url),
|
|
username = VALUES(username),
|
|
password_encrypted = VALUES(password_encrypted),
|
|
sovereignty = VALUES(sovereignty),
|
|
updated_at = CURRENT_TIMESTAMP
|
|
`, peer.Name, peer.RemoteURL, peer.Username, encryptedPwd, peer.Sovereignty)
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("failed to add federation peer: %w", err)
|
|
}
|
|
|
|
// Also add the Dolt remote
|
|
if err := s.AddRemote(ctx, peer.Name, peer.RemoteURL); err != nil {
|
|
// Ignore "remote already exists" errors
|
|
if !strings.Contains(err.Error(), "already exists") {
|
|
return fmt.Errorf("failed to add dolt remote: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetFederationPeer retrieves a federation peer by name.
|
|
// Returns nil if peer doesn't exist.
|
|
func (s *DoltStore) GetFederationPeer(ctx context.Context, name string) (*storage.FederationPeer, error) {
|
|
var peer storage.FederationPeer
|
|
var encryptedPwd []byte
|
|
var lastSync sql.NullTime
|
|
var username sql.NullString
|
|
|
|
err := s.db.QueryRowContext(ctx, `
|
|
SELECT name, remote_url, username, password_encrypted, sovereignty, last_sync, created_at, updated_at
|
|
FROM federation_peers WHERE name = ?
|
|
`, name).Scan(&peer.Name, &peer.RemoteURL, &username, &encryptedPwd, &peer.Sovereignty, &lastSync, &peer.CreatedAt, &peer.UpdatedAt)
|
|
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get federation peer: %w", err)
|
|
}
|
|
|
|
if username.Valid {
|
|
peer.Username = username.String
|
|
}
|
|
if lastSync.Valid {
|
|
peer.LastSync = &lastSync.Time
|
|
}
|
|
|
|
// Decrypt password
|
|
if len(encryptedPwd) > 0 {
|
|
peer.Password, err = s.decryptPassword(encryptedPwd)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decrypt password: %w", err)
|
|
}
|
|
}
|
|
|
|
return &peer, nil
|
|
}
|
|
|
|
// ListFederationPeers returns all configured federation peers.
|
|
func (s *DoltStore) ListFederationPeers(ctx context.Context) ([]*storage.FederationPeer, error) {
|
|
rows, err := s.db.QueryContext(ctx, `
|
|
SELECT name, remote_url, username, password_encrypted, sovereignty, last_sync, created_at, updated_at
|
|
FROM federation_peers ORDER BY name
|
|
`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list federation peers: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var peers []*storage.FederationPeer
|
|
for rows.Next() {
|
|
var peer storage.FederationPeer
|
|
var encryptedPwd []byte
|
|
var lastSync sql.NullTime
|
|
var username sql.NullString
|
|
|
|
if err := rows.Scan(&peer.Name, &peer.RemoteURL, &username, &encryptedPwd, &peer.Sovereignty, &lastSync, &peer.CreatedAt, &peer.UpdatedAt); err != nil {
|
|
return nil, fmt.Errorf("failed to scan federation peer: %w", err)
|
|
}
|
|
|
|
if username.Valid {
|
|
peer.Username = username.String
|
|
}
|
|
if lastSync.Valid {
|
|
peer.LastSync = &lastSync.Time
|
|
}
|
|
|
|
// Decrypt password
|
|
if len(encryptedPwd) > 0 {
|
|
peer.Password, err = s.decryptPassword(encryptedPwd)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decrypt password: %w", err)
|
|
}
|
|
}
|
|
|
|
peers = append(peers, &peer)
|
|
}
|
|
|
|
return peers, rows.Err()
|
|
}
|
|
|
|
// RemoveFederationPeer removes a federation peer and its credentials.
|
|
func (s *DoltStore) RemoveFederationPeer(ctx context.Context, name string) error {
|
|
result, err := s.db.ExecContext(ctx, "DELETE FROM federation_peers WHERE name = ?", name)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to remove federation peer: %w", err)
|
|
}
|
|
|
|
rows, _ := result.RowsAffected()
|
|
if rows == 0 {
|
|
// Peer not in credentials table, but might still be a Dolt remote
|
|
// Continue to try removing the remote
|
|
}
|
|
|
|
// Also remove the Dolt remote (best-effort)
|
|
_ = s.RemoveRemote(ctx, name)
|
|
|
|
return nil
|
|
}
|
|
|
|
// UpdatePeerLastSync updates the last sync time for a peer.
|
|
func (s *DoltStore) UpdatePeerLastSync(ctx context.Context, name string) error {
|
|
_, err := s.db.ExecContext(ctx, "UPDATE federation_peers SET last_sync = CURRENT_TIMESTAMP WHERE name = ?", name)
|
|
return err
|
|
}
|
|
|
|
// setFederationCredentials sets DOLT_REMOTE_USER and DOLT_REMOTE_PASSWORD env vars.
|
|
// Returns a cleanup function that must be called (typically via defer) to unset them.
|
|
// The caller must hold federationEnvMutex.
|
|
func setFederationCredentials(username, password string) func() {
|
|
if username != "" {
|
|
// Best-effort: failures here should not crash the caller.
|
|
_ = os.Setenv("DOLT_REMOTE_USER", username)
|
|
}
|
|
if password != "" {
|
|
// Best-effort: failures here should not crash the caller.
|
|
_ = os.Setenv("DOLT_REMOTE_PASSWORD", password)
|
|
}
|
|
return func() {
|
|
// Best-effort cleanup.
|
|
_ = os.Unsetenv("DOLT_REMOTE_USER")
|
|
_ = os.Unsetenv("DOLT_REMOTE_PASSWORD")
|
|
}
|
|
}
|
|
|
|
// withPeerCredentials executes a function with peer credentials set in environment.
|
|
// If the peer has stored credentials, they are set as DOLT_REMOTE_USER/PASSWORD
|
|
// for the duration of the function call.
|
|
func (s *DoltStore) withPeerCredentials(ctx context.Context, peerName string, fn func() error) error {
|
|
// Look up credentials for this peer
|
|
peer, err := s.GetFederationPeer(ctx, peerName)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get peer credentials: %w", err)
|
|
}
|
|
|
|
// If we have credentials, set env vars with mutex protection
|
|
if peer != nil && (peer.Username != "" || peer.Password != "") {
|
|
federationEnvMutex.Lock()
|
|
cleanup := setFederationCredentials(peer.Username, peer.Password)
|
|
defer func() {
|
|
cleanup()
|
|
federationEnvMutex.Unlock()
|
|
}()
|
|
}
|
|
|
|
// Execute the function
|
|
err = fn()
|
|
|
|
// Update last sync time on success
|
|
if err == nil && peer != nil {
|
|
_ = s.UpdatePeerLastSync(ctx, peerName)
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
// PushWithCredentials pushes to a remote using stored credentials.
|
|
func (s *DoltStore) PushWithCredentials(ctx context.Context, remoteName string) error {
|
|
return s.withPeerCredentials(ctx, remoteName, func() error {
|
|
return s.PushTo(ctx, remoteName)
|
|
})
|
|
}
|
|
|
|
// PullWithCredentials pulls from a remote using stored credentials.
|
|
func (s *DoltStore) PullWithCredentials(ctx context.Context, remoteName string) ([]storage.Conflict, error) {
|
|
var conflicts []storage.Conflict
|
|
err := s.withPeerCredentials(ctx, remoteName, func() error {
|
|
var pullErr error
|
|
conflicts, pullErr = s.PullFrom(ctx, remoteName)
|
|
return pullErr
|
|
})
|
|
return conflicts, err
|
|
}
|
|
|
|
// FederationPeer is an alias for storage.FederationPeer for convenience.
|
|
type FederationPeer = storage.FederationPeer
|