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
3 changes: 2 additions & 1 deletion cmd/kms/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ func startCmd() *cobra.Command {
defer mgr.Stop()

grpcErr := make(chan error, 1)
gs, lis, err := app.BuildGRPC(cfg, home, logger)
gs, cleanupGRPC, lis, err := app.BuildGRPC(cfg, home, logger)
if err != nil {
return err
}
Expand All @@ -137,6 +137,7 @@ func startCmd() *cobra.Command {
grpcErr <- serr
}
}()
defer cleanupGRPC()
defer gs.GracefulStop()
}

Expand Down
20 changes: 12 additions & 8 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,13 +143,17 @@ type GRPCConfig struct {
Keys []GRPCKey `yaml:"keys"`
}

// GRPCKey binds a signing key to a key_id. Backend selects the custodian and
// Algorithm the key type; both default to the only implemented combination
// (file/secp256k1) when empty. The server performs no caller authorization,
// so every configured key is usable by any connecting client.
// GRPCKey binds a signing key to an id (the SignerService key handle clients
// address). Backend selects the custodian and Algorithm the key type. The
// supported combinations are file/secp256k1 (the default) and awskms/ed25519;
// PKCS#11 is not yet supported over gRPC. The server performs no caller
// authorization, so every configured key is usable by any connecting client.
type GRPCKey struct {
ID string `yaml:"id"`
Backend string `yaml:"backend"` // "file" (default)
Algorithm string `yaml:"algorithm"` // "secp256k1" (default)
KeyFile string `yaml:"key_file"`
ID string `yaml:"id"`
Backend Backend `yaml:"backend"` // "file" (default) | "awskms"
Algorithm string `yaml:"algorithm"` // file: "secp256k1" (default); awskms: "ed25519" (default)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ed26619 is relevant not only to AWS Keys

KeyID string `yaml:"key_id"` // awskms: KMS id, ARN, or alias/<name>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
KeyID string `yaml:"key_id"` // awskms: KMS id, ARN, or alias/<name>
AWSKeyID string `yaml:"aws_key_id"` // awskms: KMS id, ARN, or alias/<name>


FileConfig `yaml:",inline"`
AWSKMSConfig `yaml:",inline"`
}
22 changes: 20 additions & 2 deletions config/config_grpc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ func baseGRPC(t *testing.T) (*Config, string) {
TLSCert: cert,
TLSKey: key,
Keys: []GRPCKey{{
ID: "attestor-1",
KeyFile: kkey,
ID: "attestor-1",
FileConfig: FileConfig{KeyFile: kkey},
}},
},
}
Expand Down Expand Up @@ -63,3 +63,21 @@ func TestValidateGRPCDuplicateKeyID(t *testing.T) {
c.GRPC.Keys = append(c.GRPC.Keys, dup)
require.Error(t, c.Validate(home))
}

func TestValidateGRPCAWSKMSOK(t *testing.T) {
c, home := baseGRPC(t)
c.GRPC.Keys = []GRPCKey{{ID: "a1", Backend: BackendAWSKMS, KeyID: "alias/attestor", Algorithm: "ed25519"}}
require.NoError(t, c.Validate(home))
}

func TestValidateGRPCAWSKMSRequiresKeyID(t *testing.T) {
c, home := baseGRPC(t)
c.GRPC.Keys = []GRPCKey{{ID: "a1", Backend: BackendAWSKMS}}
require.ErrorContains(t, c.Validate(home), "key_id")
}

