diff --git a/cmd/kms/main.go b/cmd/kms/main.go index 3e45f5d..f2932c6 100644 --- a/cmd/kms/main.go +++ b/cmd/kms/main.go @@ -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 } @@ -137,6 +137,7 @@ func startCmd() *cobra.Command { grpcErr <- serr } }() + defer cleanupGRPC() defer gs.GracefulStop() } diff --git a/config/config.go b/config/config.go index 7292cb7..b4426a0 100644 --- a/config/config.go +++ b/config/config.go @@ -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) + KeyID string `yaml:"key_id"` // awskms: KMS id, ARN, or alias/ + + FileConfig `yaml:",inline"` + AWSKMSConfig `yaml:",inline"` } diff --git a/config/config_grpc_test.go b/config/config_grpc_test.go index 2545ec8..ad7bd56 100644 --- a/config/config_grpc_test.go +++ b/config/config_grpc_test.go @@ -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}, }}, }, } @@ -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") +} diff --git a/config/default.yaml b/config/default.yaml index 2327224..20fb843 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -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/ +# region: us-east-1 # optional; AWS default chain otherwise diff --git a/config/validate.go b/config/validate.go index 040ca95..7b4b003 100644 --- a/config/validate.go +++ b/config/validate.go @@ -232,7 +232,8 @@ 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) } @@ -240,11 +241,31 @@ func (c *Config) validateGRPC(home string) error { 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 == "" { + 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) + } + 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 diff --git a/internal/app/build.go b/internal/app/build.go index ddd07e9..b668dd2 100644 --- a/internal/app/build.go +++ b/internal/app/build.go @@ -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) { 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" + 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) } diff --git a/internal/app/build_awskms_test.go b/internal/app/build_awskms_test.go index 43f8a7f..bc748cb 100644 --- a/internal/app/build_awskms_test.go +++ b/internal/app/build_awskms_test.go @@ -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") + }) } diff --git a/internal/signerservice/server_test.go b/internal/signerservice/server_test.go index 8d2112b..54f4a6d 100644 --- a/internal/signerservice/server_test.go +++ b/internal/signerservice/server_test.go @@ -2,6 +2,8 @@ package signerservice import ( "context" + "crypto/ed25519" + "crypto/rand" "crypto/sha256" "net" "os" @@ -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() @@ -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) diff --git a/signing/awskms/awskms.go b/signing/awskms/awskms.go index 9d97c59..2954320 100644 --- a/signing/awskms/awskms.go +++ b/signing/awskms/awskms.go @@ -38,10 +38,11 @@ type kmsAPI interface { // The concrete AWS KMS client must satisfy the interface we depend on. var _ kmsAPI = (*kms.Client)(nil) -// Signer signs via the AWS KMS Sign API. It is +// Backend signs via the AWS KMS Sign API for the consensus (privval) path. It is // stateless beyond the cached public key and is safe for concurrent use (the -// AWS SDK client is concurrency-safe). -type Signer struct { +// AWS SDK client is concurrency-safe). The gRPC SignerService uses Signer (see +// signer.go), which wraps a *Backend. +type Backend struct { client kmsAPI keyID string pub crypto.PubKey @@ -52,7 +53,7 @@ type Signer struct { // key's public key, and validates its spec against the configured algorithm. Any // failure is returned (fatal at startup for the chain). It performs one KMS // GetPublicKey call. -func Open(ctx context.Context, cfg Config) (*Signer, error) { +func Open(ctx context.Context, cfg Config) (*Backend, error) { algoName := cfg.Algorithm if algoName == "" { algoName = algoEd25519 @@ -84,7 +85,7 @@ func Open(ctx context.Context, cfg Config) (*Signer, error) { // open is the client-injectable core of Open: it fetches the public key, // verifies the key spec, and decodes the public key. Tests call it with a fake // kmsAPI. -func open(ctx context.Context, client kmsAPI, keyID string, algo keyAlgo) (*Signer, error) { +func open(ctx context.Context, client kmsAPI, keyID string, algo keyAlgo) (*Backend, error) { out, err := client.GetPublicKey(ctx, &kms.GetPublicKeyInput{KeyId: aws.String(keyID)}) if err != nil { return nil, fmt.Errorf("awskms: get public key for %q: %w", keyID, err) @@ -97,14 +98,14 @@ func open(ctx context.Context, client kmsAPI, keyID string, algo keyAlgo) (*Sign if err != nil { return nil, fmt.Errorf("awskms: decode public key for %q: %w", keyID, err) } - return &Signer{client: client, keyID: keyID, pub: pub, algo: algo}, nil + return &Backend{client: client, keyID: keyID, pub: pub, algo: algo}, nil } // PubKey returns the validator public key cached at Open. -func (s *Signer) PubKey(context.Context) (crypto.PubKey, error) { return s.pub, nil } +func (s *Backend) PubKey(context.Context) (crypto.PubKey, error) { return s.pub, nil } // Sign signs the canonical consensus sign-bytes via the KMS Sign API. -func (s *Signer) Sign(ctx context.Context, signBytes []byte) ([]byte, error) { +func (s *Backend) Sign(ctx context.Context, signBytes []byte) ([]byte, error) { out, err := s.client.Sign(ctx, &kms.SignInput{ KeyId: aws.String(s.keyID), Message: signBytes, @@ -118,6 +119,6 @@ func (s *Signer) Sign(ctx context.Context, signBytes []byte) ([]byte, error) { } // Close is a no-op for awskms based signers. -func (s *Signer) Close() error { +func (s *Backend) Close() error { return nil } diff --git a/signing/awskms/awskms_localstack_test.go b/signing/awskms/awskms_localstack_test.go index 922ab39..753a06e 100644 --- a/signing/awskms/awskms_localstack_test.go +++ b/signing/awskms/awskms_localstack_test.go @@ -12,6 +12,7 @@ package awskms import ( "context" + "crypto/ed25519" "os" "strings" "testing" @@ -22,6 +23,8 @@ import ( "github.com/aws/aws-sdk-go-v2/service/kms" "github.com/aws/aws-sdk-go-v2/service/kms/types" "github.com/stretchr/testify/require" + + pb "github.com/cosmos/kms/gen/signerservice" ) func endpoint() string { @@ -73,4 +76,17 @@ func TestLocalStackSignRoundtrip(t *testing.T) { sig, err := s.Sign(ctx, msg) require.NoError(t, err) require.True(t, pub.VerifySignature(msg, sig)) + + // Same key through the gRPC SignerService adapter: 32-byte pubkey, ED25519 + // scheme, 64-byte signature that the pubkey verifies. + gs := &Signer{be: s} + require.Equal(t, pb.SignatureScheme_ED25519, gs.Scheme()) + gpub := gs.PubKey() + require.Len(t, gpub, ed25519.PublicKeySize) + + gmsg := []byte("localstack signerservice payload") + gsig, err := gs.Sign(ctx, gmsg) + require.NoError(t, err) + require.Len(t, gsig, ed25519.SignatureSize) + require.True(t, ed25519.Verify(ed25519.PublicKey(gpub), gmsg, gsig)) } diff --git a/signing/awskms/signer.go b/signing/awskms/signer.go new file mode 100644 index 0000000..f601ecb --- /dev/null +++ b/signing/awskms/signer.go @@ -0,0 +1,57 @@ +package awskms + +import ( + "context" + "fmt" + + pb "github.com/cosmos/kms/gen/signerservice" + "github.com/cosmos/kms/signing" +) + +// Signer adapts an AWS KMS ed25519 key to the gRPC SignerService signing.Signer +// interface. The private key never leaves KMS; signing is performed by the KMS +// Sign API. It wraps a *Backend (the consensus-path KMS signer) and reuses its +// client, cached public key, and Sign path verbatim — KMS ed25519 is PureEd25519 +// over the raw message (MessageType=RAW), which is exactly what the gRPC ED25519 +// scheme requires. Only ed25519 is supported over gRPC today. +type Signer struct { + be *Backend +} + +// The adapter must satisfy the SignerService signer contract. +var _ signing.Signer = (*Signer)(nil) + +// OpenSigner resolves AWS configuration, builds a KMS client, fetches and caches +// the key's public key, and validates its spec against the configured algorithm +// (which must be ed25519). It performs one KMS GetPublicKey call and any failure +// is returned (fatal at startup). Algorithm defaults to ed25519 when empty; a +// non-ed25519 algorithm is rejected since the gRPC SignerService only supports +// ED25519 for KMS keys. +func OpenSigner(ctx context.Context, cfg Config) (*Signer, error) { + if cfg.Algorithm != "" && cfg.Algorithm != algoEd25519 { + return nil, fmt.Errorf("awskms: gRPC SignerService supports only %q, got %q", algoEd25519, cfg.Algorithm) + } + be, err := Open(ctx, cfg) + if err != nil { + return nil, err + } + return &Signer{be: be}, nil +} + +// PubKey returns the 32-byte ed25519 public key, the canonical SignerService +// encoding for ED25519. +func (s *Signer) PubKey() []byte { return s.be.pub.Bytes() } + +// Scheme reports ED25519. +func (s *Signer) Scheme() pb.SignatureScheme { return pb.SignatureScheme_ED25519 } + +// Sign signs the payload (the message) under ED25519 via the KMS Sign API and +// returns the raw 64-byte signature. +func (s *Signer) Sign(ctx context.Context, payload []byte) ([]byte, error) { + return s.be.Sign(ctx, payload) +} + +// Close closes the backend for the aws kms based signer. +func (s *Signer) Close() error { + return s.be.Close() +} diff --git a/signing/awskms/signer_test.go b/signing/awskms/signer_test.go new file mode 100644 index 0000000..a801491 --- /dev/null +++ b/signing/awskms/signer_test.go @@ -0,0 +1,42 @@ +package awskms + +import ( + "context" + "crypto/ed25519" + "testing" + + "github.com/stretchr/testify/require" + + pb "github.com/cosmos/kms/gen/signerservice" +) + +// TestGRPCSignerRoundtrip exercises the SignerService adapter end to end against +// the in-process fake KMS: the public key is the 32-byte ed25519 key, the scheme +// is ED25519, and Sign returns a 64-byte signature the same key verifies. +func TestGRPCSignerRoundtrip(t *testing.T) { + f := newFakeKMS(t) + be, err := open(context.Background(), f, "alias/attestor", algos[algoEd25519]) + require.NoError(t, err) + s := &Signer{be: be} + + require.Equal(t, pb.SignatureScheme_ED25519, s.Scheme()) + + pub := s.PubKey() + require.Len(t, pub, ed25519.PublicKeySize) + require.Equal(t, []byte(f.priv.Public().(ed25519.PublicKey)), pub) + + msg := []byte("attestation payload") + sig, err := s.Sign(context.Background(), msg) + require.NoError(t, err) + require.Len(t, sig, ed25519.SignatureSize) + require.True(t, ed25519.Verify(ed25519.PublicKey(pub), msg, sig), + "SignerService pubkey must verify the KMS signature") +} + +// TestOpenSignerRejectsNonEd25519Algorithm guards against a future secp256k1 +// registry entry being silently served under the ED25519 scheme. The rejection +// happens before any AWS call, so no network/credentials are needed. +func TestOpenSignerRejectsNonEd25519Algorithm(t *testing.T) { + _, err := OpenSigner(context.Background(), Config{KeyID: "k", Algorithm: "secp256k1"}) + require.ErrorContains(t, err, "only") +} diff --git a/signing/file/ed25519.go b/signing/file/ed25519.go index a12b0e5..04dfa59 100644 --- a/signing/file/ed25519.go +++ b/signing/file/ed25519.go @@ -14,8 +14,8 @@ import ( cmtjson "github.com/cometbft/cometbft/libs/json" ) -// Signer is a file-backed Ed25519 key held in memory. -type Signer struct { +// Backend is a file-backed Ed25519 key held in memory. +type Backend struct { priv crypto.PrivKey pub crypto.PubKey } @@ -23,7 +23,7 @@ type Signer struct { // LoadEd25519 reads a key file. It accepts either a CometBFT priv_validator_key.json // (typed JSON with a "priv_key" field) or a file containing the base64-encoded // 64-byte Ed25519 private key. -func LoadEd25519(path string) (*Signer, error) { +func LoadEd25519(path string) (*Backend, error) { raw, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("file: read key file %q: %w", path, err) @@ -33,7 +33,7 @@ func LoadEd25519(path string) (*Signer, error) { if err != nil { return nil, fmt.Errorf("file: parse key file %q: %w", path, err) } - return &Signer{priv: priv, pub: priv.PubKey()}, nil + return &Backend{priv: priv, pub: priv.PubKey()}, nil } func parseKey(raw []byte) (crypto.PrivKey, error) { @@ -66,14 +66,14 @@ func parseKey(raw []byte) (crypto.PrivKey, error) { } // PubKey returns the public key. -func (s *Signer) PubKey(context.Context) (crypto.PubKey, error) { return s.pub, nil } +func (s *Backend) PubKey(context.Context) (crypto.PubKey, error) { return s.pub, nil } // Sign signs signBytes with the in-memory private key. -func (s *Signer) Sign(_ context.Context, signBytes []byte) ([]byte, error) { +func (s *Backend) Sign(_ context.Context, signBytes []byte) ([]byte, error) { return s.priv.Sign(signBytes) } // Close is a no-op for file based signers. -func (s *Signer) Close() error { +func (s *Backend) Close() error { return nil } diff --git a/signing/pkcs11/pkcs11.go b/signing/pkcs11/pkcs11.go index 4b67ec8..15d9355 100644 --- a/signing/pkcs11/pkcs11.go +++ b/signing/pkcs11/pkcs11.go @@ -32,10 +32,10 @@ type Config struct { Algorithm string } -// Signer signs on a PKCS#11 token. It owns a single +// Backend signs on a PKCS#11 token. It owns a single // long-lived session; the mutex serializes signing (PKCS#11 sessions are not // safe for concurrent use) and guards Close. -type Signer struct { +type Backend struct { mod *pkcs11.Ctx module string // module path, used to release the shared context on Close session pkcs11.SessionHandle @@ -49,9 +49,9 @@ type Signer struct { // Open loads the PKCS#11 module, logs into the selected token, locates the key, // and caches its public key. Any failure is returned (fatal at startup for the -// chain). On success the returned Signer holds an open, logged-in session that +// chain). On success the returned Backend holds an open, logged-in session that // must be released with Close. -func Open(cfg Config) (s *Signer, err error) { +func Open(cfg Config) (s *Backend, err error) { algoName := cfg.Algorithm if algoName == "" { algoName = algoEd25519 @@ -124,7 +124,7 @@ func Open(cfg Config) (s *Signer, err error) { return nil, fmt.Errorf("pkcs11: decode public key: %w", err) } - return &Signer{mod: mod, module: cfg.Module, session: session, privH: privH, pub: pub, algo: algo}, nil + return &Backend{mod: mod, module: cfg.Module, session: session, privH: privH, pub: pub, algo: algo}, nil } // selectSlot returns the slot to use: the explicit Slot when set, otherwise the @@ -193,10 +193,10 @@ func keySelector(cfg Config) string { } // PubKey returns the validator public key cached at Open. -func (s *Signer) PubKey(context.Context) (crypto.PubKey, error) { return s.pub, nil } +func (s *Backend) PubKey(context.Context) (crypto.PubKey, error) { return s.pub, nil } // Sign signs the canonical consensus sign-bytes on the token. -func (s *Signer) Sign(_ context.Context, signBytes []byte) ([]byte, error) { +func (s *Backend) Sign(_ context.Context, signBytes []byte) ([]byte, error) { s.mu.Lock() defer s.mu.Unlock() if s.closed { @@ -214,7 +214,7 @@ func (s *Signer) Sign(_ context.Context, signBytes []byte) ([]byte, error) { // Close logs out, closes the session, and tears down the module. It is // idempotent. -func (s *Signer) Close() error { +func (s *Backend) Close() error { s.mu.Lock() defer s.mu.Unlock() if s.closed { diff --git a/signing/signer.go b/signing/signer.go index 7176b94..558a147 100644 --- a/signing/signer.go +++ b/signing/signer.go @@ -18,6 +18,8 @@ type Signer interface { Scheme() pb.SignatureScheme // Sign signs payload under Scheme and returns the raw signature bytes. Sign(ctx context.Context, payload []byte) ([]byte, error) + // Close contains any logic that should be called on cleanup. + Close() error } // Key is a configured SignerService signing identity.