feat(federation): add SQL user authentication for peer sync

Merge SQL user authentication with Emma federation sync implementation:

- Add federation_peers table for encrypted credential storage
- Add credentials.go with AES-256-GCM encryption, SHA-256 key derivation
- Extend FederatedStorage interface with credential methods
- Add --user, --password, --sovereignty flags to bd federation add-peer
- Integrate credentials into PushTo/PullFrom/Fetch via withPeerCredentials
- DOLT_REMOTE_USER/PASSWORD env vars protected by mutex for concurrency

Credentials automatically used when syncing with peers that have stored auth.

Continues: bd-wkumz.10, Closes: bd-4p67y

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
beads/crew/jane
2026-01-20 21:15:00 -08:00
committed by Steve Yegge
parent ea51c4b0bd
commit d3d2326a8b
6 changed files with 490 additions and 29 deletions

View File

@@ -2,16 +2,23 @@ package main
import (
"fmt"
"os"
"strings"
"syscall"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/storage/dolt"
"github.com/steveyegge/beads/internal/ui"
"golang.org/x/term"
)
var (
federationPeer string
federationStrategy string
federationUser string
federationPassword string
federationSov string
)
var federationCmd = &cobra.Command{
@@ -66,17 +73,22 @@ Examples:
var federationAddPeerCmd = &cobra.Command{
Use: "add-peer <name> <url>",
Short: "Add a federation peer",
Long: `Add a new federation peer remote.
Short: "Add a federation peer with optional SQL credentials",
Long: `Add a new federation peer remote with optional SQL user authentication.
The URL can be:
- dolthub://org/repo DoltHub hosted repository
- host:port/database Direct dolt sql-server connection
- file:///path/to/repo Local file path (for testing)
Credentials are encrypted and stored locally. They are used automatically
when syncing with the peer. If --user is provided without --password,
you will be prompted for the password interactively.
Examples:
bd federation add-peer town-beta dolthub://acme/town-beta-beads
bd federation add-peer town-gamma 192.168.1.100:3306/beads`,
bd federation add-peer town-gamma 192.168.1.100:3306/beads --user sync-bot
bd federation add-peer partner https://partner.example.com/beads --user admin --password secret`,
Args: cobra.ExactArgs(2),
Run: runFederationAddPeer,
}
@@ -109,6 +121,11 @@ func init() {
// Flags for status
federationStatusCmd.Flags().StringVar(&federationPeer, "peer", "", "Specific peer to check")
// Flags for add-peer (SQL user authentication)
federationAddPeerCmd.Flags().StringVarP(&federationUser, "user", "u", "", "SQL username for authentication")
federationAddPeerCmd.Flags().StringVarP(&federationPassword, "password", "p", "", "SQL password (prompted if --user set without --password)")
federationAddPeerCmd.Flags().StringVar(&federationSov, "sovereignty", "", "Sovereignty tier (T1, T2, T3, T4)")
rootCmd.AddCommand(federationCmd)
}
@@ -288,19 +305,63 @@ func runFederationAddPeer(cmd *cobra.Command, args []string) {
FatalErrorRespectJSON("federation requires Dolt backend")
}
if err := fs.AddRemote(ctx, name, url); err != nil {
FatalErrorRespectJSON("failed to add peer: %v", err)
// If user is provided but password is not, prompt for it
password := federationPassword
if federationUser != "" && password == "" {
fmt.Fprint(os.Stderr, "Password: ")
pwBytes, err := term.ReadPassword(int(syscall.Stdin))
fmt.Fprintln(os.Stderr) // newline after password
if err != nil {
FatalErrorRespectJSON("failed to read password: %v", err)
}
password = string(pwBytes)
}
// Validate sovereignty tier if provided
sov := federationSov
if sov != "" {
sov = strings.ToUpper(sov)
if sov != "T1" && sov != "T2" && sov != "T3" && sov != "T4" {
FatalErrorRespectJSON("invalid sovereignty tier: %s (must be T1, T2, T3, or T4)", federationSov)
}
}
// If credentials provided, use AddFederationPeer to store them
if federationUser != "" {
peer := &storage.FederationPeer{
Name: name,
RemoteURL: url,
Username: federationUser,
Password: password,
Sovereignty: sov,
}
if err := fs.AddFederationPeer(ctx, peer); err != nil {
FatalErrorRespectJSON("failed to add peer: %v", err)
}
} else {
// No credentials, just add the remote
if err := fs.AddRemote(ctx, name, url); err != nil {
FatalErrorRespectJSON("failed to add peer: %v", err)
}
}
if jsonOutput {
outputJSON(map[string]interface{}{
"added": name,
"url": url,
"added": name,
"url": url,
"has_auth": federationUser != "",
"sovereignty": sov,
})
return
}
fmt.Printf("Added peer %s: %s\n", ui.RenderAccent(name), url)
if federationUser != "" {
fmt.Printf(" User: %s (credentials stored)\n", federationUser)
}
if sov != "" {
fmt.Printf(" Sovereignty: %s\n", sov)
}
}
func runFederationRemovePeer(cmd *cobra.Command, args []string) {

View File

@@ -0,0 +1,327 @@
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 != "" {
os.Setenv("DOLT_REMOTE_USER", username)
}
if password != "" {
os.Setenv("DOLT_REMOTE_PASSWORD", password)
}
return func() {
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

View File

@@ -0,0 +1,13 @@
package dolt
import (
"testing"
"github.com/steveyegge/beads/internal/storage"
)
func TestDoltStoreImplementsCredentialMethods(t *testing.T) {
// Compile-time check that DoltStore implements FederatedStorage with credential methods
var _ storage.FederatedStorage = (*DoltStore)(nil)
t.Log("DoltStore implements FederatedStorage interface with credential methods")
}

View File

@@ -12,39 +12,51 @@ import (
// These methods enable peer-to-peer synchronization between Gas Towns.
// PushTo pushes commits to a specific peer remote.
// If credentials are stored for this peer, they are used automatically.
func (s *DoltStore) PushTo(ctx context.Context, peer string) error {
// DOLT_PUSH(remote, branch)
_, err := s.db.ExecContext(ctx, "CALL DOLT_PUSH(?, ?)", peer, s.branch)
if err != nil {
return fmt.Errorf("failed to push to peer %s: %w", peer, err)
}
return nil
return s.withPeerCredentials(ctx, peer, func() error {
// DOLT_PUSH(remote, branch)
_, err := s.db.ExecContext(ctx, "CALL DOLT_PUSH(?, ?)", peer, s.branch)
if err != nil {
return fmt.Errorf("failed to push to peer %s: %w", peer, err)
}
return nil
})
}
// PullFrom pulls changes from a specific peer remote.
// If credentials are stored for this peer, they are used automatically.
// Returns any merge conflicts if present.
func (s *DoltStore) PullFrom(ctx context.Context, peer string) ([]storage.Conflict, error) {
// DOLT_PULL(remote) - pulls and merges
_, err := s.db.ExecContext(ctx, "CALL DOLT_PULL(?)", peer)
if err != nil {
// Check if the error is due to merge conflicts
conflicts, conflictErr := s.GetConflicts(ctx)
if conflictErr == nil && len(conflicts) > 0 {
return conflicts, nil
var conflicts []storage.Conflict
err := s.withPeerCredentials(ctx, peer, func() error {
// DOLT_PULL(remote) - pulls and merges
_, pullErr := s.db.ExecContext(ctx, "CALL DOLT_PULL(?)", peer)
if pullErr != nil {
// Check if the error is due to merge conflicts
c, conflictErr := s.GetConflicts(ctx)
if conflictErr == nil && len(c) > 0 {
conflicts = c
return nil
}
return fmt.Errorf("failed to pull from peer %s: %w", peer, pullErr)
}
return nil, fmt.Errorf("failed to pull from peer %s: %w", peer, err)
}
return nil, nil
return nil
})
return conflicts, err
}
// Fetch fetches refs from a peer without merging.
// If credentials are stored for this peer, they are used automatically.
func (s *DoltStore) Fetch(ctx context.Context, peer string) error {
// DOLT_FETCH(remote)
_, err := s.db.ExecContext(ctx, "CALL DOLT_FETCH(?)", peer)
if err != nil {
return fmt.Errorf("failed to fetch from peer %s: %w", peer, err)
}
return nil
return s.withPeerCredentials(ctx, peer, func() error {
// DOLT_FETCH(remote)
_, err := s.db.ExecContext(ctx, "CALL DOLT_FETCH(?)", peer)
if err != nil {
return fmt.Errorf("failed to fetch from peer %s: %w", peer, err)
}
return nil
})
}
// ListRemotes returns configured remote names and URLs.

View File

@@ -234,6 +234,20 @@ CREATE TABLE IF NOT EXISTS interactions (
INDEX idx_interactions_issue_id (issue_id),
INDEX idx_interactions_parent_id (parent_id)
);
-- Federation peers table (for SQL user authentication)
-- Stores credentials for peer-to-peer Dolt remotes between Gas Towns
CREATE TABLE IF NOT EXISTS federation_peers (
name VARCHAR(255) PRIMARY KEY,
remote_url VARCHAR(1024) NOT NULL,
username VARCHAR(255),
password_encrypted BLOB,
sovereignty VARCHAR(8) DEFAULT '',
last_sync DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_federation_peers_sovereignty (sovereignty)
);
`
// defaultConfig contains the default configuration values

View File

@@ -171,6 +171,27 @@ type FederatedStorage interface {
// SyncStatus returns the sync status with a peer.
SyncStatus(ctx context.Context, peer string) (*SyncStatus, error)
// Credential management for SQL user authentication
// AddFederationPeer adds or updates a federation peer with credentials.
AddFederationPeer(ctx context.Context, peer *FederationPeer) error
// GetFederationPeer retrieves a federation peer by name.
// Returns nil if peer doesn't exist.
GetFederationPeer(ctx context.Context, name string) (*FederationPeer, error)
// ListFederationPeers returns all configured federation peers.
ListFederationPeers(ctx context.Context) ([]*FederationPeer, error)
// RemoveFederationPeer removes a federation peer and its credentials.
RemoveFederationPeer(ctx context.Context, name string) error
// PushWithCredentials pushes to a remote using stored credentials.
PushWithCredentials(ctx context.Context, remoteName string) error
// PullWithCredentials pulls from a remote using stored credentials.
PullWithCredentials(ctx context.Context, remoteName string) ([]Conflict, error)
}
// RemoteInfo describes a configured remote.
@@ -188,6 +209,19 @@ type SyncStatus struct {
HasConflicts bool // Whether there are unresolved conflicts
}
// FederationPeer represents a remote peer with authentication credentials.
// Used for peer-to-peer Dolt remotes between Gas Towns with SQL user auth.
type FederationPeer struct {
Name string // Unique name for this peer (used as remote name)
RemoteURL string // Dolt remote URL (e.g., http://host:port/org/db)
Username string // SQL username for authentication
Password string // Password (decrypted, not stored directly)
Sovereignty string // Sovereignty tier: T1, T2, T3, T4
LastSync *time.Time // Last successful sync time
CreatedAt time.Time
UpdatedAt time.Time
}
// IsFederated checks if a storage instance supports federation.
func IsFederated(s Storage) bool {
_, ok := s.(FederatedStorage)