221 lines
6.8 KiB
Go
221 lines
6.8 KiB
Go
package managedkey
|
|
|
|
import (
|
|
"context"
|
|
"encoding/hex"
|
|
"math/big"
|
|
"path"
|
|
"strings"
|
|
|
|
"github.com/ethereum/go-ethereum/core/types"
|
|
"github.com/ethereum/go-ethereum/crypto"
|
|
"github.com/tech/sendico/pkg/merrors"
|
|
"github.com/tech/sendico/pkg/mlogger"
|
|
"github.com/tech/sendico/pkg/vault/kv"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
const defaultComponent = "vault key manager"
|
|
|
|
type service struct {
|
|
logger mlogger.Logger
|
|
component string
|
|
store kv.Client
|
|
keyPrefix string
|
|
}
|
|
|
|
func newService(opts Options) (Service, error) {
|
|
logger := opts.Logger
|
|
if logger == nil {
|
|
logger = zap.NewNop()
|
|
}
|
|
|
|
component := strings.TrimSpace(opts.Component)
|
|
if component == "" {
|
|
component = defaultComponent
|
|
}
|
|
|
|
store, err := kv.New(kv.Options{
|
|
Logger: logger,
|
|
Config: kv.Config{
|
|
Address: opts.Config.Address,
|
|
TokenEnv: opts.Config.TokenEnv,
|
|
TokenFileEnv: opts.Config.TokenFileEnv,
|
|
TokenFile: opts.Config.TokenFile,
|
|
Namespace: opts.Config.Namespace,
|
|
MountPath: opts.Config.MountPath,
|
|
},
|
|
Component: component,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
keyPrefix := strings.Trim(strings.TrimSpace(opts.Config.KeyPrefix), "/")
|
|
if keyPrefix == "" {
|
|
keyPrefix = strings.Trim(strings.TrimSpace(opts.DefaultKeyPrefix), "/")
|
|
}
|
|
if keyPrefix == "" {
|
|
keyPrefix = "wallets"
|
|
}
|
|
|
|
return &service{
|
|
logger: logger.Named("vault"),
|
|
component: component,
|
|
store: store,
|
|
keyPrefix: keyPrefix,
|
|
}, nil
|
|
}
|
|
|
|
func (s *service) CreateManagedWalletKey(ctx context.Context, walletRef, network string) (*ManagedWalletKey, error) {
|
|
walletRef = strings.TrimSpace(walletRef)
|
|
network = strings.TrimSpace(network)
|
|
if walletRef == "" {
|
|
s.logger.Warn("WalletRef missing for managed key creation", zap.String("network", network))
|
|
return nil, merrors.InvalidArgument(s.component+": walletRef is required", "wallet_ref")
|
|
}
|
|
if network == "" {
|
|
s.logger.Warn("Network missing for managed key creation", zap.String("wallet_ref", walletRef))
|
|
return nil, merrors.InvalidArgument(s.component+": network is required", "network")
|
|
}
|
|
|
|
privateKey, err := crypto.GenerateKey()
|
|
if err != nil {
|
|
s.logger.Warn("Failed to generate managed wallet key", zap.String("wallet_ref", walletRef), zap.String("network", network), zap.Error(err))
|
|
return nil, merrors.Internal(s.component + ": failed to generate key: " + err.Error())
|
|
}
|
|
|
|
privateKeyBytes := crypto.FromECDSA(privateKey)
|
|
publicKey := privateKey.PublicKey
|
|
publicKeyBytes := crypto.FromECDSAPub(&publicKey)
|
|
publicKeyHex := hex.EncodeToString(publicKeyBytes)
|
|
address := strings.ToLower(crypto.PubkeyToAddress(publicKey).Hex())
|
|
keyID := s.BuildKeyID(network, walletRef)
|
|
|
|
payload := map[string]interface{}{
|
|
"private_key": hex.EncodeToString(privateKeyBytes),
|
|
"public_key": hex.EncodeToString(publicKeyBytes),
|
|
"address": address,
|
|
"network": strings.ToLower(network),
|
|
}
|
|
if err := s.store.Put(ctx, keyID, payload); err != nil {
|
|
s.logger.Warn("Failed to persist managed wallet key", zap.String("wallet_ref", walletRef), zap.String("network", network), zap.Error(err))
|
|
zeroBytes(privateKeyBytes)
|
|
zeroBytes(publicKeyBytes)
|
|
return nil, err
|
|
}
|
|
|
|
zeroBytes(privateKeyBytes)
|
|
zeroBytes(publicKeyBytes)
|
|
|
|
s.logger.Info("Managed wallet key created", zap.String("wallet_ref", walletRef), zap.String("network", network), zap.String("address", address))
|
|
|
|
return &ManagedWalletKey{
|
|
KeyID: keyID,
|
|
Address: address,
|
|
PublicKey: publicKeyHex,
|
|
}, nil
|
|
}
|
|
|
|
func (s *service) SignEVMTransaction(ctx context.Context, keyID string, tx *types.Transaction, chainID *big.Int) (*types.Transaction, error) {
|
|
if strings.TrimSpace(keyID) == "" {
|
|
s.logger.Warn("Signing failed: empty key id")
|
|
return nil, merrors.InvalidArgument(s.component+": keyID is required", "key_id")
|
|
}
|
|
if tx == nil {
|
|
s.logger.Warn("Signing failed: nil transaction", zap.String("key_id", keyID))
|
|
return nil, merrors.InvalidArgument(s.component+": transaction is nil", "transaction")
|
|
}
|
|
if chainID == nil {
|
|
s.logger.Warn("Signing failed: nil chain id", zap.String("key_id", keyID))
|
|
return nil, merrors.InvalidArgument(s.component+": chainID is nil", "chain_id")
|
|
}
|
|
|
|
material, err := s.LoadKeyMaterial(ctx, keyID)
|
|
if err != nil {
|
|
s.logger.Warn("Failed to load key material", zap.String("key_id", keyID), zap.Error(err))
|
|
return nil, err
|
|
}
|
|
|
|
keyBytes, err := hex.DecodeString(material.PrivateKey)
|
|
if err != nil {
|
|
s.logger.Warn("Invalid key material", zap.String("key_id", keyID), zap.Error(err))
|
|
return nil, merrors.Internal(s.component + ": invalid key material: " + err.Error())
|
|
}
|
|
defer zeroBytes(keyBytes)
|
|
|
|
privateKey, err := crypto.ToECDSA(keyBytes)
|
|
if err != nil {
|
|
s.logger.Warn("Failed to construct private key", zap.String("key_id", keyID), zap.Error(err))
|
|
return nil, merrors.Internal(s.component + ": failed to construct private key: " + err.Error())
|
|
}
|
|
|
|
signed, err := types.SignTx(tx, types.LatestSignerForChainID(chainID), privateKey)
|
|
if err != nil {
|
|
s.logger.Warn("Failed to sign transaction", zap.String("key_id", keyID), zap.Error(err))
|
|
return nil, merrors.Internal(s.component + ": failed to sign transaction: " + err.Error())
|
|
}
|
|
|
|
s.logger.Info("Transaction signed with managed key", zap.String("key_id", keyID), zap.String("network", material.Network), zap.String("tx_hash", signed.Hash().Hex()))
|
|
|
|
return signed, nil
|
|
}
|
|
|
|
func (s *service) LoadKeyMaterial(ctx context.Context, keyID string) (*Material, error) {
|
|
data, err := s.store.Get(ctx, keyID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
secretPath := strings.Trim(strings.TrimPrefix(strings.TrimSpace(keyID), "/"), "/")
|
|
privateKey, err := fieldAsString(data, "private_key", secretPath, s.component)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
publicKey, err := fieldAsString(data, "public_key", secretPath, s.component)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
address, err := fieldAsString(data, "address", secretPath, s.component)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
network, err := fieldAsString(data, "network", secretPath, s.component)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &Material{
|
|
PrivateKey: privateKey,
|
|
PublicKey: publicKey,
|
|
Address: address,
|
|
Network: network,
|
|
}, nil
|
|
}
|
|
|
|
func (s *service) BuildKeyID(network, walletRef string) string {
|
|
net := strings.Trim(strings.ToLower(strings.TrimSpace(network)), "/")
|
|
ref := strings.Trim(strings.TrimSpace(walletRef), "/")
|
|
return path.Join(s.keyPrefix, net, ref)
|
|
}
|
|
|
|
func fieldAsString(data map[string]interface{}, field, secretPath, component string) (string, error) {
|
|
value, ok := data[field]
|
|
if !ok {
|
|
return "", merrors.Internal(component + ": secret " + secretPath + " missing " + field)
|
|
}
|
|
str, ok := value.(string)
|
|
if !ok || strings.TrimSpace(str) == "" {
|
|
return "", merrors.Internal(component + ": secret " + secretPath + " invalid " + field)
|
|
}
|
|
return str, nil
|
|
}
|
|
|
|
func zeroBytes(data []byte) {
|
|
for i := range data {
|
|
data[i] = 0
|
|
}
|
|
}
|
|
|
|
var _ Service = (*service)(nil)
|