Ctrl K
ComunidadGuías…

internal/app/wallet/filestore.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package wallet

import (
	"bytes"
	"context"
	"crypto/ed25519"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/gofrs/flock"
	"telesrv/internal/domain"
)

var ErrWalletStore = errors.New("wallet state storage unavailable")

// FileStore is single-writer, durable wallet storage independent of PostgreSQL.
// A process lock prevents two server instances from racing over the same files.
// Per-user state changes use fsync + atomic rename, including challenge use and
// wallet replacement in the same record. Keys telesrv generated itself are
// stored only sealed under the wallet master key; imported wallets never have
// private key material here.
type FileStore struct {
	mu     sync.Mutex
	dir    string
	lock   *flock.Flock
	closed bool
}

type storedChallenge struct {
	Challenge   domain.WalletChallenge `json:"challenge"`
	Consumed    bool                   `json:"consumed"`
	Operation   string                 `json:"operation,omitempty"`
	RequestHash []byte                 `json:"request_hash,omitempty"`
}

type walletRecord struct {
	Version    int                        `json:"version"`
	Link       *domain.WalletLink         `json:"link,omitempty"`
	Key        *StoredWalletKey           `json:"key,omitempty"`
	Challenges map[string]storedChallenge `json:"challenges"`
}

// StoredWalletKey is telesrv's custody of a wallet it generated itself. Only
// the sealed private key and its nonce are persisted; the plaintext key exists
// in memory for the lifetime of the request and is never logged.
type StoredWalletKey struct {
	Ciphertext string    `json:"ciphertext"`
	Nonce      string    `json:"nonce"`
	CreatedAt  time.Time `json:"created_at"`
	Origin     string    `json:"origin"`
}

func NewFileStore(dir string) (*FileStore, error) {
	if dir == "" {
		return nil, ErrWalletStore
	}
	if err := os.MkdirAll(dir, 0700); err != nil {
		return nil, err
	}
	info, err := os.Stat(dir)
	if err != nil {
		return nil, err
	}
	if !info.IsDir() || info.Mode().Perm()&0077 != 0 {
		return nil, fmt.Errorf("wallet state directory must have mode 0700")
	}
	lock := flock.New(filepath.Join(dir, ".lock"), flock.SetPermissions(0600))
	ok, err := lock.TryLock()
	if err != nil {
		return nil, err
	}
	if !ok {
		return nil, errors.New("wallet state is locked by another server")
	}
	return &FileStore{dir: dir, lock: lock}, nil
}

func (s *FileStore) Close() error {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.closed {
		return nil
	}
	s.closed = true
	return s.lock.Close()
}

func (s *FileStore) read(userID int64) (walletRecord, error) {
	empty := walletRecord{Version: 1, Challenges: map[string]storedChallenge{}}
	if s.closed || userID <= 0 {
		return empty, ErrWalletStore
	}
	f, err := os.Open(filepath.Join(s.dir, strconv.FormatInt(userID, 10)+".json"))
	if errors.Is(err, os.ErrNotExist) {
		return empty, nil
	}
	if err != nil {
		return empty, err
	}
	defer f.Close()
	data, err := io.ReadAll(io.LimitReader(f, 65537))
	if err != nil || len(data) > 65536 {
		return empty, ErrWalletStore
	}
	var record walletRecord
	if json.Unmarshal(data, &record) != nil || record.Version != 1 || len(record.Challenges) > 32 {
		return empty, ErrWalletStore
	}
	if record.Link != nil && (record.Link.UserID != userID || len(record.Link.PublicKey) != 32 || record.Link.Address == "") {
		return empty, ErrWalletStore
	}
	for key, c := range record.Challenges {
		if c.Challenge.UserID != userID || key != hex.EncodeToString(c.Challenge.AuthKeyID[:]) {
			return empty, ErrWalletStore
		}
	}
	if record.Challenges == nil {
		record.Challenges = map[string]storedChallenge{}
	}
	return record, nil
}

