internal/app/wallet/filestore.go
Rama main · réplica de lectura del repositorio original.
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
}
}
}