diff --git a/cmd/ffsigner.go b/cmd/ffsigner.go index c5a4b13..3fc0dc3 100644 --- a/cmd/ffsigner.go +++ b/cmd/ffsigner.go @@ -30,6 +30,7 @@ import ( "github.com/hyperledger-firefly/signer/internal/signerconfig" "github.com/hyperledger-firefly/signer/internal/signermsgs" "github.com/hyperledger-firefly/signer/pkg/fswallet" + "github.com/hyperledger-firefly/signer/pkg/kmswallet" "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) @@ -89,6 +90,18 @@ func run() error { cancelCtx() }() + if config.GetBool(signerconfig.KMSWalletEnabled) { + kmsWallet, err := kmswallet.NewKMSWallet(ctx, kmswallet.ReadConfig(signerconfig.KMSWalletConfig)) + if err != nil { + return err + } + server, err := rpcserver.NewServer(ctx, kmsWallet) + if err != nil { + return err + } + return runServer(server) + } + if !config.GetBool(signerconfig.FileWalletEnabled) { return i18n.NewError(ctx, signermsgs.MsgNoWalletEnabled) } diff --git a/go.mod b/go.mod index 0b55f98..f2bf935 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,9 @@ go 1.26.0 toolchain go1.26.4 require ( + github.com/aws/aws-sdk-go-v2 v1.43.6 + github.com/aws/aws-sdk-go-v2/config v1.32.37 + github.com/aws/aws-sdk-go-v2/service/kms v1.55.6 github.com/btcsuite/btcd/btcec/v2 v2.5.0 github.com/fsnotify/fsnotify v1.10.1 github.com/go-resty/resty/v2 v2.17.2 @@ -24,6 +27,18 @@ require ( require ( github.com/aidarkhanov/nanoid v1.0.8 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.36 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.37 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.37 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.37 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.38 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.37 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.5.6 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.33.6 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.6 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.45.6 // indirect + github.com/aws/smithy-go v1.27.8 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect diff --git a/go.sum b/go.sum index 0244283..e972cfd 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,35 @@ github.com/aidarkhanov/nanoid v1.0.8 h1:yxyJkgsEDFXP7+97vc6JevMcjyb03Zw+/9fqhlVXBXA= github.com/aidarkhanov/nanoid v1.0.8/go.mod h1:vadfZHT+m4uDhttg0yY4wW3GKtl2T6i4d2Age+45pYk= +github.com/aws/aws-sdk-go-v2 v1.43.6 h1:RrmFcqCBxkJuf7g1axVo5krB4jM/AO8r5e5oujrgdoQ= +github.com/aws/aws-sdk-go-v2 v1.43.6/go.mod h1:tXpPM+v0D1lndmga+HqqLDIzUFJlEeR21aspVklHF00= +github.com/aws/aws-sdk-go-v2/config v1.32.37 h1:Ljl7LOJB6ym0liuEl0+TZ3d7f5I8MEZN1Cj9PINlj/g= +github.com/aws/aws-sdk-go-v2/config v1.32.37/go.mod h1:WJ7pe7ZPpmG8Q5kKS53zeypIV4FBGACxmte8Uc6SgUc= +github.com/aws/aws-sdk-go-v2/credentials v1.19.36 h1:84s5xMme6ENYEdKG8rsbSFFg/8+lbHBeM9QYSO0gnDk= +github.com/aws/aws-sdk-go-v2/credentials v1.19.36/go.mod h1:c46BLdagDLIswjgt+GeQOslXgeS0E6wCacs5yZbxPGk= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.37 h1:b5tb+CZItBkydC7r3hTNdSO3pszG1R2EtnA+7TePQPk= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.37/go.mod h1:ZQ+6SU9X0oz6+7MUCSswv9Mjci4eaqZr21HI2RVy/yA= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.37 h1:lznzIOvvbqjfe8UAaciCRJgBgJsxuTROKlhZuXQWfv8= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.37/go.mod h1:otfkzyfQeMMLZAqX59GSXTL3o22BR/l6HFaRzzbWSqA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.37 h1:zCEORWo0eU0gDjG+IyApE/2B+ZGG1m+GU7B263XV8ds= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.37/go.mod h1:i6c0PEl3TNOWxRbQ++KQcVenPWS/GoQeiklKhNuqzJ8= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.38 h1:A3UAuCmx7LyUcrixBTzKJYYIUZ2yTvn6ZhT8PB+7APk= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.38/go.mod h1:1PDUYG9Z+JrbbsobsAZHjWOm9QBT/djiK3QbykTL5Z4= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17 h1:OvYZOB3qA6zvfdRFiRFRzVSiElMYrz3GdntkXZxlp1o= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17/go.mod h1:JgR/2Ew50ACfIWau1oeMRX59tMtC0kM+PYQGEaT04cY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.37 h1:a3D4AjrOrTrP8+d9ILBthqrElf0z1JNol09Xvnwcys8= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.37/go.mod h1:ky0gTu+ukvUTuUKFIpp6Wid4oninrkCyvbFkVs0kpHM= +github.com/aws/aws-sdk-go-v2/service/kms v1.55.6 h1:t7MKfMvQw90vIGnYvAP5gAq8V2eB5C6UQsRStkteVG8= +github.com/aws/aws-sdk-go-v2/service/kms v1.55.6/go.mod h1:+PBOEnL6FIG3JJlZw7wSAWM70z9f6QwwiwlbKlgWHXQ= +github.com/aws/aws-sdk-go-v2/service/signin v1.5.6 h1:i68sFvXidKlkiSvI7d7Ilc1/UvW4CtBOaivH7jhG4fs= +github.com/aws/aws-sdk-go-v2/service/signin v1.5.6/go.mod h1:/h7Obr9WTtzbjTHGASRQwLN7Bupw+TC3x8x7fyx39hE= +github.com/aws/aws-sdk-go-v2/service/sso v1.33.6 h1:tpfGChmjUmv3W9WlRvy+stwKDTbFFdq8Zk9DbFPrfMU= +github.com/aws/aws-sdk-go-v2/service/sso v1.33.6/go.mod h1:CSjiDzmG/lsKkTOYjbkM+duLmRlW+LOxD64Na44ijnI= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.6 h1:49BBtY68A+KJCQ3a2F3eUe6ROsKucxUdfHKoqorc0wI= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.6/go.mod h1:ptG2hbs7QltE1GcQY0MpS4bfrc51KCnBXUr7OT1EEfE= +github.com/aws/aws-sdk-go-v2/service/sts v1.45.6 h1:JvExZWabChDM0qJAirQYGfOYo0ndT3edXj+fqSPNjkE= +github.com/aws/aws-sdk-go-v2/service/sts v1.45.6/go.mod h1:XZcaQkV2cItp6yEkrwljyaPOf22RuX7T43jxap/FOmM= +github.com/aws/smithy-go v1.27.8 h1:FR0dxZfIlV7Z8eh2iHfIofdunw382XsDV3Mxt9nUvRY= +github.com/aws/smithy-go v1.27.8/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/btcsuite/btcd/btcec/v2 v2.5.0 h1:KioMXOWa76b86sTZZOmbzv/ldaQCmB8KFAyn5PbB8E8= diff --git a/internal/signerconfig/signerconfig.go b/internal/signerconfig/signerconfig.go index 878de6a..4726d9f 100644 --- a/internal/signerconfig/signerconfig.go +++ b/internal/signerconfig/signerconfig.go @@ -21,6 +21,7 @@ import ( "github.com/hyperledger-firefly/common/pkg/httpserver" "github.com/hyperledger-firefly/common/pkg/wsclient" "github.com/hyperledger-firefly/signer/pkg/fswallet" + "github.com/hyperledger-firefly/signer/pkg/kmswallet" "github.com/spf13/viper" ) @@ -31,6 +32,8 @@ var ( BackendChainID = ffc("backend.chainId") // FileWalletEnabled if the Keystore V3 wallet is enabled FileWalletEnabled = ffc("fileWallet.enabled") + // KMSWalletEnabled if the AWS KMS wallet is enabled + KMSWalletEnabled = ffc("kmsWallet.enabled") ) var ServerConfig config.Section @@ -41,9 +44,12 @@ var BackendConfig config.Section var FileWalletConfig config.Section +var KMSWalletConfig config.Section + func setDefaults() { viper.SetDefault(string(BackendChainID), -1) viper.SetDefault(string(FileWalletEnabled), true) + viper.SetDefault(string(KMSWalletEnabled), false) } func Reset() { @@ -61,4 +67,6 @@ func Reset() { FileWalletConfig = config.RootSection("fileWallet") fswallet.InitConfig(FileWalletConfig) + KMSWalletConfig = config.RootSection("kmsWallet") + kmswallet.InitConfig(KMSWalletConfig) } diff --git a/pkg/kmswallet/kmssigner.go b/pkg/kmswallet/kmssigner.go new file mode 100644 index 0000000..66983a7 --- /dev/null +++ b/pkg/kmswallet/kmssigner.go @@ -0,0 +1,41 @@ +// Copyright © 2026 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package kmswallet + +import ( + "context" + + "github.com/hyperledger-firefly/signer/pkg/secp256k1" + "golang.org/x/crypto/sha3" +) + +type kmsSigner struct { + wallet *KMSWallet +} + +// Sign implements secp256k1.Signer — hashes the message with Keccak-256 then signs via KMS. +func (k *kmsSigner) Sign(message []byte) (*secp256k1.SignatureData, error) { + msgHash := sha3.NewLegacyKeccak256() + msgHash.Write(message) + hashed := msgHash.Sum(nil) + return k.SignDirect(hashed) +} + +// SignDirect implements secp256k1.SignerDirect — signs a pre-hashed (32-byte) message via KMS. +func (k *kmsSigner) SignDirect(message []byte) (*secp256k1.SignatureData, error) { + return k.wallet.signDirect(context.Background(), message) +} diff --git a/pkg/kmswallet/kmswallet.go b/pkg/kmswallet/kmswallet.go new file mode 100644 index 0000000..283b1b9 --- /dev/null +++ b/pkg/kmswallet/kmswallet.go @@ -0,0 +1,265 @@ +// Copyright © 2026 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package kmswallet + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + _ "crypto/sha256" + "encoding/asn1" + "fmt" + "math/big" + "sync" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/kms" + "github.com/hyperledger-firefly/common/pkg/config" + "github.com/hyperledger-firefly/common/pkg/i18n" + "github.com/hyperledger-firefly/common/pkg/log" + "github.com/hyperledger-firefly/signer/internal/signermsgs" + "github.com/hyperledger-firefly/signer/pkg/eip712" + "github.com/hyperledger-firefly/signer/pkg/ethsigner" + "github.com/hyperledger-firefly/signer/pkg/ethtypes" + "github.com/hyperledger-firefly/signer/pkg/secp256k1" + "golang.org/x/crypto/sha3" + + btcec "github.com/btcsuite/btcd/btcec/v2" + "github.com/aws/aws-sdk-go-v2/service/kms/types" +) + +const ( + ConfigEnabled = "enabled" + ConfigKeyID = "keyId" + ConfigRegion = "region" + ConfigEndpoint = "endpoint" +) + +func InitConfig(c config.Section) { + c.AddKnownKey(ConfigEnabled, false) + c.AddKnownKey(ConfigKeyID, "") + c.AddKnownKey(ConfigRegion, "") + c.AddKnownKey(ConfigEndpoint, "") +} + +func ReadConfig(c config.Section) *Config { + return &Config{ + Enabled: c.GetBool(ConfigEnabled), + KeyID: c.GetString(ConfigKeyID), + Region: c.GetString(ConfigRegion), + Endpoint: c.GetString(ConfigEndpoint), + } +} + +type Config struct { + Enabled bool + KeyID string + Region string + Endpoint string +} + +type ConfigGeneric = Config + +func NewKMSWallet(ctx context.Context, conf *Config) (*KMSWallet, error) { + if conf.KeyID == "" { + return nil, i18n.NewError(ctx, signermsgs.MsgNoWalletEnabled) + } + + opts := []func(*awsconfig.LoadOptions) error{} + if conf.Region != "" { + opts = append(opts, awsconfig.WithRegion(conf.Region)) + } + cfg, err := awsconfig.LoadDefaultConfig(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("loading AWS config: %w", err) + } + + kmsOpts := []func(*kms.Options){} + if conf.Endpoint != "" { + kmsOpts = append(kmsOpts, func(o *kms.Options) { + o.BaseEndpoint = aws.String(conf.Endpoint) + }) + } + client := kms.NewFromConfig(cfg, kmsOpts...) + + return &KMSWallet{ + conf: *conf, + client: client, + }, nil +} + +type KMSWallet struct { + conf Config + client *kms.Client + + mu sync.RWMutex + address ethtypes.Address0xHex + pubKeyBytes []byte + initialized bool +} + +func (w *KMSWallet) Initialize(ctx context.Context) error { + w.mu.Lock() + defer w.mu.Unlock() + + if w.initialized { + return nil + } + + out, err := w.client.GetPublicKey(ctx, &kms.GetPublicKeyInput{ + KeyId: aws.String(w.conf.KeyID), + }) + if err != nil { + return fmt.Errorf("kms GetPublicKey: %w", err) + } + if len(out.PublicKey) == 0 { + return fmt.Errorf("kms returned empty public key") + } + + pubKeyBytes := make([]byte, len(out.PublicKey)) + copy(pubKeyBytes, out.PublicKey) + + pubKey, err := unmarshalSecp256k1PublicKey(pubKeyBytes) + if err != nil { + return fmt.Errorf("parsing KMS public key: %w", err) + } + + addr := publicKeyToAddress(pubKey) + + w.pubKeyBytes = pubKeyBytes + w.address = *addr + w.initialized = true + + log.L(ctx).Infof("KMS wallet initialized: keyId=%s address=%s", w.conf.KeyID, addr) + return nil +} + +func (w *KMSWallet) Refresh(ctx context.Context) error { + w.mu.Lock() + w.initialized = false + w.mu.Unlock() + return w.Initialize(ctx) +} + +func (w *KMSWallet) Close() error { return nil } + +func (w *KMSWallet) GetAccounts(_ context.Context) ([]*ethtypes.Address0xHex, error) { + if !w.initialized { + return nil, fmt.Errorf("wallet not initialized") + } + w.mu.RLock() + defer w.mu.RUnlock() + return []*ethtypes.Address0xHex{&w.address}, nil +} + +func (w *KMSWallet) Sign(ctx context.Context, txn *ethsigner.Transaction, chainID int64) ([]byte, error) { + signer := &kmsSigner{wallet: w} + return txn.Sign(signer, chainID) +} + +func (w *KMSWallet) SignTypedDataV4(ctx context.Context, _ ethtypes.Address0xHex, payload *eip712.TypedData) (*ethsigner.EIP712Result, error) { + signer := &kmsSigner{wallet: w} + return ethsigner.SignTypedDataV4(ctx, signer, payload) +} + +func (w *KMSWallet) signDirect(ctx context.Context, message []byte) (*secp256k1.SignatureData, error) { + if len(message) != 32 { + return nil, fmt.Errorf("KMS signDirect expects a 32-byte pre-hashed message, got %d bytes", len(message)) + } + + out, err := w.client.Sign(ctx, &kms.SignInput{ + KeyId: aws.String(w.conf.KeyID), + Message: message, + MessageType: types.MessageTypeDigest, + SigningAlgorithm: types.SigningAlgorithmSpecEcdsaSha256, + }) + if err != nil { + return nil, fmt.Errorf("kms Sign: %w", err) + } + + r, s, err := parseDERSignature(out.Signature) + if err != nil { + return nil, fmt.Errorf("parsing DER signature from KMS: %w", err) + } + + v, err := w.recoverYParity(message, r, s) + if err != nil { + return nil, fmt.Errorf("recovering y-parity: %w", err) + } + + return &secp256k1.SignatureData{ + V: v, + R: r, + S: s, + }, nil +} + +func (w *KMSWallet) recoverYParity(hashedMessage []byte, r, s *big.Int) (*big.Int, error) { + w.mu.RLock() + defer w.mu.RUnlock() + + for _, parity := range []int64{27, 28} { + sig := &secp256k1.SignatureData{ + V: big.NewInt(parity), + R: r, + S: s, + } + recovered, err := sig.RecoverDirect(hashedMessage, 0) + if err != nil { + continue + } + if recovered.String() == w.address.String() { + return big.NewInt(parity), nil + } + } + return nil, fmt.Errorf("could not recover y-parity — signature does not match the KMS public key") +} + +func parseDERSignature(der []byte) (r, s *big.Int, err error) { + var sig struct { + R *asn1.RawValue + S *asn1.RawValue + } + if _, err := asn1.Unmarshal(der, &sig); err != nil { + return nil, nil, fmt.Errorf("asn1 unmarshal: %w", err) + } + r = new(big.Int).SetBytes(sig.R.Bytes) + s = new(big.Int).SetBytes(sig.S.Bytes) + return r, s, nil +} + +func unmarshalSecp256k1PublicKey(raw []byte) (*ecdsa.PublicKey, error) { + if len(raw) == 0 { + return nil, fmt.Errorf("empty public key") + } + curve := btcec.S256() + x, y := elliptic.Unmarshal(curve, raw) + if x == nil { + return nil, fmt.Errorf("failed to unmarshal public key (len=%d, prefix=0x%02x)", len(raw), raw[0]) + } + return &ecdsa.PublicKey{Curve: curve, X: x, Y: y}, nil +} + +func publicKeyToAddress(pubKey *ecdsa.PublicKey) *ethtypes.Address0xHex { + pubBytes := elliptic.Marshal(pubKey.Curve, pubKey.X, pubKey.Y) + hash := sha3.NewLegacyKeccak256() + hash.Write(pubBytes[1:]) + a := new(ethtypes.Address0xHex) + copy(a[:], hash.Sum(nil)[12:32]) + return a +}