func (s *FileStore) write(userID int64, record walletRecord) error {
	data, err := json.Marshal(record)
	if err != nil {
		return err
	}
	if len(data) > 65536 {
		return ErrWalletStore
	}
	f, err := os.CreateTemp(s.dir, ".wallet-tmp-")
	if err != nil {
		return err
	}
	name := f.Name()
	defer os.Remove(name)
	if _, err = f.Write(data); err != nil {
		f.Close()
		return err
	}
	if err = f.Sync(); err != nil {
		f.Close()
		return err
	}
	if err = f.Close(); err != nil {
		return err
	}
	if err = os.Rename(name, filepath.Join(s.dir, strconv.FormatInt(userID, 10)+".json")); err != nil {
		return err
	}
	dir, err := os.Open(s.dir)
	if err != nil {
		return err
	}
	defer dir.Close()
	return dir.Sync()
}

func (s *FileStore) PutWalletChallenge(ctx context.Context, c domain.WalletChallenge) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	if err := ctx.Err(); err != nil {
		return err
	}
	record, err := s.read(c.UserID)
	if err != nil {
		return err
	}
	for key, old := range record.Challenges {
		if !c.IssuedAt.Before(old.Challenge.ExpiresAt) {
			delete(record.Challenges, key)
		}
	}
	key := hex.EncodeToString(c.AuthKeyID[:])
	if _, exists := record.Challenges[key]; !exists && len(record.Challenges) >= 32 {
		return ErrWalletStore
	}
	record.Challenges[key] = storedChallenge{Challenge: c}
	return s.write(c.UserID, record)
}

func (s *FileStore) WalletChallenge(ctx context.Context, userID int64, key [8]byte) (domain.WalletChallenge, bool, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if err := ctx.Err(); err != nil {
		return domain.WalletChallenge{}, false, err
	}
	record, err := s.read(userID)
	if err != nil {
		return domain.WalletChallenge{}, false, err
	}
	c, ok := record.Challenges[hex.EncodeToString(key[:])]
	return c.Challenge, ok, nil
}

func (s *FileStore) CommitWalletLink(ctx context.Context, c domain.WalletChallenge, link domain.WalletLink, hash [32]byte, allowReplacement bool, now time.Time) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	if err := ctx.Err(); err != nil {
		return err
	}
	if c.UserID != link.UserID || len(link.PublicKey) != 32 || link.Address == "" {
		return domain.ErrWalletProofInvalid
	}
	record, err := s.read(c.UserID)
	if err != nil {
		return err
	}
	key := hex.EncodeToString(c.AuthKeyID[:])
	stored, found := record.Challenges[key]
	if !found || stored.Challenge.Payload != c.Payload || stored.Challenge.Domain != c.Domain || !now.Before(stored.Challenge.ExpiresAt) {
		return domain.ErrWalletProofInvalid
	}
	if stored.Consumed {
		if stored.Operation != "import" || !bytes.Equal(stored.RequestHash, hash[:]) || record.Link == nil || !bytes.Equal(record.Link.PublicKey, link.PublicKey) {
			return domain.ErrWalletProofInvalid
		}
		return nil
	}
	if record.Link != nil && !allowReplacement && !bytes.Equal(record.Link.PublicKey, link.PublicKey) {
		return domain.ErrWalletPasswordRequired
	}
	stored.Consumed = true
	stored.RequestHash = bytes.Clone(hash[:])
	stored.Operation = "import"
	record.Challenges[key] = stored
	link.PublicKey = bytes.Clone(link.PublicKey)
	record.Link = &link
	record.Key = nil
	return s.write(c.UserID, record)
}

