callbacks service draft
This commit is contained in:
34
api/pkg/vault/kv/module.go
Normal file
34
api/pkg/vault/kv/module.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package kv
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
)
|
||||
|
||||
// Config describes Vault KV v2 connection settings.
|
||||
type Config struct {
|
||||
Address string `mapstructure:"address" yaml:"address"`
|
||||
TokenEnv string `mapstructure:"token_env" yaml:"token_env"`
|
||||
Namespace string `mapstructure:"namespace" yaml:"namespace"`
|
||||
MountPath string `mapstructure:"mount_path" yaml:"mount_path"`
|
||||
}
|
||||
|
||||
// Client defines KV operations used by services.
|
||||
type Client interface {
|
||||
Put(ctx context.Context, secretPath string, payload map[string]interface{}) error
|
||||
Get(ctx context.Context, secretPath string) (map[string]interface{}, error)
|
||||
GetString(ctx context.Context, secretPath, field string) (string, error)
|
||||
}
|
||||
|
||||
// Options configure KV client creation.
|
||||
type Options struct {
|
||||
Logger mlogger.Logger
|
||||
Config Config
|
||||
Component string
|
||||
}
|
||||
|
||||
// New creates a Vault KV v2 client.
|
||||
func New(opts Options) (Client, error) {
|
||||
return newService(opts)
|
||||
}
|
||||
151
api/pkg/vault/kv/service.go
Normal file
151
api/pkg/vault/kv/service.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package kv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/hashicorp/vault/api"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const defaultComponent = "vault kv"
|
||||
|
||||
type service struct {
|
||||
logger mlogger.Logger
|
||||
component string
|
||||
store *api.KVv2
|
||||
}
|
||||
|
||||
func newService(opts Options) (Client, error) {
|
||||
logger := opts.Logger
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
|
||||
component := strings.TrimSpace(opts.Component)
|
||||
if component == "" {
|
||||
component = defaultComponent
|
||||
}
|
||||
|
||||
address := strings.TrimSpace(opts.Config.Address)
|
||||
if address == "" {
|
||||
logger.Error("Vault address missing")
|
||||
return nil, merrors.InvalidArgument(component + ": address is required")
|
||||
}
|
||||
|
||||
tokenEnv := strings.TrimSpace(opts.Config.TokenEnv)
|
||||
if tokenEnv == "" {
|
||||
logger.Error("Vault token env missing")
|
||||
return nil, merrors.InvalidArgument(component + ": token_env is required")
|
||||
}
|
||||
|
||||
token := strings.TrimSpace(os.Getenv(tokenEnv))
|
||||
if token == "" {
|
||||
logger.Error("Vault token missing; expected Vault Agent to export token", zap.String("env", tokenEnv))
|
||||
return nil, merrors.InvalidArgument(component + ": token env " + tokenEnv + " is not set (expected Vault Agent sink to populate it)")
|
||||
}
|
||||
|
||||
mountPath := strings.Trim(strings.TrimSpace(opts.Config.MountPath), "/")
|
||||
if mountPath == "" {
|
||||
logger.Error("Vault mount path missing")
|
||||
return nil, merrors.InvalidArgument(component + ": mount_path is required")
|
||||
}
|
||||
|
||||
clientCfg := api.DefaultConfig()
|
||||
clientCfg.Address = address
|
||||
|
||||
client, err := api.NewClient(clientCfg)
|
||||
if err != nil {
|
||||
logger.Error("Failed to create vault client", zap.Error(err))
|
||||
return nil, merrors.Internal(component + ": failed to create client: " + err.Error())
|
||||
}
|
||||
client.SetToken(token)
|
||||
|
||||
if ns := strings.TrimSpace(opts.Config.Namespace); ns != "" {
|
||||
client.SetNamespace(ns)
|
||||
}
|
||||
|
||||
return &service{
|
||||
logger: logger.Named("vault"),
|
||||
component: component,
|
||||
store: client.KVv2(mountPath),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *service) Put(ctx context.Context, secretPath string, payload map[string]interface{}) error {
|
||||
if payload == nil {
|
||||
return merrors.InvalidArgument(s.component+": payload is required", "payload")
|
||||
}
|
||||
|
||||
normalizedPath, err := normalizePath(secretPath)
|
||||
if err != nil {
|
||||
return merrors.InvalidArgumentWrap(err, s.component+": secret path is invalid", "secret_path")
|
||||
}
|
||||
|
||||
if _, err := s.store.Put(ctx, normalizedPath, payload); err != nil {
|
||||
s.logger.Warn("Failed to write secret", zap.String("path", normalizedPath), zap.Error(err))
|
||||
return merrors.Internal(s.component + ": failed to write secret at " + normalizedPath + ": " + err.Error())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *service) Get(ctx context.Context, secretPath string) (map[string]interface{}, error) {
|
||||
normalizedPath, err := normalizePath(secretPath)
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidArgumentWrap(err, s.component+": secret path is invalid", "secret_path")
|
||||
}
|
||||
|
||||
secret, err := s.store.Get(ctx, normalizedPath)
|
||||
if err != nil {
|
||||
s.logger.Warn("Failed to read secret", zap.String("path", normalizedPath), zap.Error(err))
|
||||
return nil, merrors.Internal(s.component + ": failed to read secret at " + normalizedPath + ": " + err.Error())
|
||||
}
|
||||
if secret == nil || secret.Data == nil {
|
||||
return nil, merrors.NoData(s.component + ": secret " + normalizedPath + " not found")
|
||||
}
|
||||
|
||||
data := make(map[string]interface{}, len(secret.Data))
|
||||
for k, v := range secret.Data {
|
||||
data[k] = v
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (s *service) GetString(ctx context.Context, secretPath, field string) (string, error) {
|
||||
field = strings.TrimSpace(field)
|
||||
if field == "" {
|
||||
return "", merrors.InvalidArgument(s.component+": field is required", "field")
|
||||
}
|
||||
|
||||
data, err := s.Get(ctx, secretPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
val, ok := data[field]
|
||||
if !ok {
|
||||
return "", merrors.Internal(s.component + ": secret " + strings.Trim(strings.TrimPrefix(secretPath, "/"), "/") + " missing " + field)
|
||||
}
|
||||
|
||||
str, ok := val.(string)
|
||||
if !ok || strings.TrimSpace(str) == "" {
|
||||
return "", merrors.Internal(s.component + ": secret " + strings.Trim(strings.TrimPrefix(secretPath, "/"), "/") + " invalid " + field)
|
||||
}
|
||||
|
||||
return str, nil
|
||||
}
|
||||
|
||||
func normalizePath(secretPath string) (string, error) {
|
||||
normalizedPath := strings.Trim(strings.TrimPrefix(strings.TrimSpace(secretPath), "/"), "/")
|
||||
if normalizedPath == "" {
|
||||
return "", merrors.InvalidArgument("secret path is required", "secret_path")
|
||||
}
|
||||
return normalizedPath, nil
|
||||
}
|
||||
|
||||
var _ Client = (*service)(nil)
|
||||
54
api/pkg/vault/managedkey/module.go
Normal file
54
api/pkg/vault/managedkey/module.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package managedkey
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/big"
|
||||
|
||||
"github.com/ethereum/go-ethereum/core/types"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
)
|
||||
|
||||
// Config describes how to connect to Vault for managed wallet keys.
|
||||
type Config struct {
|
||||
Address string `mapstructure:"address" yaml:"address"`
|
||||
TokenEnv string `mapstructure:"token_env" yaml:"token_env"`
|
||||
Namespace string `mapstructure:"namespace" yaml:"namespace"`
|
||||
MountPath string `mapstructure:"mount_path" yaml:"mount_path"`
|
||||
KeyPrefix string `mapstructure:"key_prefix" yaml:"key_prefix"`
|
||||
}
|
||||
|
||||
// ManagedWalletKey captures metadata returned after key provisioning.
|
||||
type ManagedWalletKey struct {
|
||||
KeyID string
|
||||
Address string
|
||||
PublicKey string
|
||||
}
|
||||
|
||||
// Material contains key material loaded from Vault.
|
||||
type Material struct {
|
||||
PrivateKey string
|
||||
PublicKey string
|
||||
Address string
|
||||
Network string
|
||||
}
|
||||
|
||||
// Service defines managed key operations shared by gateways.
|
||||
type Service interface {
|
||||
CreateManagedWalletKey(ctx context.Context, walletRef, network string) (*ManagedWalletKey, error)
|
||||
SignEVMTransaction(ctx context.Context, keyID string, tx *types.Transaction, chainID *big.Int) (*types.Transaction, error)
|
||||
LoadKeyMaterial(ctx context.Context, keyID string) (*Material, error)
|
||||
BuildKeyID(network, walletRef string) string
|
||||
}
|
||||
|
||||
// Options configure managed key service creation.
|
||||
type Options struct {
|
||||
Logger mlogger.Logger
|
||||
Config Config
|
||||
Component string
|
||||
DefaultKeyPrefix string
|
||||
}
|
||||
|
||||
// New creates a managed wallet key service backed by Vault KV.
|
||||
func New(opts Options) (Service, error) {
|
||||
return newService(opts)
|
||||
}
|
||||
218
api/pkg/vault/managedkey/service.go
Normal file
218
api/pkg/vault/managedkey/service.go
Normal file
@@ -0,0 +1,218 @@
|
||||
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,
|
||||
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)
|
||||
Reference in New Issue
Block a user