Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion common/version/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import (
"runtime/debug"
)

var tag = "v4.7.13"
var tag = "v4.7.14"

var commit = func() string {
if info, ok := debug.ReadBuildInfo(); ok {
Expand Down
1 change: 1 addition & 0 deletions rollup/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ require (
github.com/aws/aws-sdk-go-v2 v1.36.3
github.com/aws/aws-sdk-go-v2/config v1.29.14
github.com/aws/aws-sdk-go-v2/credentials v1.17.67
github.com/aws/aws-sdk-go-v2/service/kms v1.38.3
github.com/aws/aws-sdk-go-v2/service/s3 v1.80.0
github.com/consensys/gnark-crypto v0.16.0
github.com/crate-crypto/go-kzg-4844 v1.1.0
Expand Down
2 changes: 2 additions & 0 deletions rollup/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 h1:moLQUoVq91LiqT1nbvzDukyqAlCv89ZmwaHw/ZFlFZg=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15/go.mod h1:ZH34PJUc8ApjBIfgQCFvkWcUDBtl/WTD+uiYHjd8igA=
github.com/aws/aws-sdk-go-v2/service/kms v1.38.3 h1:RivOtUH3eEu6SWnUMFHKAW4MqDOzWn1vGQ3S38Y5QMg=
github.com/aws/aws-sdk-go-v2/service/kms v1.38.3/go.mod h1:cQn6tAF77Di6m4huxovNM7NVAozWTZLsDRp9t8Z/WYk=
github.com/aws/aws-sdk-go-v2/service/s3 v1.80.0 h1:fV4XIU5sn/x8gjRouoJpDVHj+ExJaUk4prYF+eb6qTs=
github.com/aws/aws-sdk-go-v2/service/s3 v1.80.0/go.mod h1:qbn305Je/IofWBJ4bJz/Q7pDEtnnoInw/dGt71v6rHE=
github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 h1:1Gw+9ajCV1jogloEv1RRnvfRFia2cL6c9cuKV2Ps+G8=
Expand Down
17 changes: 16 additions & 1 deletion rollup/internal/config/relayer.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,10 @@ type GasOracleConfig struct {

// SignerConfig - config of signer, contains type and config corresponding to type
type SignerConfig struct {
SignerType string `json:"signer_type"` // type of signer can be PrivateKey or RemoteSigner
SignerType string `json:"signer_type"` // type of signer can be PrivateKey, RemoteSigner or AWSKMS
PrivateKeySignerConfig *PrivateKeySignerConfig `json:"private_key_signer_config"`
RemoteSignerConfig *RemoteSignerConfig `json:"remote_signer_config"`
AWSKMSSignerConfig *AWSKMSSignerConfig `json:"aws_kms_signer_config"`
}

// PrivateKeySignerConfig - config of private signer, contains private key
Expand All @@ -138,3 +139,17 @@ type RemoteSignerConfig struct {
RemoteSignerUrl string `json:"remote_signer_url"` // remote signer url (web3signer) in case of RemoteSigner signerType
SignerAddress string `json:"signer_address"` // address of signer
}

// AWSKMSSignerConfig - config of an AWS KMS backed signer. The private key never
// leaves KMS; the service only requests signatures over transaction hashes.
type AWSKMSSignerConfig struct {
// KeyID is the KMS key id, alias or ARN of an asymmetric ECC_SECG_P256K1 / SIGN_VERIFY key.
KeyID string `json:"key_id"`
// Region is the AWS region of the key. Optional; falls back to the ambient AWS config
// (AWS_REGION, shared config, instance/IRSA role) when empty.
Region string `json:"region,omitempty"`
// SignerAddress is the expected Ethereum address of the key. Required: it is validated
// against the address derived from the KMS public key at startup so a misconfigured
// key id fails fast instead of signing from an unexpected account.
SignerAddress string `json:"signer_address"`
}
8 changes: 8 additions & 0 deletions rollup/internal/controller/relayer/l2_relayer.go
Original file line number Diff line number Diff line change
Expand Up @@ -1289,6 +1289,14 @@ func addrFromSignerConfig(config *config.SignerConfig) (common.Address, error) {
return common.Address{}, fmt.Errorf("signer address is empty")
}
return common.HexToAddress(config.RemoteSignerConfig.SignerAddress), nil
case sender.AWSKMSSignerType:
if config.AWSKMSSignerConfig == nil || config.AWSKMSSignerConfig.SignerAddress == "" {
return common.Address{}, fmt.Errorf("aws kms signer address is empty")
}
if !common.IsHexAddress(config.AWSKMSSignerConfig.SignerAddress) {
return common.Address{}, fmt.Errorf("aws kms signer address %q is not a valid hex address", config.AWSKMSSignerConfig.SignerAddress)
}
return common.HexToAddress(config.AWSKMSSignerConfig.SignerAddress), nil
default:
return common.Address{}, fmt.Errorf("failed to determine signer address, unknown signer type: %v", config.SignerType)
}
Expand Down
92 changes: 92 additions & 0 deletions rollup/internal/controller/sender/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Transaction signers

Each rollup sender (`gas_oracle_sender`, `commit_sender`, `finalize_sender`) is
configured with its own `*_signer_config` block in the relayer config. The
`signer_type` field selects how transactions are signed:

| `signer_type` | Key location | Blob tx (EIP-4844) support |
| ------------- | ------------ | -------------------------- |
| `PrivateKey` | In process / mounted secret | yes |
| `RemoteSigner`| web3signer (`eth_signTransaction`) | no |
| `AWSKMS` | AWS KMS (never leaves KMS) | yes |

`commit_sender` submits batches as blob transactions, so it must use
`PrivateKey` or `AWSKMS`.

## AWSKMS

The `AWSKMS` signer keeps the private key inside AWS KMS. The service only sends
the 32-byte transaction signing hash to KMS and assembles the signature locally,
so all transaction types — including blob transactions — are supported.

```json
"commit_sender_signer_config": {
"signer_type": "AWSKMS",
"aws_kms_signer_config": {
"key_id": "arn:aws:kms:us-east-1:123456789012:key/abcd-...",
"region": "us-east-1",
"signer_address": "0x1C5A77d9FA7eF466951B2F01F724BCa3A5820b63"
}
}
```

- **`key_id`** — id, alias, or ARN of an asymmetric KMS key with key spec
`ECC_SECG_P256K1` and key usage `SIGN_VERIFY`.
- **`region`** — optional; falls back to the ambient AWS config
(`AWS_REGION`, shared config, instance/IRSA role) when empty.
- **`signer_address`** — **required**. The expected Ethereum address of the key.
It is validated at startup against the address derived from the KMS public key,
so a misconfigured `key_id` fails fast instead of signing from an unexpected
account.

AWS credentials are resolved from the standard AWS SDK chain (IRSA, instance
role, or environment) — never put credentials in the config file.

### Recommended IAM setup

- Use a **separate KMS key per sender** so `kms:Sign` can be scoped and audited
(via CloudTrail) per workload.
- Grant each workload only `kms:Sign` and `kms:GetPublicKey` on its key.
- KMS protects the key material, not the spending policy: keep the hot-wallet
pattern (low balances, alerting, rotation). A compromised service can still
request signatures, so consider transaction value/rate guards as a follow-up.

### Provisioning the key

There are two ways to get a key into KMS. Whichever you use, the signer never
needs the raw private key — it derives the Ethereum address from the KMS public
key (`keccak256(pubkey)[12:]`) at startup, and you put that address in
`signer_address`. Get the address from `GetPublicKey` (the relayer logs it on
startup, or derive it yourself from the returned point) and **fund it** before
the sender goes live.

**Option A — generate in KMS (recommended).** The private key is created inside
the KMS HSM and is non-exportable: it provably never exists outside KMS.

```bash
aws kms create-key \
--key-spec ECC_SECG_P256K1 \
--key-usage SIGN_VERIFY \
--origin AWS_KMS \
--description "scroll commit_sender"
# optional friendlier reference:
aws kms create-alias --alias-name alias/scroll-commit-sender --target-key-id <key-id>
```

Use this for new senders — fund the freshly derived address.

**Option B — import an existing private key (`Origin=EXTERNAL`).** Use this only
when you must preserve an already-funded address. KMS supports importing key
material for asymmetric keys, but the key existed in plaintext outside KMS at
least once, which is exactly the exposure the KMS signer otherwise eliminates —
so prefer Option A unless address continuity is a hard requirement. Create the
key with `--origin EXTERNAL`, then `get-parameters-for-import` /
`import-key-material` to upload the wrapped secp256k1 private key.

**Rotation.** KMS does not auto-rotate asymmetric keys, and a key's address
cannot change in place. Rotating means creating a new key (Option A), pointing
`signer_address`/`key_id` at it, and sweeping the old balance to the new address
— the old KMS key signs that sweep itself while still enabled, so no key access
is needed; disable/schedule-delete it only after the sweep confirms. Because a
non-exportable key can't leak, rotation is primarily an IAM-compromise response
rather than routine hygiene.
198 changes: 198 additions & 0 deletions rollup/internal/controller/sender/kms_signer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
package sender

import (
"context"
"crypto/ecdsa"
"encoding/asn1"
"fmt"
"math/big"

awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/kms"
kmstypes "github.com/aws/aws-sdk-go-v2/service/kms/types"

"github.com/scroll-tech/go-ethereum/common"
"github.com/scroll-tech/go-ethereum/crypto"

"scroll-tech/rollup/internal/config"
)

// kmsAPI captures the subset of the AWS KMS client used by the signer. It is an
// interface so the signer can be unit-tested with a mock in place of a live KMS.
type kmsAPI interface {
GetPublicKey(ctx context.Context, params *kms.GetPublicKeyInput, optFns ...func(*kms.Options)) (*kms.GetPublicKeyOutput, error)
Sign(ctx context.Context, params *kms.SignInput, optFns ...func(*kms.Options)) (*kms.SignOutput, error)
}

// kmsSigner produces ECDSA secp256k1 signatures over transaction hashes using an
// AWS KMS asymmetric key. The private key never leaves KMS: only the 32-byte
// signing hash is sent, and KMS returns a DER-encoded signature which we turn
// into Ethereum's 65-byte [R || S || V] form locally.
type kmsSigner struct {
client kmsAPI
keyID string
addr common.Address
}

// asn1Spki mirrors the SubjectPublicKeyInfo DER structure returned by KMS
// GetPublicKey for an ECC_SECG_P256K1 key. PublicKey holds the uncompressed
// point (0x04 || X || Y).
type asn1Spki struct {
Algorithm struct {
Algorithm asn1.ObjectIdentifier
Parameters asn1.ObjectIdentifier
}
PublicKey asn1.BitString
}

// asn1EcSig mirrors the DER ECDSA signature KMS returns: SEQUENCE { r, s }.
type asn1EcSig struct {
R *big.Int
S *big.Int
}

// OIDs expected in the SPKI AlgorithmIdentifier of a KMS ECC_SECG_P256K1 key.
var (
oidPublicKeyECDSA = asn1.ObjectIdentifier{1, 2, 840, 10045, 2, 1} // id-ecPublicKey
oidNamedCurveS256 = asn1.ObjectIdentifier{1, 3, 132, 0, 10} // secp256k1
)

// secp256k1N is the secp256k1 curve order; secp256k1HalfN is N/2. Signatures with
// s above N/2 are normalized to N-s (EIP-2 low-s).
var (
secp256k1N = crypto.S256().Params().N
secp256k1HalfN = new(big.Int).Rsh(secp256k1N, 1)
)

// newKMSSigner constructs a signer backed by a live AWS KMS key. cfg.SignerAddress
// is required and validated against the address derived from the KMS public key,
// so a wrong key id fails fast instead of signing from an unexpected account.
func newKMSSigner(ctx context.Context, cfg *config.AWSKMSSignerConfig) (*kmsSigner, error) {
if cfg == nil {
return nil, fmt.Errorf("aws_kms_signer_config is nil")
}
if cfg.KeyID == "" {
return nil, fmt.Errorf("aws kms signer: key_id is empty")
}
if cfg.SignerAddress == "" {
return nil, fmt.Errorf("aws kms signer: signer_address is required")
}
if !common.IsHexAddress(cfg.SignerAddress) {
return nil, fmt.Errorf("aws kms signer: signer_address %q is not a valid hex address", cfg.SignerAddress)
}

var optFns []func(*awsconfig.LoadOptions) error
if cfg.Region != "" {
optFns = append(optFns, awsconfig.WithRegion(cfg.Region))
}
awsCfg, err := awsconfig.LoadDefaultConfig(ctx, optFns...)
if err != nil {
return nil, fmt.Errorf("aws kms signer: failed to load aws config: %w", err)
}

return newKMSSignerWithClient(ctx, kms.NewFromConfig(awsCfg), cfg.KeyID, common.HexToAddress(cfg.SignerAddress))
}

// newKMSSignerWithClient is the testable core: it derives the key's address from
// the KMS public key and asserts it matches expectedAddr.
func newKMSSignerWithClient(ctx context.Context, client kmsAPI, keyID string, expectedAddr common.Address) (*kmsSigner, error) {
pub, err := publicKeyFromKMS(ctx, client, keyID)
if err != nil {
return nil, err
}
derivedAddr := crypto.PubkeyToAddress(*pub)
if derivedAddr != expectedAddr {
return nil, fmt.Errorf("aws kms signer: configured signer_address %s does not match address %s derived from KMS key %s", expectedAddr.Hex(), derivedAddr.Hex(), keyID)
}
return &kmsSigner{client: client, keyID: keyID, addr: derivedAddr}, nil
}

func publicKeyFromKMS(ctx context.Context, client kmsAPI, keyID string) (*ecdsa.PublicKey, error) {
out, err := client.GetPublicKey(ctx, &kms.GetPublicKeyInput{KeyId: &keyID})
if err != nil {
return nil, fmt.Errorf("aws kms signer: GetPublicKey failed: %w", err)
}
var spki asn1Spki
rest, err := asn1.Unmarshal(out.PublicKey, &spki)
if err != nil {
return nil, fmt.Errorf("aws kms signer: failed to parse public key DER: %w", err)
}
if len(rest) != 0 {
return nil, fmt.Errorf("aws kms signer: trailing bytes after public key DER")
}
if !spki.Algorithm.Algorithm.Equal(oidPublicKeyECDSA) || !spki.Algorithm.Parameters.Equal(oidNamedCurveS256) {
return nil, fmt.Errorf("aws kms signer: unexpected public key algorithm/curve, want id-ecPublicKey/secp256k1")
}
if spki.PublicKey.BitLength != len(spki.PublicKey.Bytes)*8 {
return nil, fmt.Errorf("aws kms signer: public key bit string has unused bits")
}
// crypto.UnmarshalPubkey requires the 65-byte uncompressed form (0x04 || X || Y)
// and verifies the point lies on the curve.
pub, err := crypto.UnmarshalPubkey(spki.PublicKey.Bytes)
if err != nil {
return nil, fmt.Errorf("aws kms signer: failed to unmarshal secp256k1 public key: %w", err)
}
return pub, nil
}

// address returns the Ethereum address of the KMS key.
func (k *kmsSigner) address() common.Address {
return k.addr
}

// sign returns the 65-byte [R || S || V] Ethereum signature over the given 32-byte hash.
func (k *kmsSigner) sign(ctx context.Context, hash []byte) ([]byte, error) {
out, err := k.client.Sign(ctx, &kms.SignInput{
KeyId: &k.keyID,
Message: hash,
MessageType: kmstypes.MessageTypeDigest,
SigningAlgorithm: kmstypes.SigningAlgorithmSpecEcdsaSha256,
})
if err != nil {
return nil, fmt.Errorf("aws kms signer: Sign failed: %w", err)
}

var sig asn1EcSig
rest, err := asn1.Unmarshal(out.Signature, &sig)
if err != nil {
return nil, fmt.Errorf("aws kms signer: failed to parse DER signature: %w", err)
}
if len(rest) != 0 {
return nil, fmt.Errorf("aws kms signer: trailing bytes after DER signature")
}
if sig.R == nil || sig.S == nil {
return nil, fmt.Errorf("aws kms signer: DER signature missing r or s")
}

r, s := sig.R, sig.S
// Guard against a malformed DER signature: r and s must be in [1, N-1].
// Besides being the valid ECDSA range, this keeps them positive and <32 bytes,
// so FillBytes below cannot panic.
if r.Sign() <= 0 || s.Sign() <= 0 || r.Cmp(secp256k1N) >= 0 || s.Cmp(secp256k1N) >= 0 {
return nil, fmt.Errorf("aws kms signer: signature (r,s) out of range [1, N-1]")
}
// EIP-2: enforce low-s to keep signatures canonical.
if s.Cmp(secp256k1HalfN) > 0 {
s = new(big.Int).Sub(secp256k1N, s)
}

rsSig := make([]byte, 65)
r.FillBytes(rsSig[0:32])
s.FillBytes(rsSig[32:64])

// Recover the recovery id (v). KMS returns only (r, s), so we try the two valid
// Ethereum parities and keep the one that recovers our address. Recovery ids 2/3
// require R.x >= N (probability ~2^-128) and are not valid Ethereum yParity values,
// so an unrecoverable signature returns the error below rather than a bad signature.
for v := byte(0); v <= 1; v++ {
rsSig[64] = v
pub, err := crypto.SigToPub(hash, rsSig)
if err != nil {
continue
}
if crypto.PubkeyToAddress(*pub) == k.addr {
return rsSig, nil
}
}
return nil, fmt.Errorf("aws kms signer: failed to recover a valid recovery id for the signature")
}
Loading
Loading