// CommitWalletCreated persists a telesrv-generated wallet together with its
// sealed private key.
//
// Creation has no client-side secret to prove, so the ownership challenge only
// binds the operation to this authorization and makes a retry safe: when the
// challenge was already consumed by a creation, the stored link is returned and
// reused=true, and the freshly generated key material is discarded unwritten.
// The balance is deliberately NOT persisted here; a failing balance lookup must
// never leave a half-applied binding behind.
func (s *FileStore) CommitWalletCreated(
	ctx context.Context,
	c domain.WalletChallenge,
	link domain.WalletLink,
	key *StoredWalletKey,
	allowReplacement bool,
	now time.Time,
) (domain.WalletLink, bool, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if err := ctx.Err(); err != nil {
		return domain.WalletLink{}, false, err
	}
	if c.UserID != link.UserID || len(link.PublicKey) != 32 || link.Address == "" || key == nil || key.Ciphertext == "" || key.Nonce == "" {
		return domain.WalletLink{}, false, domain.ErrWalletProofInvalid
	}
	record, err := s.read(c.UserID)
	if err != nil {
		return domain.WalletLink{}, false, err
	}
	key8 := hex.EncodeToString(c.AuthKeyID[:])
	stored, found := record.Challenges[key8]
	if !found || stored.Challenge.Payload != c.Payload || stored.Challenge.Domain != c.Domain || !now.Before(stored.Challenge.ExpiresAt) {
		return domain.WalletLink{}, false, domain.ErrWalletProofInvalid
	}
	if stored.Consumed {
		// Retry of a committed creation: answer with the wallet that won, and
		// never with the discarded candidate.
		if stored.Operation != "create" || record.Link == nil || record.Key == nil || !bytes.Equal(stored.RequestHash, record.Link.PublicKey) {
			return domain.WalletLink{}, false, domain.ErrWalletProofInvalid
		}
		return *record.Link, true, nil
	}
	if record.Link != nil && !allowReplacement && !bytes.Equal(record.Link.PublicKey, link.PublicKey) {
		return domain.WalletLink{}, false, domain.ErrWalletPasswordRequired
	}
	stored.Consumed = true
	stored.RequestHash = bytes.Clone(link.PublicKey)
	stored.Operation = "create"
	record.Challenges[key8] = stored
	link.PublicKey = bytes.Clone(link.PublicKey)
	record.Link = &link
	record.Key = key
	return link, false, s.write(c.UserID, record)
}

func (s *FileStore) WalletLinks(ctx context.Context, ids []int64) ([]domain.WalletLink, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if err := ctx.Err(); err != nil {
		return nil, err
	}
	result := make([]domain.WalletLink, 0, len(ids))
	seen := make(map[int64]bool)
	for _, id := range ids {
		if seen[id] {
			continue
		}
		seen[id] = true
		r, err := s.read(id)
		if err != nil {
			return nil, err
		}
		if r.Link != nil {
			result = append(result, *r.Link)
		}
	}
	return result, nil
}

// ValidateKeyCipher fails startup if persisted custody keys cannot be opened by
// the configured master key. It also binds each secret to its stored public key.
func (s *FileStore) ValidateKeyCipher(keys *KeyCipher) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	dir, err := os.Open(s.dir)
	if err != nil {
		return err
	}
	defer dir.Close()
	for {
		entries, err := dir.ReadDir(128)
		if err != nil && err != io.EOF {
			return err
		}
		for _, entry := range entries {
			name := entry.Name()
			if !strings.HasSuffix(name, ".json") {
				continue
			}
			id, parseErr := strconv.ParseInt(strings.TrimSuffix(name, ".json"), 10, 64)
			if parseErr != nil || entry.IsDir() {
				return ErrWalletStore
			}
			record, readErr := s.read(id)
			if readErr != nil {
				return readErr
			}
			if record.Key == nil {
				continue
			}
			if record.Link == nil {
				return ErrWalletStore
			}
			ciphertext, e := base64.RawStdEncoding.DecodeString(record.Key.Ciphertext)
			if e != nil {
				return ErrWalletStore
			}
			nonce, e := base64.RawStdEncoding.DecodeString(record.Key.Nonce)
			if e != nil {
				return ErrWalletStore
			}
			private, e := keys.Open(ciphertext, nonce)
			if e != nil {
				return ErrWalletKeyFile
			}
			valid := len(private) == ed25519.PrivateKeySize && bytes.Equal(ed25519.PrivateKey(private).Public().(ed25519.PublicKey), record.Link.PublicKey)
			clear(private)
			if !valid {
				return ErrWalletKeyFile
			}
		}
		if err == io.EOF {
			return nil
		}
	}
}