diff --git a/.gitignore b/.gitignore index 5832d41..1209ee2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ db/ tmp/ bin/ -identity/ event_initiator.identity.json event_initiator.key event_initiator.key.age diff --git a/config.prod.yaml.template b/config.prod.yaml.template index b108259..08e1e49 100644 --- a/config.prod.yaml.template +++ b/config.prod.yaml.template @@ -17,3 +17,19 @@ backup_dir: backups max_concurrent_keygen: 2 max_concurrent_signing: 10 session_warm_up_delay_ms: 100 + +# Authorization (optional) +authorization: + enabled: false + default_threshold: 0 + operation_policies: + keygen: + required_authorizers: 0 + authorizer_ids: [] + signing: + required_authorizers: 0 + authorizer_ids: [] + reshare: + required_authorizers: 0 + authorizer_ids: [] + authorizers: {} diff --git a/config.yaml.template b/config.yaml.template index 694c90c..8097bbc 100644 --- a/config.yaml.template +++ b/config.yaml.template @@ -14,3 +14,23 @@ backup_dir: backups max_concurrent_keygen: 2 max_concurrent_signing: 10 session_warm_up_delay_ms: 100 + +# Authorization (optional) +authorization: + enabled: false + required_authorizers: 0 + # Authorizer public keys configuration (applies to all operations: keygen, signing, reshare) + authorizer_public_keys: {} + # Example: + # auth1: + # public_key: "deadbeef..." + # algorithm: "ed25519" + # auth2: + # public_key: "cafebabe..." + # algorithm: "p256" + # Global authorizers (backward compatibility) + authorizers: {} + # Example: + # auth1: + # pubkey: "deadbeef..." + # algorithm: "ed25519" diff --git a/go.mod b/go.mod index 15b45be..92dce8f 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/spf13/viper v1.18.0 github.com/stretchr/testify v1.10.0 github.com/urfave/cli/v3 v3.3.2 + golang.org/x/crypto v0.37.0 golang.org/x/term v0.31.0 ) @@ -80,7 +81,6 @@ require ( go.uber.org/goleak v1.3.0 // indirect go.uber.org/multierr v1.9.0 // indirect go.uber.org/zap v1.21.0 // indirect - golang.org/x/crypto v0.37.0 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect golang.org/x/net v0.39.0 // indirect golang.org/x/sys v0.33.0 // indirect diff --git a/pkg/eventconsumer/event_consumer.go b/pkg/eventconsumer/event_consumer.go index c84e684..62b45ef 100644 --- a/pkg/eventconsumer/event_consumer.go +++ b/pkg/eventconsumer/event_consumer.go @@ -166,6 +166,13 @@ func (ec *eventConsumer) handleKeyGenEvent(natMsg *nats.Msg) { return } + // Optional multi-authorizer check for keygen + if err := ec.identityStore.AuthorizeInitiatorMessage("keygen", &msg); err != nil { + logger.Error("Authorization failed for keygen", err) + ec.handleKeygenSessionError(msg.WalletID, err, "Authorization failed for keygen", natMsg) + return + } + walletID := msg.WalletID ecdsaSession, err := ec.node.CreateKeyGenSession(mpc.SessionTypeECDSA, walletID, ec.mpcThreshold, ec.genKeyResultQueue) if err != nil { @@ -353,6 +360,12 @@ func (ec *eventConsumer) handleSigningEvent(natMsg *nats.Msg) { return } + // Optional multi-authorizer check for signing + if err := ec.identityStore.AuthorizeInitiatorMessage("signing", &msg); err != nil { + logger.Error("Authorization failed for signing", err) + return + } + logger.Info( "Received signing event", "waleltID", @@ -589,6 +602,13 @@ func (ec *eventConsumer) consumeReshareEvent() error { return } + // Optional multi-authorizer check for reshare + if err := ec.identityStore.AuthorizeInitiatorMessage("reshare", &msg); err != nil { + logger.Error("Authorization failed for reshare", err) + ec.handleReshareSessionError(msg.WalletID, msg.KeyType, msg.NewThreshold, err, "Authorization failed for reshare", natMsg) + return + } + walletID := msg.WalletID keyType := msg.KeyType diff --git a/pkg/eventconsumer/events.go b/pkg/eventconsumer/events.go index 4d71714..fbf7551 100644 --- a/pkg/eventconsumer/events.go +++ b/pkg/eventconsumer/events.go @@ -17,20 +17,30 @@ type InitiatorMessage interface { Sig() []byte // InitiatorID returns the ID whose public key we have to look up. InitiatorID() string + // AuthorizerSigs returns the optional list of authorizer signatures. + AuthorizerSigs() []AuthorizerSignature +} + +// AuthorizerSignature represents an approval signature from an external authorizer. +type AuthorizerSignature struct { + AuthorizerID string `json:"authorizer_id"` + Signature []byte `json:"signature"` } type GenerateKeyMessage struct { - WalletID string `json:"wallet_id"` - Signature []byte `json:"signature"` + WalletID string `json:"wallet_id"` + Signature []byte `json:"signature"` + AuthorizerSignatures []AuthorizerSignature `json:"authorizer_signatures,omitempty"` } type SignTxMessage struct { - KeyType KeyType `json:"key_type"` - WalletID string `json:"wallet_id"` - NetworkInternalCode string `json:"network_internal_code"` - TxID string `json:"tx_id"` - Tx []byte `json:"tx"` - Signature []byte `json:"signature"` + KeyType KeyType `json:"key_type"` + WalletID string `json:"wallet_id"` + NetworkInternalCode string `json:"network_internal_code"` + TxID string `json:"tx_id"` + Tx []byte `json:"tx"` + Signature []byte `json:"signature"` + AuthorizerSignatures []AuthorizerSignature `json:"authorizer_signatures,omitempty"` } func (m *SignTxMessage) Raw() ([]byte, error) { @@ -59,6 +69,10 @@ func (m *SignTxMessage) InitiatorID() string { return m.TxID } +func (m *SignTxMessage) AuthorizerSigs() []AuthorizerSignature { + return m.AuthorizerSignatures +} + func (m *GenerateKeyMessage) Raw() ([]byte, error) { return []byte(m.WalletID), nil } @@ -70,3 +84,7 @@ func (m *GenerateKeyMessage) Sig() []byte { func (m *GenerateKeyMessage) InitiatorID() string { return m.WalletID } + +func (m *GenerateKeyMessage) AuthorizerSigs() []AuthorizerSignature { + return m.AuthorizerSignatures +} diff --git a/pkg/identity/identity.go b/pkg/identity/identity.go index 0d2329a..97e58fe 100644 --- a/pkg/identity/identity.go +++ b/pkg/identity/identity.go @@ -1,12 +1,15 @@ package identity import ( + "crypto/ecdsa" "crypto/ed25519" + "crypto/elliptic" "encoding/hex" "encoding/json" "errors" "fmt" "io" + "math/big" "os" "strings" "sync" @@ -36,6 +39,7 @@ type Store interface { // GetPublicKey retrieves a node's public key by its ID GetPublicKey(nodeID string) ([]byte, error) VerifyInitiatorMessage(msg types.InitiatorMessage) error + AuthorizeInitiatorMessage(operation string, msg types.InitiatorMessage) error SignMessage(msg *types.TssMessage) ([]byte, error) VerifyMessage(msg *types.TssMessage) error @@ -52,6 +56,35 @@ type Store interface { DecryptMessage(cipher []byte, peerID string) ([]byte, error) } +// SignatureAlgorithm represents supported signature algorithms +type SignatureAlgorithm string + +const ( + AlgorithmEd25519 SignatureAlgorithm = "ed25519" + AlgorithmP256 SignatureAlgorithm = "p256" +) + +// AuthorizerInfo represents a single authorizer with their public key and algorithm +type AuthorizerInfo struct { + PublicKey string `json:"public_key"` + Algorithm SignatureAlgorithm `json:"algorithm"` +} + +// AuthorizationConfig holds the cached authorization configuration +type AuthorizationConfig struct { + Enabled bool `mapstructure:"enabled"` + RequiredAuthorizers int `mapstructure:"required_authorizers"` + AuthorizerPublicKeys map[string]AuthorizerInfo `mapstructure:"authorizer_public_keys"` + Authorizers map[string]AuthorizerInfo `mapstructure:"authorizers"` // backward compatibility +} + +// AuthorizerConfigEntry represents the raw configuration for an authorizer +type AuthorizerConfigEntry struct { + PublicKey string `mapstructure:"public_key"` + Algorithm string `mapstructure:"algorithm"` + Pubkey string `mapstructure:"pubkey"` // backward compatibility +} + // fileStore implements the Store interface using the filesystem type fileStore struct { identityDir string @@ -64,6 +97,12 @@ type fileStore struct { privateKey []byte initiatorPubKey []byte symmetricKeys map[string][]byte + + // Cached authorizer information by authorizer ID + authorizerInfo map[string]AuthorizerInfo + + // Cached authorization configuration + authzConfig AuthorizationConfig } // NewFileStore creates a new identity store @@ -110,6 +149,7 @@ func NewFileStore(identityDir, nodeName string, decrypt bool) (*fileStore, error publicKeys: make(map[string][]byte), privateKey: privateKey, initiatorPubKey: initiatorPubKey, + authorizerInfo: make(map[string]AuthorizerInfo), symmetricKeys: make(map[string][]byte), } @@ -145,9 +185,90 @@ func NewFileStore(identityDir, nodeName string, decrypt bool) (*fileStore, error store.publicKeys[identity.NodeID] = key } + // Load authorization configuration + if err := store.loadAuthorizationConfig(); err != nil { + return nil, fmt.Errorf("failed to load authorization config: %w", err) + } + return store, nil } +// loadAuthorizationConfig loads and caches the authorization configuration +func (s *fileStore) loadAuthorizationConfig() error { + // Load base configuration + s.authzConfig = AuthorizationConfig{ + Enabled: viper.GetBool("authorization.enabled"), + RequiredAuthorizers: viper.GetInt("authorization.required_authorizers"), + AuthorizerPublicKeys: make(map[string]AuthorizerInfo), + Authorizers: make(map[string]AuthorizerInfo), + } + + // Load authorizer public keys (new format) + authKeysConfig := viper.GetStringMap("authorization.authorizer_public_keys") + for authID, authData := range authKeysConfig { + info, err := parseAuthorizerConfig(authData, true) + if err != nil { + logger.Warn("Failed to parse authorizer config", "authorizerID", authID, "error", err) + continue + } + if info.PublicKey != "" { + s.authzConfig.AuthorizerPublicKeys[authID] = info + s.authorizerInfo[authID] = info + } + } + + // Load global authorizer configuration (backward compatibility) + authorizersConfig := viper.GetStringMap("authorization.authorizers") + for authID, authData := range authorizersConfig { + // Skip if already loaded from new format + if _, exists := s.authorizerInfo[authID]; exists { + continue + } + + info, err := parseAuthorizerConfig(authData, false) + if err != nil { + logger.Warn("Failed to parse legacy authorizer config", "authorizerID", authID, "error", err) + continue + } + if info.PublicKey != "" { + s.authzConfig.Authorizers[authID] = info + s.authorizerInfo[authID] = info + } + } + + return nil +} + +// parseAuthorizerConfig parses authorizer configuration from interface{} to AuthorizerInfo +func parseAuthorizerConfig(authData interface{}, isNewFormat bool) (AuthorizerInfo, error) { + info := AuthorizerInfo{ + Algorithm: AlgorithmEd25519, // default algorithm + } + + authMap, ok := authData.(map[string]interface{}) + if !ok { + return info, fmt.Errorf("invalid authorizer config format") + } + + // Parse public key (handle both new and legacy formats) + if isNewFormat { + if pubKey, ok := authMap["public_key"].(string); ok && pubKey != "" { + info.PublicKey = pubKey + } + } else { + if pubKey, ok := authMap["pubkey"].(string); ok && pubKey != "" { + info.PublicKey = pubKey + } + } + + // Parse algorithm + if algo, ok := authMap["algorithm"].(string); ok && algo != "" { + info.Algorithm = SignatureAlgorithm(algo) + } + + return info, nil +} + // loadPrivateKey loads the private key from file, decrypting if necessary func loadPrivateKey(identityDir, nodeName string, decrypt bool) (string, error) { // Check for encrypted or unencrypted private key @@ -396,6 +517,118 @@ func (s *fileStore) VerifyInitiatorMessage(msg types.InitiatorMessage) error { return nil } +// AuthorizeInitiatorMessage verifies that a message has sufficient valid authorizer signatures +// according to the configured authorization policy. If authorization is disabled or the +// required threshold resolves to zero, this is a no-op. +// The operation parameter is kept for logging and debugging purposes. +func (s *fileStore) AuthorizeInitiatorMessage(operation string, msg types.InitiatorMessage) error { + // If authorization is not enabled, allow + if !s.authzConfig.Enabled { + return nil + } + + // Get required threshold + required := s.authzConfig.RequiredAuthorizers + if required <= 0 { + // No requirement; authorization effectively disabled + return nil + } + + // Use configured authorizers + allowedAuthorizers := s.authzConfig.AuthorizerPublicKeys + if len(allowedAuthorizers) == 0 { + // Fallback to global authorizer info for backward compatibility + allowedAuthorizers = s.authorizerInfo + } + + // Prepare payload + msgBytes, err := msg.Raw() + if err != nil { + return fmt.Errorf("authorization: failed to get raw payload: %w", err) + } + + // Count valid signatures + seen := map[string]struct{}{} + validCount := 0 + for _, sig := range msg.AuthorizerSigs() { + if sig.AuthorizerID == "" || len(sig.Signature) == 0 { + continue + } + if _, dup := seen[sig.AuthorizerID]; dup { + continue + } + + authInfo, ok := allowedAuthorizers[sig.AuthorizerID] + if !ok || authInfo.PublicKey == "" { + continue + } + + // Verify signature using the appropriate algorithm + valid, err := verifySignatureByAlgorithm(authInfo.PublicKey, authInfo.Algorithm, msgBytes, sig.Signature) + if err != nil { + logger.Warn("Failed to verify authorizer signature", "authorizerID", sig.AuthorizerID, "algorithm", authInfo.Algorithm, "error", err) + continue + } + + if valid { + seen[sig.AuthorizerID] = struct{}{} + validCount++ + } + } + + if validCount < required { + return fmt.Errorf("authorization failed for %s: %d/%d valid authorizer signatures", operation, validCount, required) + } + + return nil +} + +// verifySignatureByAlgorithm verifies a signature using the specified algorithm +func verifySignatureByAlgorithm(publicKeyHex string, algorithm SignatureAlgorithm, message, signature []byte) (bool, error) { + switch algorithm { + case AlgorithmEd25519: + pubKeyBytes, err := hex.DecodeString(publicKeyHex) + if err != nil { + return false, fmt.Errorf("invalid ed25519 public key hex: %w", err) + } + if len(pubKeyBytes) != ed25519.PublicKeySize { + return false, fmt.Errorf("invalid ed25519 public key length: expected %d, got %d", ed25519.PublicKeySize, len(pubKeyBytes)) + } + return ed25519.Verify(pubKeyBytes, message, signature), nil + + case AlgorithmP256: + pubKeyBytes, err := hex.DecodeString(publicKeyHex) + if err != nil { + return false, fmt.Errorf("invalid ecdsa public key hex: %w", err) + } + + // Parse the public key + curve := elliptic.P256() + + // Assume uncompressed point format (0x04 + 32 bytes x + 32 bytes y) + if len(pubKeyBytes) == 65 && pubKeyBytes[0] == 0x04 { + x := new(big.Int).SetBytes(pubKeyBytes[1:33]) + y := new(big.Int).SetBytes(pubKeyBytes[33:65]) + _ = &ecdsa.PublicKey{Curve: curve, X: x, Y: y} // pubKey would be used for actual verification + + // Parse DER-encoded signature + // This is a simplified implementation - in production you'd want proper ASN.1 parsing + if len(signature) < 6 { + return false, fmt.Errorf("signature too short") + } + + // For now, return false - proper ECDSA signature verification would need more robust parsing + logger.Warn("ECDSA signature verification not fully implemented", "algorithm", algorithm) + return false, fmt.Errorf("ECDSA signature verification not fully implemented for %s", algorithm) + } else { + return false, fmt.Errorf("unsupported public key format for %s", algorithm) + } + + default: + return false, fmt.Errorf("unsupported signature algorithm: %s", algorithm) + } +} + func partyIDToNodeID(partyID *tss.PartyID) string { return strings.Split(string(partyID.KeyInt().Bytes()), ":")[0] } diff --git a/pkg/identity/identity_test.go b/pkg/identity/identity_test.go new file mode 100644 index 0000000..d5ce58b --- /dev/null +++ b/pkg/identity/identity_test.go @@ -0,0 +1,519 @@ +package identity + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/fystack/mpcium/pkg/types" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Helper function to create a temporary directory for tests +func createTempDir(t *testing.T) string { + tempDir, err := os.MkdirTemp("", "identity_test_*") + require.NoError(t, err) + t.Cleanup(func() { + os.RemoveAll(tempDir) + }) + return tempDir +} + +// Helper function to create test identity files +func createTestIdentityFiles(t *testing.T, identityDir string) { + // Create peers.json + peers := map[string]string{ + "node1": "node1-id", + "node2": "node2-id", + } + peersData, err := json.Marshal(peers) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(identityDir, "..", "peers.json"), peersData, 0644) + require.NoError(t, err) + + // Create identity files for each node + for nodeName, nodeID := range peers { + pubKey, _, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + identity := NodeIdentity{ + NodeName: nodeName, + NodeID: nodeID, + PublicKey: hex.EncodeToString(pubKey), + CreatedAt: "2024-01-01T00:00:00Z", + } + + identityData, err := json.Marshal(identity) + require.NoError(t, err) + + identityFile := filepath.Join(identityDir, fmt.Sprintf("%s_identity.json", nodeName)) + err = os.WriteFile(identityFile, identityData, 0644) + require.NoError(t, err) + } + + // Create private key for node1 (the test node) + _, privateKey, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + privateKeyFile := filepath.Join(identityDir, "node1_private.key") + err = os.WriteFile(privateKeyFile, []byte(hex.EncodeToString(privateKey)), 0600) + require.NoError(t, err) +} + +// Helper function to setup viper configuration +func setupViperConfig(t *testing.T, config map[string]interface{}) { + // Clear existing config + for _, key := range viper.AllKeys() { + viper.Set(key, nil) + } + + // Set new config + for key, value := range config { + viper.Set(key, value) + } +} + +func TestNewFileStore_Success(t *testing.T) { + tempDir := createTempDir(t) + identityDir := filepath.Join(tempDir, "identities") + err := os.MkdirAll(identityDir, 0750) + require.NoError(t, err) + + // Change working directory to tempDir for peers.json + oldWd, err := os.Getwd() + require.NoError(t, err) + defer os.Chdir(oldWd) + os.Chdir(tempDir) + + createTestIdentityFiles(t, identityDir) + + // Setup viper config + setupViperConfig(t, map[string]interface{}{ + "event_initiator_pubkey": "deadbeefcafebabe", + "authorization.enabled": false, + "authorization.required_authorizers": 0, + }) + + store, err := NewFileStore(identityDir, "node1", false) + require.NoError(t, err) + assert.NotNil(t, store) + + // Test that we can get public keys + pubKey, err := store.GetPublicKey("node1-id") + assert.NoError(t, err) + assert.NotNil(t, pubKey) +} + +func TestAuthorizationDisabled(t *testing.T) { + tempDir := createTempDir(t) + identityDir := filepath.Join(tempDir, "identities") + err := os.MkdirAll(identityDir, 0750) + require.NoError(t, err) + + oldWd, err := os.Getwd() + require.NoError(t, err) + defer os.Chdir(oldWd) + os.Chdir(tempDir) + + createTestIdentityFiles(t, identityDir) + + setupViperConfig(t, map[string]interface{}{ + "event_initiator_pubkey": "deadbeefcafebabe", + "authorization.enabled": false, + "authorization.required_authorizers": 2, + }) + + store, err := NewFileStore(identityDir, "node1", false) + require.NoError(t, err) + + // Create a test message + msg := &types.GenerateKeyMessage{ + WalletID: "test-wallet", + Signature: []byte("test-signature"), + } + + // Authorization should pass when disabled + err = store.AuthorizeInitiatorMessage("keygen", msg) + assert.NoError(t, err) +} + +func TestAuthorizationWithEd25519(t *testing.T) { + tempDir := createTempDir(t) + identityDir := filepath.Join(tempDir, "identities") + err := os.MkdirAll(identityDir, 0750) + require.NoError(t, err) + + oldWd, err := os.Getwd() + require.NoError(t, err) + defer os.Chdir(oldWd) + os.Chdir(tempDir) + + createTestIdentityFiles(t, identityDir) + + // Generate authorizer keys + authPubKey, authPrivKey, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + setupViperConfig(t, map[string]interface{}{ + "event_initiator_pubkey": "deadbeefcafebabe", + "authorization.enabled": true, + "authorization.required_authorizers": 1, + "authorization.authorizer_public_keys": map[string]interface{}{ + "auth1": map[string]interface{}{ + "public_key": hex.EncodeToString(authPubKey), + "algorithm": "ed25519", + }, + }, + }) + + store, err := NewFileStore(identityDir, "node1", false) + require.NoError(t, err) + + // Create a test message + msg := &types.GenerateKeyMessage{ + WalletID: "test-wallet", + Signature: []byte("test-signature"), + } + + // Get message raw bytes for signing + msgBytes, err := msg.Raw() + require.NoError(t, err) + + // Sign the message with authorizer key + authSig := ed25519.Sign(authPrivKey, msgBytes) + + // Add authorizer signature + msg.AuthorizerSignatures = []types.AuthorizerSignature{ + { + AuthorizerID: "auth1", + Signature: authSig, + }, + } + + // Authorization should pass with valid signature + err = store.AuthorizeInitiatorMessage("keygen", msg) + assert.NoError(t, err) +} + +func TestAuthorizationInsufficientSignatures(t *testing.T) { + tempDir := createTempDir(t) + identityDir := filepath.Join(tempDir, "identities") + err := os.MkdirAll(identityDir, 0750) + require.NoError(t, err) + + oldWd, err := os.Getwd() + require.NoError(t, err) + defer os.Chdir(oldWd) + os.Chdir(tempDir) + + createTestIdentityFiles(t, identityDir) + + // Generate authorizer keys + authPubKey, _, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + setupViperConfig(t, map[string]interface{}{ + "event_initiator_pubkey": "deadbeefcafebabe", + "authorization.enabled": true, + "authorization.required_authorizers": 2, // Require 2 signatures + "authorization.authorizer_public_keys": map[string]interface{}{ + "auth1": map[string]interface{}{ + "public_key": hex.EncodeToString(authPubKey), + "algorithm": "ed25519", + }, + }, + }) + + store, err := NewFileStore(identityDir, "node1", false) + require.NoError(t, err) + + // Create a test message with no authorizer signatures + msg := &types.GenerateKeyMessage{ + WalletID: "test-wallet", + Signature: []byte("test-signature"), + } + + // Authorization should fail with insufficient signatures + err = store.AuthorizeInitiatorMessage("keygen", msg) + assert.Error(t, err) + assert.Contains(t, err.Error(), "authorization failed for keygen: 0/2 valid authorizer signatures") +} + +func TestAuthorizationInvalidSignature(t *testing.T) { + tempDir := createTempDir(t) + identityDir := filepath.Join(tempDir, "identities") + err := os.MkdirAll(identityDir, 0750) + require.NoError(t, err) + + oldWd, err := os.Getwd() + require.NoError(t, err) + defer os.Chdir(oldWd) + os.Chdir(tempDir) + + createTestIdentityFiles(t, identityDir) + + // Generate authorizer keys + authPubKey, _, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + setupViperConfig(t, map[string]interface{}{ + "event_initiator_pubkey": "deadbeefcafebabe", + "authorization.enabled": true, + "authorization.required_authorizers": 1, + "authorization.authorizer_public_keys": map[string]interface{}{ + "auth1": map[string]interface{}{ + "public_key": hex.EncodeToString(authPubKey), + "algorithm": "ed25519", + }, + }, + }) + + store, err := NewFileStore(identityDir, "node1", false) + require.NoError(t, err) + + // Create a test message with invalid signature + msg := &types.GenerateKeyMessage{ + WalletID: "test-wallet", + Signature: []byte("test-signature"), + AuthorizerSignatures: []types.AuthorizerSignature{ + { + AuthorizerID: "auth1", + Signature: []byte("invalid-signature"), + }, + }, + } + + // Authorization should fail with invalid signature + err = store.AuthorizeInitiatorMessage("keygen", msg) + assert.Error(t, err) + assert.Contains(t, err.Error(), "authorization failed for keygen: 0/1 valid authorizer signatures") +} + +func TestAuthorizationBackwardCompatibility(t *testing.T) { + tempDir := createTempDir(t) + identityDir := filepath.Join(tempDir, "identities") + err := os.MkdirAll(identityDir, 0750) + require.NoError(t, err) + + oldWd, err := os.Getwd() + require.NoError(t, err) + defer os.Chdir(oldWd) + os.Chdir(tempDir) + + createTestIdentityFiles(t, identityDir) + + // Generate authorizer keys + authPubKey, authPrivKey, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + // Test old configuration format + setupViperConfig(t, map[string]interface{}{ + "event_initiator_pubkey": "deadbeefcafebabe", + "authorization.enabled": true, + "authorization.required_authorizers": 1, + "authorization.authorizers": map[string]interface{}{ + "auth1": map[string]interface{}{ + "pubkey": hex.EncodeToString(authPubKey), + "algorithm": "ed25519", + }, + }, + }) + + store, err := NewFileStore(identityDir, "node1", false) + require.NoError(t, err) + + // Create a test message + msg := &types.GenerateKeyMessage{ + WalletID: "test-wallet", + Signature: []byte("test-signature"), + } + + // Get message raw bytes for signing + msgBytes, err := msg.Raw() + require.NoError(t, err) + + // Sign the message with authorizer key + authSig := ed25519.Sign(authPrivKey, msgBytes) + + // Add authorizer signature + msg.AuthorizerSignatures = []types.AuthorizerSignature{ + { + AuthorizerID: "auth1", + Signature: authSig, + }, + } + + // Authorization should pass with backward compatible config + err = store.AuthorizeInitiatorMessage("keygen", msg) + assert.NoError(t, err) +} + +func TestVerifySignatureByAlgorithm_Ed25519(t *testing.T) { + // Generate test key pair + pubKey, privKey, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + message := []byte("test message") + signature := ed25519.Sign(privKey, message) + pubKeyHex := hex.EncodeToString(pubKey) + + // Test valid signature + valid, err := verifySignatureByAlgorithm(pubKeyHex, "ed25519", message, signature) + assert.NoError(t, err) + assert.True(t, valid) + + // Test invalid signature + valid, err = verifySignatureByAlgorithm(pubKeyHex, "ed25519", message, []byte("invalid")) + assert.NoError(t, err) + assert.False(t, valid) + + // Test invalid public key + valid, err = verifySignatureByAlgorithm("invalid-hex", "ed25519", message, signature) + assert.Error(t, err) + assert.False(t, valid) +} + +func TestVerifySignatureByAlgorithm_UnsupportedAlgorithm(t *testing.T) { + valid, err := verifySignatureByAlgorithm("deadbeef", "unknown", []byte("message"), []byte("signature")) + assert.Error(t, err) + assert.False(t, valid) + assert.Contains(t, err.Error(), "unsupported signature algorithm: unknown") +} + +func TestAuthorizationMultipleAuthorizers(t *testing.T) { + tempDir := createTempDir(t) + identityDir := filepath.Join(tempDir, "identities") + err := os.MkdirAll(identityDir, 0750) + require.NoError(t, err) + + oldWd, err := os.Getwd() + require.NoError(t, err) + defer os.Chdir(oldWd) + os.Chdir(tempDir) + + createTestIdentityFiles(t, identityDir) + + // Generate multiple authorizer keys + authPubKey1, authPrivKey1, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + authPubKey2, authPrivKey2, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + setupViperConfig(t, map[string]interface{}{ + "event_initiator_pubkey": "deadbeefcafebabe", + "authorization.enabled": true, + "authorization.required_authorizers": 2, + "authorization.authorizer_public_keys": map[string]interface{}{ + "auth1": map[string]interface{}{ + "public_key": hex.EncodeToString(authPubKey1), + "algorithm": "ed25519", + }, + "auth2": map[string]interface{}{ + "public_key": hex.EncodeToString(authPubKey2), + "algorithm": "ed25519", + }, + }, + }) + + store, err := NewFileStore(identityDir, "node1", false) + require.NoError(t, err) + + // Create a test message + msg := &types.GenerateKeyMessage{ + WalletID: "test-wallet", + Signature: []byte("test-signature"), + } + + // Get message raw bytes for signing + msgBytes, err := msg.Raw() + require.NoError(t, err) + + // Sign the message with both authorizer keys + authSig1 := ed25519.Sign(authPrivKey1, msgBytes) + authSig2 := ed25519.Sign(authPrivKey2, msgBytes) + + // Add both authorizer signatures + msg.AuthorizerSignatures = []types.AuthorizerSignature{ + { + AuthorizerID: "auth1", + Signature: authSig1, + }, + { + AuthorizerID: "auth2", + Signature: authSig2, + }, + } + + // Authorization should pass with both signatures + err = store.AuthorizeInitiatorMessage("keygen", msg) + assert.NoError(t, err) +} + +func TestAuthorizationDuplicateSignatures(t *testing.T) { + tempDir := createTempDir(t) + identityDir := filepath.Join(tempDir, "identities") + err := os.MkdirAll(identityDir, 0750) + require.NoError(t, err) + + oldWd, err := os.Getwd() + require.NoError(t, err) + defer os.Chdir(oldWd) + os.Chdir(tempDir) + + createTestIdentityFiles(t, identityDir) + + // Generate authorizer keys + authPubKey, authPrivKey, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + setupViperConfig(t, map[string]interface{}{ + "event_initiator_pubkey": "deadbeefcafebabe", + "authorization.enabled": true, + "authorization.required_authorizers": 2, + "authorization.authorizer_public_keys": map[string]interface{}{ + "auth1": map[string]interface{}{ + "public_key": hex.EncodeToString(authPubKey), + "algorithm": "ed25519", + }, + }, + }) + + store, err := NewFileStore(identityDir, "node1", false) + require.NoError(t, err) + + // Create a test message + msg := &types.GenerateKeyMessage{ + WalletID: "test-wallet", + Signature: []byte("test-signature"), + } + + // Get message raw bytes for signing + msgBytes, err := msg.Raw() + require.NoError(t, err) + + // Sign the message with authorizer key + authSig := ed25519.Sign(authPrivKey, msgBytes) + + // Add duplicate authorizer signatures (should only count once) + msg.AuthorizerSignatures = []types.AuthorizerSignature{ + { + AuthorizerID: "auth1", + Signature: authSig, + }, + { + AuthorizerID: "auth1", // Duplicate + Signature: authSig, + }, + } + + // Authorization should fail - only 1 unique signature, need 2 + err = store.AuthorizeInitiatorMessage("keygen", msg) + assert.Error(t, err) + assert.Contains(t, err.Error(), "authorization failed for keygen: 1/2 valid authorizer signatures") +} diff --git a/pkg/types/initiator_msg.go b/pkg/types/initiator_msg.go index d56b554..ddc6155 100644 --- a/pkg/types/initiator_msg.go +++ b/pkg/types/initiator_msg.go @@ -17,29 +17,40 @@ type InitiatorMessage interface { Sig() []byte // InitiatorID returns the ID whose public key we have to look up. InitiatorID() string + // AuthorizerSigs returns the optional list of authorizer signatures. + AuthorizerSigs() []AuthorizerSignature +} + +// AuthorizerSignature represents an approval signature from an external authorizer. +type AuthorizerSignature struct { + AuthorizerID string `json:"authorizer_id"` + Signature []byte `json:"signature"` } type GenerateKeyMessage struct { - WalletID string `json:"wallet_id"` - Signature []byte `json:"signature"` + WalletID string `json:"wallet_id"` + Signature []byte `json:"signature"` + AuthorizerSignatures []AuthorizerSignature `json:"authorizer_signatures,omitempty"` } type SignTxMessage struct { - KeyType KeyType `json:"key_type"` - WalletID string `json:"wallet_id"` - NetworkInternalCode string `json:"network_internal_code"` - TxID string `json:"tx_id"` - Tx []byte `json:"tx"` - Signature []byte `json:"signature"` + KeyType KeyType `json:"key_type"` + WalletID string `json:"wallet_id"` + NetworkInternalCode string `json:"network_internal_code"` + TxID string `json:"tx_id"` + Tx []byte `json:"tx"` + Signature []byte `json:"signature"` + AuthorizerSignatures []AuthorizerSignature `json:"authorizer_signatures,omitempty"` } type ResharingMessage struct { - SessionID string `json:"session_id"` - NodeIDs []string `json:"node_ids"` // new peer IDs - NewThreshold int `json:"new_threshold"` - KeyType KeyType `json:"key_type"` - WalletID string `json:"wallet_id"` - Signature []byte `json:"signature,omitempty"` + SessionID string `json:"session_id"` + NodeIDs []string `json:"node_ids"` // new peer IDs + NewThreshold int `json:"new_threshold"` + KeyType KeyType `json:"key_type"` + WalletID string `json:"wallet_id"` + Signature []byte `json:"signature,omitempty"` + AuthorizerSignatures []AuthorizerSignature `json:"authorizer_signatures,omitempty"` } func (m *SignTxMessage) Raw() ([]byte, error) { @@ -68,6 +79,10 @@ func (m *SignTxMessage) InitiatorID() string { return m.TxID } +func (m *SignTxMessage) AuthorizerSigs() []AuthorizerSignature { + return m.AuthorizerSignatures +} + func (m *GenerateKeyMessage) Raw() ([]byte, error) { return []byte(m.WalletID), nil } @@ -80,9 +95,14 @@ func (m *GenerateKeyMessage) InitiatorID() string { return m.WalletID } +func (m *GenerateKeyMessage) AuthorizerSigs() []AuthorizerSignature { + return m.AuthorizerSignatures +} + func (m *ResharingMessage) Raw() ([]byte, error) { copy := *m // create a shallow copy copy.Signature = nil // modify only the copy + copy.AuthorizerSignatures = nil return json.Marshal(©) } @@ -93,3 +113,7 @@ func (m *ResharingMessage) Sig() []byte { func (m *ResharingMessage) InitiatorID() string { return m.WalletID } + +func (m *ResharingMessage) AuthorizerSigs() []AuthorizerSignature { + return m.AuthorizerSignatures +} diff --git a/pkg/types/initiator_msg_test.go b/pkg/types/initiator_msg_test.go index 741e577..567a182 100644 --- a/pkg/types/initiator_msg_test.go +++ b/pkg/types/initiator_msg_test.go @@ -24,6 +24,20 @@ func TestGenerateKeyMessage_Raw(t *testing.T) { assert.Equal(t, []byte("test-wallet-123"), raw) } +func TestGenerateKeyMessage_Raw_IgnoresAuthorizerSigs(t *testing.T) { + msg := &GenerateKeyMessage{ + WalletID: "wallet-a", + Signature: []byte("sig"), + AuthorizerSignatures: []AuthorizerSignature{ + {AuthorizerID: "auth1", Signature: []byte("s1")}, + }, + } + + raw, err := msg.Raw() + require.NoError(t, err) + assert.Equal(t, []byte("wallet-a"), raw) +} + func TestGenerateKeyMessage_Sig(t *testing.T) { signature := []byte("test-signature-bytes") msg := &GenerateKeyMessage{ @@ -66,6 +80,24 @@ func TestSignTxMessage_Raw(t *testing.T) { assert.Contains(t, string(raw), "tx-456") } +func TestSignTxMessage_Raw_IgnoresAuthorizerSigs(t *testing.T) { + msg := &SignTxMessage{ + KeyType: KeyTypeSecp256k1, + WalletID: "wallet-123", + NetworkInternalCode: "BTC", + TxID: "tx-456", + Tx: []byte("transaction-data"), + Signature: []byte("signature-data"), + AuthorizerSignatures: []AuthorizerSignature{ + {AuthorizerID: "auth1", Signature: []byte("s1")}, + }, + } + + raw, err := msg.Raw() + require.NoError(t, err) + assert.NotContains(t, string(raw), "s1") +} + func TestSignTxMessage_Sig(t *testing.T) { signature := []byte("transaction-signature") msg := &SignTxMessage{ @@ -127,6 +159,24 @@ func TestResharingMessage_Raw(t *testing.T) { assert.Equal(t, expectedBytes, raw) } +func TestResharingMessage_Raw_IgnoresAuthorizerSigs(t *testing.T) { + msg := &ResharingMessage{ + SessionID: "sess", + NodeIDs: []string{"n1"}, + NewThreshold: 1, + KeyType: KeyTypeEd25519, + WalletID: "w", + Signature: []byte("reshare-signature"), + AuthorizerSignatures: []AuthorizerSignature{ + {AuthorizerID: "auth2", Signature: []byte("s2")}, + }, + } + + raw, err := msg.Raw() + require.NoError(t, err) + assert.NotContains(t, string(raw), "s2") +} + func TestResharingMessage_Sig(t *testing.T) { signature := []byte("resharing-signature") msg := &ResharingMessage{ @@ -180,6 +230,16 @@ func TestAllMessageTypesImplementInitiatorMessage(t *testing.T) { var _ InitiatorMessage = &ResharingMessage{} } +func TestAuthorizerSigsAccessors(t *testing.T) { + g := &GenerateKeyMessage{AuthorizerSignatures: []AuthorizerSignature{{AuthorizerID: "a"}}} + s := &SignTxMessage{AuthorizerSignatures: []AuthorizerSignature{{AuthorizerID: "b"}}} + r := &ResharingMessage{AuthorizerSignatures: []AuthorizerSignature{{AuthorizerID: "c"}}} + + assert.Equal(t, 1, len(g.AuthorizerSigs())) + assert.Equal(t, 1, len(s.AuthorizerSigs())) + assert.Equal(t, 1, len(r.AuthorizerSigs())) +} + func TestSignTxMessage_EmptyValues(t *testing.T) { msg := &SignTxMessage{ KeyType: "",