func TestValidateGRPCAWSKMSUnknownAlgorithm(t *testing.T) {
c, home := baseGRPC(t)
c.GRPC.Keys = []GRPCKey{{ID: "a1", Backend: BackendAWSKMS, KeyID: "alias/attestor", Algorithm: "rsa-9000"}}
require.ErrorContains(t, c.Validate(home), "algorithm")
}
7 changes: 7 additions & 0 deletions config/default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,10 @@ keys:
# backend: file # optional; default "file"
# algorithm: secp256k1 # optional; default "secp256k1"
# key_file: attestor_secp256k1.hex # hex-encoded 32-byte secp256k1 key
# # awskms: an Ed25519 key in AWS KMS (key never leaves KMS; AWS default
# # credential chain). algorithm defaults to "ed25519" for awskms.
# - id: attestor-2
# backend: awskms
# algorithm: ed25519 # optional; default "ed25519" for awskms
# key_id: alias/attestor # KMS id, ARN, or alias/<name>
# region: us-east-1 # optional; AWS default chain otherwise
31 changes: 26 additions & 5 deletions config/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,19 +232,40 @@ func (c *Config) validateGRPC(home string) error {
return fmt.Errorf("config: grpc requires at least one grpc.key entry")
}
seen := map[string]bool{}
for i, k := range g.Keys {
for i := range g.Keys {
k := &g.Keys[i]
if k.ID == "" {
return fmt.Errorf("config: grpc.key[%d].id is required", i)
}
if seen[k.ID] {
return fmt.Errorf("config: duplicate grpc.key id %q", k.ID)
}
seen[k.ID] = true
if k.KeyFile == "" {
return fmt.Errorf("config: grpc.key[%d].key_file is required", i)

// gRPC keys carry their own (small) backend validation rather than sharing
// the consensus per-backend validators: only file and awskms are supported
// here, and a gRPC key is bound by id, not chain_ids.
if k.Backend == "" {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

imo, we should explicitly fail if it's not specified. Even when the backend is "", we still need a key file path.

k.Backend = BackendFile
}
if _, err := os.Stat(AbsPath(home, k.KeyFile)); err != nil {
return fmt.Errorf("config: grpc.key[%d].key_file %q: %w", i, k.KeyFile, err)
switch k.Backend {
case BackendFile:
if k.KeyFile == "" {
return fmt.Errorf("config: grpc.key[%d] (file) requires key_file", i)
}
if _, err := os.Stat(AbsPath(home, k.KeyFile)); err != nil {
return fmt.Errorf("config: grpc.key[%d].key_file %q: %w", i, k.KeyFile, err)
}
Comment thread
mattac21 marked this conversation as resolved.
k.KeyFile = AbsPath(home, k.KeyFile)
case BackendAWSKMS:
if k.KeyID == "" {
return fmt.Errorf("config: grpc.key[%d] (awskms) requires key_id", i)
}
if k.Algorithm != "" && !supportedAWSKMSAlgorithms[k.Algorithm] {
return fmt.Errorf("config: grpc.key[%d] (awskms) has unknown algorithm %q", i, k.Algorithm)
}
default:
return fmt.Errorf("config: grpc.key[%d] has unsupported backend %q", i, k.Backend)
}
}
return nil
Expand Down
56 changes: 42 additions & 14 deletions internal/app/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,53 +165,81 @@ func newPrivvalBackend(k config.Key) (signing.Backend, error) {
// BuildGRPC constructs the SignerService gRPC server and its listener from the
// grpc config. Returns (nil, nil, nil) when no grpc block is configured.
// The caller owns starting/stopping the server and closing the listener.
func BuildGRPC(c *config.Config, home string, logger log.Logger) (*grpc.Server, net.Listener, error) {
func BuildGRPC(c *config.Config, home string, logger log.Logger) (gs *grpc.Server, cleanup func(), lis net.Listener, err error) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we wrap all of this into Server with s.Close(), s.Listener(), etc... and return only (*Server, error) ?

if c.GRPC == nil {
return nil, nil, nil
return nil, nil, nil, nil
}
g := c.GRPC

var closers []io.Closer
cleanup = func() {
for _, cl := range closers {
_ = cl.Close()
}
}
// On error, release anything already opened before returning.
defer func() {
if err != nil {
cleanup()
}
}()

// keyID -> signing.Signer. The server performs no caller auth: any client
// reaching the listener may use any key (see signerservice.Server).
keys := map[string]signing.Key{}
for _, k := range g.Keys {
s, err := newGRPCSigner(home, k)
if err != nil {
return nil, nil, err
return nil, cleanup, nil, err
}
closers = append(closers, s)
keys[k.ID] = signing.Key{ID: k.ID, Signer: s}
}
srv := signerservice.NewServer(keys)

creds, err := credentials.NewServerTLSFromFile(config.AbsPath(home, g.TLSCert), config.AbsPath(home, g.TLSKey))
if err != nil {
return nil, nil, fmt.Errorf("app: grpc tls: %w", err)
return nil, cleanup, nil, fmt.Errorf("app: grpc tls: %w", err)
}
gs := grpc.NewServer(grpc.Creds(creds))
gs = grpc.NewServer(grpc.Creds(creds))
gensignerservice.RegisterSignerServiceServer(gs, srv)

lis, err := net.Listen("tcp", g.Listen)
lis, err = net.Listen("tcp", g.Listen)
if err != nil {
return nil, nil, fmt.Errorf("app: grpc listen %q: %w", g.Listen, err)
return nil, cleanup, nil, fmt.Errorf("app: grpc listen %q: %w", g.Listen, err)
}
logger.Info("signerservice gRPC server configured", "listen", g.Listen, "keys", len(keys))
return gs, lis, nil
return gs, cleanup, lis, nil
}

// newGRPCSigner constructs the signing.Signer for one grpc.key entry from its
// backend/algorithm. Empty backend/algorithm default to file/secp256k1, the
// only implemented combination.
// backend/algorithm. The algorithm default depends on the backend: file defaults
// to secp256k1, awskms to ed25519. Supported combinations are file/secp256k1 and
// awskms/ed25519.
func newGRPCSigner(home string, k config.GRPCKey) (signing.Signer, error) {
be, algo := k.Backend, k.Algorithm
if be == "" {
be = "file"
be = config.BackendFile
}
if algo == "" {
algo = "secp256k1"
switch be {
case config.BackendAWSKMS:
algo = "ed25519"

@swift1337 swift1337 Jun 26, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let' move all algo types to const Algo* eg AlgoSecp256k1 (applies to all files)

default:
algo = "secp256k1"
}
}
switch {
case be == "file" && algo == "secp256k1":
return file.LoadSecp256k1(config.AbsPath(home, k.KeyFile))
case be == config.BackendFile && algo == "secp256k1":
return file.LoadSecp256k1(k.KeyFile)
case be == config.BackendAWSKMS && algo == "ed25519":
return awskms.OpenSigner(context.Background(), awskms.Config{
KeyID: k.KeyID,
Region: k.Region,
Profile: k.Profile,
Endpoint: k.Endpoint,
Algorithm: algo,
})
default:
return nil, fmt.Errorf("app: grpc key %q: unsupported backend/algorithm %q/%q", k.ID, be, algo)
}
Expand Down
65 changes: 44 additions & 21 deletions internal/app/build_awskms_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,26 +18,49 @@ func TestBuildAWSKMSKeyUnreachableErrors(t *testing.T) {
t.Setenv("AWS_MAX_ATTEMPTS", "1")

home := t.TempDir()
c := &config.Config{
Chains: []config.Chain{{ID: "c1"}},
Validators: []config.Validator{{ChainID: "c1", Addr: "tcp://127.0.0.1:1", IdentityKey: filepath.Join(home, "id.json")}},
Keys: []config.Key{{
ChainIDs: []string{"c1"},
Backend: config.BackendAWSKMS,
KeyID: "alias/validator",
AWSKMSConfig: config.AWSKMSConfig{
Region: "us-east-1",
Endpoint: "http://127.0.0.1:1", // closed port -> connection refused

t.Run("consensus signer", func(t *testing.T) {
c := &config.Config{
Chains: []config.Chain{{ID: "c1"}},
Validators: []config.Validator{{ChainID: "c1", Addr: "tcp://127.0.0.1:1", IdentityKey: filepath.Join(home, "id.json")}},
Keys: []config.Key{{
ChainIDs: []string{"c1"},
Backend: config.BackendAWSKMS,
KeyID: "alias/validator",
AWSKMSConfig: config.AWSKMSConfig{
Region: "us-east-1",
Endpoint: "http://127.0.0.1:1", // closed port -> connection refused
},
}},
}
require.NoError(t, c.Validate(home))

_, cleanup, err := app.Build(c, log.TestingLogger())
t.Cleanup(cleanup)
// The error must come from awskms.Open's GetPublicKey call (connection
// refused against the closed port), proving the provider was wired in. Before
// the wiring exists, Build instead errors with "chain has no backend", which
// does NOT contain this substring — so this assertion is a true red->green.
require.ErrorContains(t, err, "get public key")
})

t.Run("grpc signer", func(t *testing.T) {
c := &config.Config{
GRPC: &config.GRPCConfig{
Listen: "127.0.0.1:0",
Keys: []config.GRPCKey{{
ID: "attestor-1",
Backend: config.BackendAWSKMS,
KeyID: "alias/attestor",
AWSKMSConfig: config.AWSKMSConfig{
Region: "us-east-1",
Endpoint: "http://127.0.0.1:1", // closed port -> connection refused
},
}},
},
}},
}
require.NoError(t, c.Validate(home))

_, cleanup, err := app.Build(c, log.TestingLogger())
t.Cleanup(cleanup)
// The error must come from awskms.Open's GetPublicKey call (connection
// refused against the closed port), proving the provider was wired in. Before
// the wiring exists, Build instead errors with "chain has no backend", which
// does NOT contain this substring — so this assertion is a true red->green.
require.ErrorContains(t, err, "get public key")
}

_, _, _, err := app.BuildGRPC(c, home, log.TestingLogger())
require.ErrorContains(t, err, "get public key")
})
}
50 changes: 50 additions & 0 deletions internal/signerservice/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package signerservice

import (
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"net"
"os"
Expand All @@ -24,6 +26,31 @@ import (
// Secp256k1Signer must satisfy the signing.Signer contract the server signs through.
var _ signing.Signer = (*file.Secp256k1Signer)(nil)

// memEd25519 is a minimal in-memory ED25519-scheme signing.Signer used to prove
// the server's scheme-generic path (e.g. an awskms.Signer behaves the same to
// the server). The 32-byte digest check applies only to ECDSA_SECP256K1, so an
// ED25519 key signs its payload as a message of any length.
type memEd25519 struct {
pub ed25519.PublicKey
priv ed25519.PrivateKey
}

func newMemEd25519(t *testing.T) *memEd25519 {
t.Helper()
pub, priv, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err)
return &memEd25519{pub: pub, priv: priv}
}

func (m *memEd25519) PubKey() []byte { return m.pub }
func (m *memEd25519) Scheme() pb.SignatureScheme { return pb.SignatureScheme_ED25519 }
func (m *memEd25519) Sign(_ context.Context, payload []byte) ([]byte, error) {
return ed25519.Sign(m.priv, payload), nil
}
func (m *memEd25519) Close() error { return nil }

var _ signing.Signer = (*memEd25519)(nil)

func newKey(t *testing.T) *file.Secp256k1Signer {
t.Helper()
dir := t.TempDir()
Expand Down Expand Up @@ -75,6 +102,29 @@ func TestSignRecoverableRecovers(t *testing.T) {
require.Equal(t, k.PubKeyUncompressed(), recovered.SerializeUncompressed())
}

func TestSignAndGetKeyEd25519(t *testing.T) {
k := newMemEd25519(t)
srv := NewServer(map[string]signing.Key{"val-1": {ID: "val-1", Signer: k}})
client := dialTestServer(t, srv)

// ED25519 signs a message of any length: no 32-byte digest restriction.
msg := []byte("an arbitrary-length consensus message")
resp, err := client.Sign(context.Background(), &pb.SignRequest{
KeyId: "val-1",
Payload: &pb.Payload{Kind: &pb.Payload_Generic{Generic: msg}},
})
require.NoError(t, err)
require.Len(t, resp.Signature, ed25519.SignatureSize)
require.True(t, ed25519.Verify(k.pub, msg, resp.Signature))

// GetKey reports the 32-byte pubkey and ED25519 scheme.
got, err := client.GetKey(context.Background(), &pb.GetKeyRequest{Id: "val-1"})
require.NoError(t, err)
require.Equal(t, pb.SignatureScheme_ED25519, got.Key.Scheme)
require.Equal(t, []byte(k.pub), got.Key.Pubkey)
require.Len(t, got.Key.Pubkey, ed25519.PublicKeySize)
}

func TestSignUnknownKey(t *testing.T) {
srv := NewServer(map[string]signing.Key{})
client := dialTestServer(t, srv)
Expand Down
Loading