diff --git a/README.md b/README.md index ae5edf35..1269f4d4 100644 --- a/README.md +++ b/README.md @@ -409,6 +409,46 @@ The example will: 3. Use KMS for signing wallet creation events 4. Generate wallets using the MPC cluster +### Authorization (Optional) + +Mpcium can enforce an extra authorization layer on top of the initiator signature. When enabled, every keygen, signing, or reshare request must include signatures from trusted authorizers, strengthening decentralization and operational control. Authorization is disabled by default—activate it by extending your `config.yaml` (or per-node configs) with the block below: + +```yaml +authorization: + enabled: true + required_authorizers: + - authorizer1 + - authorizer2 + authorizer_public_keys: + authorizer1: + public_key: "4711ec2728feb66f223078140323e0947a70a5fa36615c21382c2a9bc9241524" + algorithm: "ed25519" + authorizer2: + public_key: "33d5b5b3973c9bd46d782bc5488ea1840188234b0cbed66153b691caafe85385" + algorithm: "p256" +``` + +- `enabled`: toggle the feature without removing existing configuration. +- `authorizer_public_keys`: register every authorizer ID and algorithm. Ed25519 keys must be 32-byte hex strings. P256 keys must use the hex-encoded PKIX (DER) bytes generated by the CLI. +- `required_authorizers`: list the IDs that must sign every request. Leave empty to accept any subset of the registered authorizers. +- When authorization is on, upstream clients must attach matching signatures in the request payload; missing or invalid entries cause the node to reject the operation. + +#### Generating Authorizers + +Use `mpcium-cli` to issue authorizer identities and keys. The command below writes a JSON identity file (containing the `public_key` to paste into `config.yaml`) and a private key file, optionally protected with Age: + +```bash +mpcium generate-authorizer \ + --name authorizer1 \ + --algorithm p256 \ + --output-dir ./authorizers \ + --encrypt +``` + +- `--algorithm` supports `ed25519` and `p256` (default: `ed25519`). +- `--encrypt` stores the private key as `.authorizer.key.age`; omit it to write the plain hex key. +- The identity JSON is saved as `.authorizer.identity.json`; copy the `public_key` into `authorization.authorizer_public_keys`. + ### Testing ## 1. Unit tests diff --git a/cmd/mpcium-cli/generate-authorizer.go b/cmd/mpcium-cli/generate-authorizer.go new file mode 100644 index 00000000..e2686940 --- /dev/null +++ b/cmd/mpcium-cli/generate-authorizer.go @@ -0,0 +1,213 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/user" + "path/filepath" + "regexp" + "runtime" + "slices" + "time" + + "filippo.io/age" + "github.com/fystack/mpcium/pkg/common/pathutil" + "github.com/fystack/mpcium/pkg/encryption" + "github.com/fystack/mpcium/pkg/types" + "github.com/urfave/cli/v3" +) + +// AuthorizerIdentity struct to store authorizer metadata +type AuthorizerIdentity struct { + Name string `json:"name"` + Algorithm string `json:"algorithm,omitempty"` + PublicKey string `json:"public_key"` + CreatedAt string `json:"created_at"` + CreatedBy string `json:"created_by"` + MachineOS string `json:"machine_os"` + MachineName string `json:"machine_name"` +} + +// validateAuthorizerName checks if the authorizer name is valid (no spaces, special characters) +func validateAuthorizerName(name string) error { + if name == "" { + return fmt.Errorf("name cannot be empty") + } + + // Only allow alphanumeric characters, hyphens, and underscores + validNamePattern := regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) + if !validNamePattern.MatchString(name) { + return fmt.Errorf("name can only contain alphanumeric characters, hyphens, and underscores (no spaces or special characters)") + } + + return nil +} + +func generateAuthorizerIdentity(ctx context.Context, c *cli.Command) error { + name := c.String("name") + outputDir := c.String("output-dir") + encrypt := c.Bool("encrypt") + overwrite := c.Bool("overwrite") + algorithm := c.String("algorithm") + + // Validate authorizer name + if err := validateAuthorizerName(name); err != nil { + return err + } + + if algorithm == "" { + algorithm = string(types.EventInitiatorKeyTypeEd25519) + } + + if !slices.Contains( + []string{string(types.EventInitiatorKeyTypeEd25519), string(types.EventInitiatorKeyTypeP256)}, + algorithm, + ) { + return fmt.Errorf("invalid algorithm: %s. Must be %s or %s", + algorithm, + types.EventInitiatorKeyTypeEd25519, + types.EventInitiatorKeyTypeP256, + ) + } + + // Create output directory if it doesn't exist + if err := os.MkdirAll(outputDir, 0750); err != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } + + // Check if files already exist before proceeding + identityPath := filepath.Join(outputDir, fmt.Sprintf("%s.authorizer.identity.json", name)) + keyPath := filepath.Join(outputDir, fmt.Sprintf("%s.authorizer.key", name)) + encKeyPath := keyPath + ".age" + + // Check for existing identity file + if _, err := os.Stat(identityPath); err == nil && !overwrite { + return fmt.Errorf( + "identity file already exists: %s (use --overwrite to force)", + identityPath, + ) + } + + // Check for existing key files + if _, err := os.Stat(keyPath); err == nil && !overwrite { + return fmt.Errorf("key file already exists: %s (use --overwrite to force)", keyPath) + } + + if encrypt { + if _, err := os.Stat(encKeyPath); err == nil && !overwrite { + return fmt.Errorf( + "encrypted key file already exists: %s (use --overwrite to force)", + encKeyPath, + ) + } + } + + // Generate keys based on algorithm + var keyData encryption.KeyData + var err error + + if algorithm == string(types.EventInitiatorKeyTypeEd25519) { + keyData, err = encryption.GenerateEd25519Keys() + } else if algorithm == string(types.EventInitiatorKeyTypeP256) { + keyData, err = encryption.GenerateP256Keys() + } + + if err != nil { + return fmt.Errorf("failed to generate %s keys: %w", algorithm, err) + } + + // Get current user + currentUser, err := user.Current() + if err != nil { + return fmt.Errorf("failed to get current user: %w", err) + } + + // Get hostname + hostname, err := os.Hostname() + if err != nil { + hostname = "unknown" + } + + // Create Identity object + identity := AuthorizerIdentity{ + Name: name, + Algorithm: algorithm, + PublicKey: keyData.PublicKeyHex, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + CreatedBy: currentUser.Username, + MachineOS: runtime.GOOS, + MachineName: hostname, + } + + // Save identity JSON + identityBytes, err := json.MarshalIndent(identity, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal identity JSON: %w", err) + } + + if err := os.WriteFile(identityPath, identityBytes, 0600); err != nil { + return fmt.Errorf("failed to save identity file: %w", err) + } + + // Handle private key (with optional encryption) + if encrypt { + // Use requestPassword function instead of inline password handling + passphrase, err := requestPassword() + if err != nil { + return err + } + + // Create encrypted key file + encKeyPath := keyPath + ".age" + + // Validate the encrypted key path for security + if err := pathutil.ValidateFilePath(encKeyPath); err != nil { + return fmt.Errorf("invalid encrypted key file path: %w", err) + } + + outFile, err := os.Create(encKeyPath) + if err != nil { + return fmt.Errorf("failed to create encrypted private key file: %w", err) + } + defer outFile.Close() + + // Set up age encryption + recipient, err := age.NewScryptRecipient(passphrase) + if err != nil { + return fmt.Errorf("failed to create scrypt recipient: %w", err) + } + + identityWriter, err := age.Encrypt(outFile, recipient) + if err != nil { + return fmt.Errorf("failed to create age encryption writer: %w", err) + } + + // Write the encrypted private key + if _, err := identityWriter.Write([]byte(keyData.PrivateKeyHex)); err != nil { + return fmt.Errorf("failed to write encrypted private key: %w", err) + } + + if err := identityWriter.Close(); err != nil { + return fmt.Errorf("failed to finalize age encryption: %w", err) + } + + fmt.Println("✅ Successfully generated authorizer identity:") + fmt.Println("- Encrypted Private Key:", encKeyPath) + fmt.Println("- Identity JSON:", identityPath) + return nil + } else { + fmt.Println("WARNING: You are generating the private key without encryption.") + fmt.Println("This is less secure. Consider using --encrypt flag for better security.") + + if err := os.WriteFile(keyPath, []byte(keyData.PrivateKeyHex), 0600); err != nil { + return fmt.Errorf("failed to save private key: %w", err) + } + } + + fmt.Println("✅ Successfully generated authorizer identity:") + fmt.Println("- Private Key:", keyPath) + fmt.Println("- Identity JSON:", identityPath) + return nil +} diff --git a/cmd/mpcium-cli/main.go b/cmd/mpcium-cli/main.go index cc8cabe2..8ccc1031 100644 --- a/cmd/mpcium-cli/main.go +++ b/cmd/mpcium-cli/main.go @@ -139,6 +139,43 @@ func main() { }, Action: generateInitiatorIdentity, }, + { + Name: "generate-authorizer", + Usage: "Generate identity files for an authorizer node", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "name", + Aliases: []string{"n"}, + Usage: "Name for the authorizer (alphanumeric, hyphens, underscores only)", + Required: true, + }, + &cli.StringFlag{ + Name: "output-dir", + Aliases: []string{"o"}, + Value: ".", + Usage: "Output directory for identity files", + }, + &cli.BoolFlag{ + Name: "encrypt", + Aliases: []string{"e"}, + Value: false, + Usage: "Encrypt private key with Age (recommended for production)", + }, + &cli.BoolFlag{ + Name: "overwrite", + Aliases: []string{"f"}, + Value: false, + Usage: "Overwrite identity files if they already exist", + }, + &cli.StringFlag{ + Name: "algorithm", + Aliases: []string{"a"}, + Value: "ed25519", + Usage: "Algorithm to use for key generation (ed25519,p256)", + }, + }, + Action: generateAuthorizerIdentity, + }, { Name: "recover", Usage: "Recover database from encrypted backup files", diff --git a/config.yaml.template b/config.yaml.template index 35be6927..d868f563 100644 --- a/config.yaml.template +++ b/config.yaml.template @@ -15,3 +15,19 @@ backup_dir: backups max_concurrent_keygen: 2 max_concurrent_signing: 10 session_warm_up_delay_ms: 100 + +# Authorization (optional) +# authorization: +# enabled: true +# required_authorizers: +# - authorizer1 +# - authorizer2 +# # Authorizer public keys configuration (applies to all operations: keygen, signing, reshare) +# authorizer_public_keys: +# # Example: +# authorizer1: +# public_key: "4711ec2728feb66f223078140323e0947a70a5fa36615c21382c2a9bc9241524" +# algorithm: "ed25519" +# authorizer2: +# public_key: "33d5b5b3973c9bd46d782bc5488ea1840188234b0cbed66153b691caafe85385" +# algorithm: "ed25519" diff --git a/e2e/go.mod b/e2e/go.mod index 4112bb34..7b6e43b1 100644 --- a/e2e/go.mod +++ b/e2e/go.mod @@ -1,12 +1,14 @@ module github.com/fystack/mpcium/e2e -go 1.23.0 +go 1.23.8 + +toolchain go1.24.9 require ( github.com/dgraph-io/badger/v4 v4.7.0 github.com/fystack/mpcium v0.0.0-00010101000000-000000000000 github.com/google/uuid v1.6.0 - github.com/hashicorp/consul/api v1.26.1 + github.com/hashicorp/consul/api v1.32.1 github.com/nats-io/nats.go v1.31.0 github.com/stretchr/testify v1.10.0 gopkg.in/yaml.v2 v2.4.0 @@ -91,7 +93,7 @@ require ( 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-20240112132812-db7319d0e0e3 // indirect + golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect golang.org/x/net v0.39.0 // indirect golang.org/x/sys v0.33.0 // indirect golang.org/x/term v0.31.0 // indirect diff --git a/e2e/go.sum b/e2e/go.sum index 804a2c4e..62e53389 100644 --- a/e2e/go.sum +++ b/e2e/go.sum @@ -158,10 +158,10 @@ github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/ github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hashicorp/consul/api v1.26.1 h1:5oSXOO5fboPZeW5SN+TdGFP/BILDgBm19OrPZ/pICIM= -github.com/hashicorp/consul/api v1.26.1/go.mod h1:B4sQTeaSO16NtynqrAdwOlahJ7IUDZM9cj2420xYL8A= -github.com/hashicorp/consul/sdk v0.15.0 h1:2qK9nDrr4tiJKRoxPGhm6B7xJjLVIQqkjiab2M4aKjU= -github.com/hashicorp/consul/sdk v0.15.0/go.mod h1:r/OmRRPbHOe0yxNahLw7G9x5WG17E1BIECMtCjcPSNo= +github.com/hashicorp/consul/api v1.32.1 h1:0+osr/3t/aZNAdJX558crU3PEjVrG4x6715aZHRgceE= +github.com/hashicorp/consul/api v1.32.1/go.mod h1:mXUWLnxftwTmDv4W3lzxYCPD199iNLLUyLfLGFJbtl4= +github.com/hashicorp/consul/sdk v0.16.1 h1:V8TxTnImoPD5cj0U9Spl0TUxcytjcbbJeADFF07KdHg= +github.com/hashicorp/consul/sdk v0.16.1/go.mod h1:fSXvwxB2hmh1FMZCNl6PwX0Q/1wdWtHJcZ7Ea5tns0s= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -402,8 +402,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= -golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3 h1:hNQpMuAJe5CtcUqCXaWga3FHu+kQvCqcsoVaQgSV60o= -golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= diff --git a/examples/authorizers/generate/main.go b/examples/authorizers/generate/main.go new file mode 100644 index 00000000..0cfec028 --- /dev/null +++ b/examples/authorizers/generate/main.go @@ -0,0 +1,245 @@ +package main + +import ( + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "os" + "os/signal" + "slices" + "sync" + "sync/atomic" + "syscall" + "time" + + "github.com/fystack/mpcium/pkg/client" + "github.com/fystack/mpcium/pkg/config" + "github.com/fystack/mpcium/pkg/event" + "github.com/fystack/mpcium/pkg/logger" + "github.com/fystack/mpcium/pkg/types" + "github.com/google/uuid" + "github.com/nats-io/nats.go" + "github.com/spf13/viper" +) + +// Required authorizer names (hardcoded as requested) +var requiredAuthorizers = []string{"authorizer1", "authorizer2"} + +func main() { + const environment = "development" + numWallets := flag.Int("n", 1, "Number of wallets to generate") + + flag.Parse() + + config.InitViperConfig("") + logger.Init(environment, false) + + algorithm := viper.GetString("event_initiator_algorithm") + if algorithm == "" { + algorithm = string(types.EventInitiatorKeyTypeEd25519) + } + + if !slices.Contains( + []string{ + string(types.EventInitiatorKeyTypeEd25519), + string(types.EventInitiatorKeyTypeP256), + }, + algorithm, + ) { + logger.Fatal( + fmt.Sprintf( + "invalid algorithm: %s. Must be %s or %s", + algorithm, + types.EventInitiatorKeyTypeEd25519, + types.EventInitiatorKeyTypeP256, + ), + nil, + ) + } + + natsURL := viper.GetString("nats.url") + natsConn, err := nats.Connect(natsURL) + if err != nil { + logger.Fatal("Failed to connect to NATS", err) + } + defer natsConn.Drain() + defer natsConn.Close() + + localSigner, err := client.NewLocalSigner(types.EventInitiatorKeyType(algorithm), client.LocalSignerOptions{ + KeyPath: "./event_initiator.key", + }) + if err != nil { + logger.Fatal("Failed to create local signer", err) + } + + // Load authorizer signers + authorizerSigners := make(map[string]client.Signer) + for _, authorizerID := range requiredAuthorizers { + keyPath := fmt.Sprintf("./%s.authorizer.key", authorizerID) + signer, err := client.NewLocalSigner(types.EventInitiatorKeyTypeEd25519, client.LocalSignerOptions{ + KeyPath: keyPath, + }) + if err != nil { + logger.Fatal(fmt.Sprintf("Failed to create authorizer signer for %s", authorizerID), err) + } + authorizerSigners[authorizerID] = signer + } + + mpcClient := client.NewMPCClient(client.Options{ + NatsConn: natsConn, + Signer: localSigner, + }) + + var walletStartTimes sync.Map + var walletIDs []string + var walletIDsMu sync.Mutex + var wg sync.WaitGroup + var completedCount int32 + + startAll := time.Now() + + // STEP 1: Pre-generate wallet IDs and store start times + for i := 0; i < *numWallets; i++ { + walletID := uuid.New().String() + walletStartTimes.Store(walletID, time.Now()) + + walletIDsMu.Lock() + walletIDs = append(walletIDs, walletID) + walletIDsMu.Unlock() + } + + // STEP 2: Register the result handler AFTER all walletIDs are stored + err = mpcClient.OnWalletCreationResult(func(event event.KeygenResultEvent) { + logger.Info("Received wallet creation result", "event", event) + now := time.Now() + startTimeAny, ok := walletStartTimes.Load(event.WalletID) + if ok { + startTime := startTimeAny.(time.Time) + duration := now.Sub(startTime).Seconds() + accumulated := now.Sub(startAll).Seconds() + countSoFar := atomic.AddInt32(&completedCount, 1) + + logger.Info("Wallet created", + "walletID", event.WalletID, + "duration_seconds", fmt.Sprintf("%.3f", duration), + "accumulated_time_seconds", fmt.Sprintf("%.3f", accumulated), + "count_so_far", countSoFar, + ) + + walletStartTimes.Delete(event.WalletID) + } else { + logger.Warn("Received wallet result but no start time found", "walletID", event.WalletID) + } + wg.Done() + }) + if err != nil { + logger.Fatal("Failed to subscribe to wallet-creation results", err) + } + + // STEP 3: Create wallets with authorizer signatures + for _, walletID := range walletIDs { + wg.Add(1) // Add to WaitGroup BEFORE attempting to create wallet + + // Create a temporary message to get the initiator signature + tempMsg := &types.GenerateKeyMessage{ + WalletID: walletID, + } + + // Sign with initiator + raw, err := tempMsg.Raw() + if err != nil { + logger.Error("Failed to get raw message", err) + walletStartTimes.Delete(walletID) + wg.Done() + continue + } + + signature, err := localSigner.Sign(raw) + if err != nil { + logger.Error("Failed to sign message", err) + walletStartTimes.Delete(walletID) + wg.Done() + continue + } + tempMsg.Signature = signature + + // Collect authorizer signatures + authorizerRaw, err := types.ComposeAuthorizerRaw(tempMsg) + if err != nil { + logger.Error("Failed to compose authorizer raw data", err) + walletStartTimes.Delete(walletID) + wg.Done() + continue + } + + var authorizerSignatures []types.AuthorizerSignature + for _, authorizerID := range requiredAuthorizers { + signer := authorizerSigners[authorizerID] + authSig, err := signer.Sign(authorizerRaw) + if err != nil { + logger.Error(fmt.Sprintf("Failed to sign with authorizer %s", authorizerID), err) + continue + } + + authorizerSignatures = append(authorizerSignatures, types.AuthorizerSignature{ + AuthorizerID: authorizerID, + Signature: authSig, + }) + + logger.Info("Added authorizer signature", + "authorizer", authorizerID, + "signature", hex.EncodeToString(authSig), + ) + } + + // Use the new CreateWalletWithAuthorizers method + if err := mpcClient.CreateWalletWithAuthorizers(walletID, authorizerSignatures); err != nil { + logger.Error("CreateWallet failed", err) + walletStartTimes.Delete(walletID) + wg.Done() + continue + } + + logger.Info("CreateWallet sent with authorizers, awaiting result...", + "walletID", walletID, + "authorizers", requiredAuthorizers, + ) + } + + // Wait until all wallet creations complete + go func() { + wg.Wait() + totalDuration := time.Since(startAll).Seconds() + logger.Info( + "All wallets generated", + "count", + completedCount, + "total_duration_seconds", + fmt.Sprintf("%.3f", totalDuration), + ) + + // Save wallet IDs to wallets.json + walletIDsMu.Lock() + data, err := json.MarshalIndent(walletIDs, "", " ") + walletIDsMu.Unlock() + if err != nil { + logger.Error("Failed to marshal wallet IDs", err) + } else { + err = os.WriteFile("wallets.json", data, 0600) + if err != nil { + logger.Error("Failed to write wallets.json", err) + } else { + logger.Info("wallets.json written", "count", len(walletIDs)) + } + } + os.Exit(0) + }() + + // Block on SIGINT/SIGTERM (Ctrl+C etc.) + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) + <-stop + + fmt.Println("Shutting down.") +} diff --git a/examples/authorizers/sign/main.go b/examples/authorizers/sign/main.go new file mode 100644 index 00000000..6f5d3ef5 --- /dev/null +++ b/examples/authorizers/sign/main.go @@ -0,0 +1,159 @@ +package main + +import ( + "encoding/hex" + "fmt" + "os" + "os/signal" + "slices" + "syscall" + + "github.com/fystack/mpcium/pkg/client" + "github.com/fystack/mpcium/pkg/config" + "github.com/fystack/mpcium/pkg/event" + "github.com/fystack/mpcium/pkg/logger" + "github.com/fystack/mpcium/pkg/types" + "github.com/google/uuid" + "github.com/nats-io/nats.go" + "github.com/spf13/viper" +) + +// Required authorizer names (hardcoded as requested) +var requiredAuthorizers = []string{"authorizer1", "authorizer2"} + +func main() { + const environment = "dev" + config.InitViperConfig("") + logger.Init(environment, true) + + algorithm := viper.GetString("event_initiator_algorithm") + if algorithm == "" { + algorithm = string(types.EventInitiatorKeyTypeEd25519) + } + + // Validate algorithm + if !slices.Contains( + []string{ + string(types.EventInitiatorKeyTypeEd25519), + string(types.EventInitiatorKeyTypeP256), + }, + algorithm, + ) { + logger.Fatal( + fmt.Sprintf( + "invalid algorithm: %s. Must be %s or %s", + algorithm, + types.EventInitiatorKeyTypeEd25519, + types.EventInitiatorKeyTypeP256, + ), + nil, + ) + } + natsURL := viper.GetString("nats.url") + natsConn, err := nats.Connect(natsURL) + if err != nil { + logger.Fatal("Failed to connect to NATS", err) + } + defer natsConn.Drain() + defer natsConn.Close() + + localSigner, err := client.NewLocalSigner(types.EventInitiatorKeyType(algorithm), client.LocalSignerOptions{ + KeyPath: "./event_initiator.key", + }) + if err != nil { + logger.Fatal("Failed to create local signer", err) + } + + // Load authorizer signers + authorizerSigners := make(map[string]client.Signer) + for _, authorizerID := range requiredAuthorizers { + keyPath := fmt.Sprintf("./%s.authorizer.key", authorizerID) + signer, err := client.NewLocalSigner(types.EventInitiatorKeyTypeEd25519, client.LocalSignerOptions{ + KeyPath: keyPath, + }) + if err != nil { + logger.Fatal(fmt.Sprintf("Failed to create authorizer signer for %s", authorizerID), err) + } + authorizerSigners[authorizerID] = signer + } + + mpcClient := client.NewMPCClient(client.Options{ + NatsConn: natsConn, + Signer: localSigner, + }) + + // Create a signing request with authorizers + txID := uuid.New().String() + dummyTx := []byte("deadbeef") // replace with real transaction bytes + + txMsg := &types.SignTxMessage{ + KeyType: types.KeyTypeEd25519, + WalletID: "ad24f678-b04b-4149-bcf6-bf9c90df8e63", // Use the generated wallet ID + NetworkInternalCode: "solana-devnet", + TxID: txID, + Tx: dummyTx, + } + + // First, we need to sign the message with the initiator to get the signature + raw, err := txMsg.Raw() + if err != nil { + logger.Fatal("Failed to get raw message", err) + } + + signature, err := localSigner.Sign(raw) + if err != nil { + logger.Fatal("Failed to sign message", err) + } + txMsg.Signature = signature + + // Collect authorizer signatures + authorizerRaw, err := types.ComposeAuthorizerRaw(txMsg) + if err != nil { + logger.Fatal("Failed to compose authorizer raw data", err) + } + + for _, authorizerID := range requiredAuthorizers { + signer := authorizerSigners[authorizerID] + authSig, err := signer.Sign(authorizerRaw) + if err != nil { + logger.Fatal(fmt.Sprintf("Failed to sign with authorizer %s", authorizerID), err) + } + + txMsg.AuthorizerSignatures = append(txMsg.AuthorizerSignatures, types.AuthorizerSignature{ + AuthorizerID: authorizerID, + Signature: authSig, + }) + + logger.Info("Added authorizer signature", + "authorizer", authorizerID, + "signature", hex.EncodeToString(authSig), + ) + } + + // Send the signing request with authorizer signatures + err = mpcClient.SignTransaction(txMsg) + if err != nil { + logger.Fatal("SignTransaction failed", err) + } + logger.Info("SignTransaction sent with authorizers, awaiting result...", + "txID", txID, + "authorizers", requiredAuthorizers, + ) + + // Listen for signing results + err = mpcClient.OnSignResult(func(evt event.SigningResultEvent) { + logger.Info("Signing result received", + "txID", evt.TxID, + "signature", fmt.Sprintf("%x", evt.Signature), + ) + }) + if err != nil { + logger.Fatal("Failed to subscribe to OnSignResult", err) + } + + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) + <-stop + + fmt.Println("Shutting down.") +} diff --git a/pkg/client/client.go b/pkg/client/client.go index 3121bdbd..2ed4dc41 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -20,6 +20,7 @@ const ( type MPCClient interface { CreateWallet(walletID string) error + CreateWalletWithAuthorizers(walletID string, authorizerSignatures []types.AuthorizerSignature) error OnWalletCreationResult(callback func(event event.KeygenResultEvent)) error SignTransaction(msg *types.SignTxMessage) error @@ -104,9 +105,15 @@ func NewMPCClient(opts Options) MPCClient { // CreateWallet generates a GenerateKeyMessage, signs it, and publishes it. func (c *mpcClient) CreateWallet(walletID string) error { + return c.CreateWalletWithAuthorizers(walletID, nil) +} + +// CreateWalletWithAuthorizers generates a GenerateKeyMessage with authorizer signatures, signs it, and publishes it. +func (c *mpcClient) CreateWalletWithAuthorizers(walletID string, authorizerSignatures []types.AuthorizerSignature) error { // build the message msg := &types.GenerateKeyMessage{ - WalletID: walletID, + WalletID: walletID, + AuthorizerSignatures: authorizerSignatures, } // compute the canonical raw bytes raw, err := msg.Raw() diff --git a/pkg/event/types.go b/pkg/event/types.go index ac26ca94..8bcb8116 100644 --- a/pkg/event/types.go +++ b/pkg/event/types.go @@ -57,6 +57,10 @@ const ( ErrorCodePreParamsGeneration ErrorCode = "ERROR_PRE_PARAMS_GENERATION" ErrorCodeTSSPartyCreation ErrorCode = "ERROR_TSS_PARTY_CREATION" + // Authorization errors + ErrorCodeMissingAuthorizerSignature ErrorCode = "ERROR_MISSING_AUTHORIZER_SIGNATURE" + ErrorCodeInvalidAuthorizerSignature ErrorCode = "ERROR_INVALID_AUTHORIZER_SIGNATURE" + // Data serialization errors ErrorCodeMarshalFailure ErrorCode = "ERROR_MARSHAL_FAILURE" ErrorCodeUnmarshalFailure ErrorCode = "ERROR_UNMARSHAL_FAILURE" @@ -105,6 +109,10 @@ func GetErrorCodeFromError(err error) ErrorCode { // Check for specific error patterns switch { + case contains(errStr, "missing required authorizer signature"): + return ErrorCodeMissingAuthorizerSignature + case contains(errStr, "authorizer", "verification failed"): + return ErrorCodeInvalidAuthorizerSignature case contains(errStr, "validation"): return ErrorCodeMsgValidation case contains(errStr, "timeout", "timed out"): diff --git a/pkg/eventconsumer/event_consumer.go b/pkg/eventconsumer/event_consumer.go index 691c712d..af21881c 100644 --- a/pkg/eventconsumer/event_consumer.go +++ b/pkg/eventconsumer/event_consumer.go @@ -166,6 +166,12 @@ func (ec *eventConsumer) handleKeyGenEvent(natMsg *nats.Msg) { return } + if err := ec.identityStore.AuthorizeInitiatorMessage(&msg); err != nil { + logger.Error("Failed to authorize initiator message", err) + ec.handleKeygenSessionError(msg.WalletID, err, "Failed to authorize initiator message", natMsg) + return + } + walletID := msg.WalletID ecdsaSession, err := ec.node.CreateKeyGenSession(mpc.SessionTypeECDSA, walletID, ec.mpcThreshold, ec.genKeyResultQueue) if err != nil { @@ -353,6 +359,12 @@ func (ec *eventConsumer) handleSigningEvent(natMsg *nats.Msg) { return } + err = ec.identityStore.AuthorizeInitiatorMessage(&msg) + if err != nil { + logger.Error("Failed to authorize initiator message", err) + return + } + logger.Info( "Received signing event", "waleltID", @@ -590,6 +602,12 @@ func (ec *eventConsumer) consumeReshareEvent() error { return } + if err := ec.identityStore.AuthorizeInitiatorMessage(&msg); err != nil { + logger.Error("Failed to authorize initiator message", err) + ec.handleReshareSessionError(msg.WalletID, msg.KeyType, msg.NewThreshold, err, "Failed to authorize initiator message", natMsg) + return + } + walletID := msg.WalletID keyType := msg.KeyType diff --git a/pkg/identity/identity.go b/pkg/identity/identity.go index 2d2f1eee..45d72234 100644 --- a/pkg/identity/identity.go +++ b/pkg/identity/identity.go @@ -39,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(msg types.InitiatorMessage) error SignMessage(msg *types.TssMessage) ([]byte, error) VerifyMessage(msg *types.TssMessage) error @@ -61,6 +62,34 @@ type InitiatorKey struct { P256 *ecdsa.PublicKey } +// SignatureAlgorithm represents supported signature algorithms +type SignatureAlgorithm string + +const ( + AlgorithmEd25519 SignatureAlgorithm = "ed25519" + AlgorithmP256 SignatureAlgorithm = "p256" +) + +type AuthorizerID string + +// AuthorizerPublicKey represents a single authorizer with their public key and algorithm +type AuthorizerPublicKey struct { + PublicKey string `json:"public_key" mapstructure:"public_key"` + Algorithm SignatureAlgorithm `json:"algorithm" mapstructure:"algorithm"` +} + +type AuthorizationConfig struct { + Enabled bool `mapstructure:"enabled"` + RequiredAuthorizers []AuthorizerID `mapstructure:"required_authorizers"` + AuthorizerPublicKeys map[AuthorizerID]AuthorizerPublicKey `mapstructure:"authorizer_public_keys"` +} + +// AuthorizerConfigEntry represents the raw configuration for an authorizer +type AuthorizerConfigEntry struct { + PublicKey string `mapstructure:"public_key"` + Algorithm string `mapstructure:"algorithm"` +} + // fileStore implements the Store interface using the filesystem type fileStore struct { identityDir string @@ -70,9 +99,11 @@ type fileStore struct { publicKeys map[string][]byte mu sync.RWMutex - privateKey []byte - initiatorKey *InitiatorKey - symmetricKeys map[string][]byte + privateKey []byte + initiatorKey *InitiatorKey + symmetricKeys map[string][]byte + authConfig AuthorizationConfig + cachedAuthorizerKeys map[AuthorizerID]any // ed25519.PublicKey or *ecdsa.PublicKey } // NewFileStore creates a new identity store @@ -108,12 +139,13 @@ func NewFileStore(identityDir, nodeName string, decrypt bool, agePasswordFile st } store := &fileStore{ - identityDir: identityDir, - currentNodeName: nodeName, - publicKeys: make(map[string][]byte), - privateKey: privateKey, - initiatorKey: initiatorKey, - symmetricKeys: make(map[string][]byte), + identityDir: identityDir, + currentNodeName: nodeName, + publicKeys: make(map[string][]byte), + privateKey: privateKey, + initiatorKey: initiatorKey, + symmetricKeys: make(map[string][]byte), + cachedAuthorizerKeys: make(map[AuthorizerID]any), } // Check that each node in peers.json has an identity file @@ -157,6 +189,11 @@ func NewFileStore(identityDir, nodeName string, decrypt bool, agePasswordFile st store.publicKeys[identity.NodeID] = key } + err = store.loadAuthorizationConfig() + if err != nil { + return nil, err + } + return store, nil } @@ -208,6 +245,42 @@ func loadInitiatorKeys() (*InitiatorKey, error) { return initiatorKey, nil } +// loadAuthorizationConfig loads and caches the authorization configuration +func (s *fileStore) loadAuthorizationConfig() error { + var authConfig AuthorizationConfig + if err := viper.UnmarshalKey("authorization", &authConfig); err != nil { + return fmt.Errorf("failed to unmarshal authorization config: %w", err) + } + s.authConfig = authConfig + if !authConfig.Enabled { + return nil + } + + for id, key := range authConfig.AuthorizerPublicKeys { + switch key.Algorithm { + case AlgorithmEd25519: + pubKeyBytes, err := encryption.ParseEd25519PublicKeyFromHex(key.PublicKey) + if err != nil { + logger.Fatal("Invalid authorization config", fmt.Errorf("invalid ed25519 public key for authorizer %s: %w", id, err)) + } + s.cachedAuthorizerKeys[id] = ed25519.PublicKey(pubKeyBytes) + + case AlgorithmP256: + pubKey, err := encryption.ParseP256PublicKeyFromHex(key.PublicKey) + if err != nil { + logger.Fatal("Invalid authorization config", fmt.Errorf("invalid P256 public key for authorizer %s: %w", id, err)) + } + s.cachedAuthorizerKeys[id] = pubKey + + default: + logger.Fatal("Invalid authorization config", fmt.Errorf("unknown algorithm %s for authorizer %s", key.Algorithm, id)) + } + } + + logger.Info("Loaded authorization config", "authConfig", authConfig) + return nil +} + // loadEd25519InitiatorKey loads Ed25519 initiator public key func loadEd25519InitiatorKey() ([]byte, error) { pubKeyHex := viper.GetString("event_initiator_pubkey") @@ -503,6 +576,82 @@ func (s *fileStore) VerifyInitiatorMessage(msg types.InitiatorMessage) error { return fmt.Errorf("unsupported algorithm: %s", algo) } +func (s *fileStore) AuthorizeInitiatorMessage(msg types.InitiatorMessage) error { + if !s.authConfig.Enabled { + return nil + } + + sigs := msg.GetAuthorizerSignatures() + if len(s.authConfig.RequiredAuthorizers) > 0 { + // Build a map of provided signatures for quick lookup + providedSigs := make(map[AuthorizerID]types.AuthorizerSignature) + for _, sig := range sigs { + providedSigs[AuthorizerID(sig.AuthorizerID)] = sig + } + + // Verify that ALL required authorizers have provided signatures + for _, requiredID := range s.authConfig.RequiredAuthorizers { + if _, ok := providedSigs[requiredID]; !ok { + return fmt.Errorf("missing required authorizer signature: %s", requiredID) + } + } + } + + // If no signatures provided but none required, that's okay + if len(sigs) == 0 { + return nil + } + + authorizerRaw, err := types.ComposeAuthorizerRaw(msg) + if err != nil { + return fmt.Errorf("failed to compose authorizer raw: %w", err) + } + + // Verify each authorizer signature + for _, sig := range sigs { + if err := s.verifyAuthorizerSignature(authorizerRaw, sig); err != nil { + return fmt.Errorf("authorizer %s verification failed: %w", sig.AuthorizerID, err) + } + } + + return nil +} + +func (s *fileStore) verifyAuthorizerSignature(raw []byte, sig types.AuthorizerSignature) error { + authPub, ok := s.cachedAuthorizerKeys[AuthorizerID(sig.AuthorizerID)] + if !ok { + return fmt.Errorf("authorizer %s not found in cache", sig.AuthorizerID) + } + + keyMeta := s.authConfig.AuthorizerPublicKeys[AuthorizerID(sig.AuthorizerID)] + switch keyMeta.Algorithm { + case AlgorithmEd25519: + pub := authPub.(ed25519.PublicKey) + if !ed25519.Verify(pub, raw, sig.Signature) { + return fmt.Errorf("ed25519 verification failed for %s", sig.AuthorizerID) + } + + case AlgorithmP256: + pub := authPub.(*ecdsa.PublicKey) + if err := encryption.VerifyP256Signature(pub, raw, sig.Signature); err != nil { + return fmt.Errorf("p256 verification failed for %s: %w", sig.AuthorizerID, err) + } + + default: + return fmt.Errorf("unsupported algorithm %q for authorizer %s", keyMeta.Algorithm, sig.AuthorizerID) + } + + return nil +} + +func (s *fileStore) getAuthorizerPublicKey(authorizerID string) (*AuthorizerPublicKey, error) { + publicKey, ok := s.authConfig.AuthorizerPublicKeys[AuthorizerID(authorizerID)] + if !ok { + return nil, fmt.Errorf("unknown authorizer ID: %s", authorizerID) + } + return &publicKey, nil +} + func (s *fileStore) verifyEd25519(msg types.InitiatorMessage) error { msgBytes, err := msg.Raw() if err != nil { diff --git a/pkg/identity/identity_test.go b/pkg/identity/identity_test.go new file mode 100644 index 00000000..99d4f68e --- /dev/null +++ b/pkg/identity/identity_test.go @@ -0,0 +1,768 @@ +package identity + +import ( + "crypto/ecdsa" + "crypto/ed25519" + "crypto/rand" + "encoding/hex" + "strings" + "testing" + + "github.com/fystack/mpcium/pkg/encryption" + "github.com/fystack/mpcium/pkg/types" +) + +// Mock InitiatorMessage for testing +type mockInitiatorMessage struct { + raw []byte + sig []byte + initiatorID string + authorizerSignatures []types.AuthorizerSignature +} + +func (m *mockInitiatorMessage) Raw() ([]byte, error) { + return m.raw, nil +} + +func (m *mockInitiatorMessage) Sig() []byte { + return m.sig +} + +func (m *mockInitiatorMessage) InitiatorID() string { + return m.initiatorID +} + +func (m *mockInitiatorMessage) GetAuthorizerSignatures() []types.AuthorizerSignature { + return m.authorizerSignatures +} + +// Test helper functions +func generateTestEd25519Key() (ed25519.PublicKey, ed25519.PrivateKey, string) { + pubKey, privKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + panic(err) + } + pubKeyHex := hex.EncodeToString(pubKey) + return pubKey, privKey, pubKeyHex +} + +func generateTestP256Key() (*ecdsa.PublicKey, *ecdsa.PrivateKey, string, error) { + keyData, err := encryption.GenerateP256Keys() + if err != nil { + return nil, nil, "", err + } + + privKeyBytes, err := hex.DecodeString(keyData.PrivateKeyHex) + if err != nil { + return nil, nil, "", err + } + + privKey, err := encryption.ParseP256PrivateKey(privKeyBytes) + if err != nil { + return nil, nil, "", err + } + + return &privKey.PublicKey, privKey, keyData.PublicKeyHex, nil +} + +func createTestStore(authEnabled bool, authorizerKeys map[AuthorizerID]AuthorizerPublicKey) *fileStore { + cachedKeys := make(map[AuthorizerID]any) + + if authEnabled { + for id, keyMeta := range authorizerKeys { + switch keyMeta.Algorithm { + case AlgorithmEd25519: + pubKeyBytes, err := encryption.ParseEd25519PublicKeyFromHex(keyMeta.PublicKey) + if err != nil { + panic(err) + } + cachedKeys[id] = ed25519.PublicKey(pubKeyBytes) + + case AlgorithmP256: + pubKey, err := encryption.ParseP256PublicKeyFromHex(keyMeta.PublicKey) + if err != nil { + panic(err) + } + cachedKeys[id] = pubKey + } + } + } + + return &fileStore{ + authConfig: AuthorizationConfig{ + Enabled: authEnabled, + RequiredAuthorizers: []AuthorizerID{}, + AuthorizerPublicKeys: authorizerKeys, + }, + cachedAuthorizerKeys: cachedKeys, + } +} + +// TestAuthorizeInitiatorMessage_Disabled tests authorization when it's disabled +func TestAuthorizeInitiatorMessage_Disabled(t *testing.T) { + store := createTestStore(false, nil) + + msg := &mockInitiatorMessage{ + raw: []byte("test message"), + sig: []byte("fake signature"), + initiatorID: "test-id", + authorizerSignatures: []types.AuthorizerSignature{ + {AuthorizerID: "auth1", Signature: []byte("fake sig")}, + }, + } + + err := store.AuthorizeInitiatorMessage(msg) + if err != nil { + t.Errorf("AuthorizeInitiatorMessage() with disabled auth should return nil, got error: %v", err) + } +} + +// TestAuthorizeInitiatorMessage_NoSignatures tests when there are no authorizer signatures +func TestAuthorizeInitiatorMessage_NoSignatures(t *testing.T) { + _, _, pubKeyHex := generateTestEd25519Key() + + authorizerKeys := map[AuthorizerID]AuthorizerPublicKey{ + "auth1": { + PublicKey: pubKeyHex, + Algorithm: AlgorithmEd25519, + }, + } + + store := createTestStore(true, authorizerKeys) + + msg := &mockInitiatorMessage{ + raw: []byte("test message"), + sig: []byte("fake signature"), + initiatorID: "test-id", + authorizerSignatures: []types.AuthorizerSignature{}, + } + + err := store.AuthorizeInitiatorMessage(msg) + if err != nil { + t.Errorf("AuthorizeInitiatorMessage() with no signatures should return nil, got error: %v", err) + } +} + +// TestAuthorizeInitiatorMessage_ValidEd25519 tests valid Ed25519 authorizer signatures +func TestAuthorizeInitiatorMessage_ValidEd25519(t *testing.T) { + // Generate authorizer keys + auth1Pub, auth1Priv, auth1PubHex := generateTestEd25519Key() + auth2Pub, auth2Priv, auth2PubHex := generateTestEd25519Key() + + authorizerKeys := map[AuthorizerID]AuthorizerPublicKey{ + "auth1": { + PublicKey: auth1PubHex, + Algorithm: AlgorithmEd25519, + }, + "auth2": { + PublicKey: auth2PubHex, + Algorithm: AlgorithmEd25519, + }, + } + + store := createTestStore(true, authorizerKeys) + + // Create test message + msg := &mockInitiatorMessage{ + raw: []byte("test message"), + sig: []byte("initiator signature"), + initiatorID: "wallet-123", + } + + // Compose the authorizer raw data + authorizerRaw, err := types.ComposeAuthorizerRaw(msg) + if err != nil { + t.Fatalf("Failed to compose authorizer raw: %v", err) + } + + // Sign with both authorizers + auth1Sig := ed25519.Sign(auth1Priv, authorizerRaw) + auth2Sig := ed25519.Sign(auth2Priv, authorizerRaw) + + msg.authorizerSignatures = []types.AuthorizerSignature{ + {AuthorizerID: "auth1", Signature: auth1Sig}, + {AuthorizerID: "auth2", Signature: auth2Sig}, + } + + err = store.AuthorizeInitiatorMessage(msg) + if err != nil { + t.Errorf("AuthorizeInitiatorMessage() with valid Ed25519 signatures failed: %v", err) + } + + // Verify the keys are still valid + if !ed25519.Verify(auth1Pub, authorizerRaw, auth1Sig) { + t.Error("Auth1 signature verification failed") + } + if !ed25519.Verify(auth2Pub, authorizerRaw, auth2Sig) { + t.Error("Auth2 signature verification failed") + } +} + +// TestAuthorizeInitiatorMessage_ValidP256 tests valid P256 authorizer signatures +func TestAuthorizeInitiatorMessage_ValidP256(t *testing.T) { + // Generate P256 authorizer keys + auth1Pub, auth1Priv, auth1PubHex, err := generateTestP256Key() + if err != nil { + t.Fatalf("Failed to generate P256 key for auth1: %v", err) + } + + auth2Pub, auth2Priv, auth2PubHex, err := generateTestP256Key() + if err != nil { + t.Fatalf("Failed to generate P256 key for auth2: %v", err) + } + + authorizerKeys := map[AuthorizerID]AuthorizerPublicKey{ + "auth1": { + PublicKey: auth1PubHex, + Algorithm: AlgorithmP256, + }, + "auth2": { + PublicKey: auth2PubHex, + Algorithm: AlgorithmP256, + }, + } + + store := createTestStore(true, authorizerKeys) + + // Create test message + msg := &mockInitiatorMessage{ + raw: []byte("test message"), + sig: []byte("initiator signature"), + initiatorID: "wallet-123", + } + + // Compose the authorizer raw data + authorizerRaw, err := types.ComposeAuthorizerRaw(msg) + if err != nil { + t.Fatalf("Failed to compose authorizer raw: %v", err) + } + + // Sign with both P256 authorizers + auth1Sig, err := encryption.SignWithP256(auth1Priv, authorizerRaw) + if err != nil { + t.Fatalf("Failed to sign with auth1: %v", err) + } + + auth2Sig, err := encryption.SignWithP256(auth2Priv, authorizerRaw) + if err != nil { + t.Fatalf("Failed to sign with auth2: %v", err) + } + + msg.authorizerSignatures = []types.AuthorizerSignature{ + {AuthorizerID: "auth1", Signature: auth1Sig}, + {AuthorizerID: "auth2", Signature: auth2Sig}, + } + + err = store.AuthorizeInitiatorMessage(msg) + if err != nil { + t.Errorf("AuthorizeInitiatorMessage() with valid P256 signatures failed: %v", err) + } + + // Verify the signatures are still valid + if err := encryption.VerifyP256Signature(auth1Pub, authorizerRaw, auth1Sig); err != nil { + t.Errorf("Auth1 P256 signature verification failed: %v", err) + } + if err := encryption.VerifyP256Signature(auth2Pub, authorizerRaw, auth2Sig); err != nil { + t.Errorf("Auth2 P256 signature verification failed: %v", err) + } +} + +// TestAuthorizeInitiatorMessage_MixedAlgorithms tests mixed Ed25519 and P256 signatures +func TestAuthorizeInitiatorMessage_MixedAlgorithms(t *testing.T) { + // Generate Ed25519 key for auth1 + _, auth1Ed25519Priv, auth1Ed25519PubHex := generateTestEd25519Key() + + // Generate P256 key for auth2 + _, auth2P256Priv, auth2P256PubHex, err := generateTestP256Key() + if err != nil { + t.Fatalf("Failed to generate P256 key: %v", err) + } + + authorizerKeys := map[AuthorizerID]AuthorizerPublicKey{ + "auth1": { + PublicKey: auth1Ed25519PubHex, + Algorithm: AlgorithmEd25519, + }, + "auth2": { + PublicKey: auth2P256PubHex, + Algorithm: AlgorithmP256, + }, + } + + store := createTestStore(true, authorizerKeys) + + // Create test message + msg := &mockInitiatorMessage{ + raw: []byte("test message with mixed algorithms"), + sig: []byte("initiator signature"), + initiatorID: "wallet-456", + } + + // Compose the authorizer raw data + authorizerRaw, err := types.ComposeAuthorizerRaw(msg) + if err != nil { + t.Fatalf("Failed to compose authorizer raw: %v", err) + } + + // Sign with Ed25519 authorizer + auth1Sig := ed25519.Sign(auth1Ed25519Priv, authorizerRaw) + + // Sign with P256 authorizer + auth2Sig, err := encryption.SignWithP256(auth2P256Priv, authorizerRaw) + if err != nil { + t.Fatalf("Failed to sign with P256: %v", err) + } + + msg.authorizerSignatures = []types.AuthorizerSignature{ + {AuthorizerID: "auth1", Signature: auth1Sig}, + {AuthorizerID: "auth2", Signature: auth2Sig}, + } + + err = store.AuthorizeInitiatorMessage(msg) + if err != nil { + t.Errorf("AuthorizeInitiatorMessage() with mixed algorithms failed: %v", err) + } +} + +// TestAuthorizeInitiatorMessage_InvalidSignature tests invalid signatures +func TestAuthorizeInitiatorMessage_InvalidSignature(t *testing.T) { + _, _, authPubHex := generateTestEd25519Key() + + authorizerKeys := map[AuthorizerID]AuthorizerPublicKey{ + "auth1": { + PublicKey: authPubHex, + Algorithm: AlgorithmEd25519, + }, + } + + store := createTestStore(true, authorizerKeys) + + msg := &mockInitiatorMessage{ + raw: []byte("test message"), + sig: []byte("initiator signature"), + initiatorID: "wallet-123", + authorizerSignatures: []types.AuthorizerSignature{ + {AuthorizerID: "auth1", Signature: []byte("invalid signature")}, + }, + } + + err := store.AuthorizeInitiatorMessage(msg) + if err == nil { + t.Error("AuthorizeInitiatorMessage() should fail with invalid signature") + } + if !strings.Contains(err.Error(), "verification failed") { + t.Errorf("Expected error to contain 'verification failed', got: %v", err) + } +} + +// TestAuthorizeInitiatorMessage_UnknownAuthorizer tests unknown authorizer ID +func TestAuthorizeInitiatorMessage_UnknownAuthorizer(t *testing.T) { + _, _, authPubHex := generateTestEd25519Key() + + authorizerKeys := map[AuthorizerID]AuthorizerPublicKey{ + "auth1": { + PublicKey: authPubHex, + Algorithm: AlgorithmEd25519, + }, + } + + store := createTestStore(true, authorizerKeys) + + msg := &mockInitiatorMessage{ + raw: []byte("test message"), + sig: []byte("initiator signature"), + initiatorID: "wallet-123", + authorizerSignatures: []types.AuthorizerSignature{ + {AuthorizerID: "unknown-auth", Signature: []byte("some signature")}, + }, + } + + err := store.AuthorizeInitiatorMessage(msg) + if err == nil { + t.Error("AuthorizeInitiatorMessage() should fail with unknown authorizer") + } + if !strings.Contains(err.Error(), "not found in cache") { + t.Errorf("Expected error to contain 'not found in cache', got: %v", err) + } +} + +// TestAuthorizeInitiatorMessage_WrongKeyForSignature tests signature signed with wrong key +func TestAuthorizeInitiatorMessage_WrongKeyForSignature(t *testing.T) { + // Create two different key pairs + _, auth1Priv, auth1PubHex := generateTestEd25519Key() + _, _, auth2PubHex := generateTestEd25519Key() // Different key + + // Configure store with auth2's public key but sign with auth1's private key + authorizerKeys := map[AuthorizerID]AuthorizerPublicKey{ + "auth1": { + PublicKey: auth2PubHex, // Using auth2's public key + Algorithm: AlgorithmEd25519, + }, + } + + store := createTestStore(true, authorizerKeys) + + msg := &mockInitiatorMessage{ + raw: []byte("test message"), + sig: []byte("initiator signature"), + initiatorID: "wallet-123", + } + + authorizerRaw, err := types.ComposeAuthorizerRaw(msg) + if err != nil { + t.Fatalf("Failed to compose authorizer raw: %v", err) + } + + // Sign with auth1's private key + wrongSig := ed25519.Sign(auth1Priv, authorizerRaw) + + msg.authorizerSignatures = []types.AuthorizerSignature{ + {AuthorizerID: "auth1", Signature: wrongSig}, + } + + err = store.AuthorizeInitiatorMessage(msg) + if err == nil { + t.Error("AuthorizeInitiatorMessage() should fail when signature doesn't match public key") + } + if !strings.Contains(err.Error(), "verification failed") { + t.Errorf("Expected error to contain 'verification failed', got: %v", err) + } + + // Also test with valid key to ensure our test setup is correct + authorizerKeys2 := map[AuthorizerID]AuthorizerPublicKey{ + "auth1": { + PublicKey: auth1PubHex, // Using correct public key + Algorithm: AlgorithmEd25519, + }, + } + store2 := createTestStore(true, authorizerKeys2) + err = store2.AuthorizeInitiatorMessage(msg) + if err != nil { + t.Errorf("AuthorizeInitiatorMessage() should succeed with correct key: %v", err) + } +} + +// TestVerifyAuthorizerSignature_Ed25519 tests the verifyAuthorizerSignature helper with Ed25519 +func TestVerifyAuthorizerSignature_Ed25519(t *testing.T) { + _, privKey, pubKeyHex := generateTestEd25519Key() + + authorizerKeys := map[AuthorizerID]AuthorizerPublicKey{ + "auth1": { + PublicKey: pubKeyHex, + Algorithm: AlgorithmEd25519, + }, + } + + store := createTestStore(true, authorizerKeys) + + rawData := []byte("test data for signing") + validSig := ed25519.Sign(privKey, rawData) + + tests := []struct { + name string + raw []byte + sig types.AuthorizerSignature + wantError bool + errorMsg string + }{ + { + name: "valid signature", + raw: rawData, + sig: types.AuthorizerSignature{ + AuthorizerID: "auth1", + Signature: validSig, + }, + wantError: false, + }, + { + name: "invalid signature", + raw: rawData, + sig: types.AuthorizerSignature{ + AuthorizerID: "auth1", + Signature: []byte("invalid signature"), + }, + wantError: true, + errorMsg: "verification failed", + }, + { + name: "unknown authorizer", + raw: rawData, + sig: types.AuthorizerSignature{ + AuthorizerID: "unknown", + Signature: validSig, + }, + wantError: true, + errorMsg: "not found in cache", + }, + { + name: "tampered data", + raw: []byte("different data"), + sig: types.AuthorizerSignature{ + AuthorizerID: "auth1", + Signature: validSig, + }, + wantError: true, + errorMsg: "verification failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := store.verifyAuthorizerSignature(tt.raw, tt.sig) + + if tt.wantError { + if err == nil { + t.Error("verifyAuthorizerSignature() expected error but got none") + } else if tt.errorMsg != "" && !strings.Contains(err.Error(), tt.errorMsg) { + t.Errorf("verifyAuthorizerSignature() error = %v, want error containing %q", err, tt.errorMsg) + } + } else { + if err != nil { + t.Errorf("verifyAuthorizerSignature() unexpected error = %v", err) + } + } + }) + } +} + +// TestVerifyAuthorizerSignature_P256 tests the verifyAuthorizerSignature helper with P256 +func TestVerifyAuthorizerSignature_P256(t *testing.T) { + _, privKey, pubKeyHex, err := generateTestP256Key() + if err != nil { + t.Fatalf("Failed to generate P256 key: %v", err) + } + + authorizerKeys := map[AuthorizerID]AuthorizerPublicKey{ + "auth1": { + PublicKey: pubKeyHex, + Algorithm: AlgorithmP256, + }, + } + + store := createTestStore(true, authorizerKeys) + + rawData := []byte("test data for P256 signing") + validSig, err := encryption.SignWithP256(privKey, rawData) + if err != nil { + t.Fatalf("Failed to sign data: %v", err) + } + + tests := []struct { + name string + raw []byte + sig types.AuthorizerSignature + wantError bool + errorMsg string + }{ + { + name: "valid P256 signature", + raw: rawData, + sig: types.AuthorizerSignature{ + AuthorizerID: "auth1", + Signature: validSig, + }, + wantError: false, + }, + { + name: "invalid P256 signature", + raw: rawData, + sig: types.AuthorizerSignature{ + AuthorizerID: "auth1", + Signature: []byte("invalid signature"), + }, + wantError: true, + errorMsg: "verification failed", + }, + { + name: "tampered P256 data", + raw: []byte("different data"), + sig: types.AuthorizerSignature{ + AuthorizerID: "auth1", + Signature: validSig, + }, + wantError: true, + errorMsg: "verification failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := store.verifyAuthorizerSignature(tt.raw, tt.sig) + + if tt.wantError { + if err == nil { + t.Error("verifyAuthorizerSignature() expected error but got none") + } else if tt.errorMsg != "" && !strings.Contains(err.Error(), tt.errorMsg) { + t.Errorf("verifyAuthorizerSignature() error = %v, want error containing %q", err, tt.errorMsg) + } + } else { + if err != nil { + t.Errorf("verifyAuthorizerSignature() unexpected error = %v", err) + } + } + }) + } +} + +// TestGetAuthorizerPublicKey tests the getAuthorizerPublicKey helper +func TestGetAuthorizerPublicKey(t *testing.T) { + _, _, pubKeyHex := generateTestEd25519Key() + + authorizerKeys := map[AuthorizerID]AuthorizerPublicKey{ + "auth1": { + PublicKey: pubKeyHex, + Algorithm: AlgorithmEd25519, + }, + } + + store := createTestStore(true, authorizerKeys) + + tests := []struct { + name string + authorizerID string + wantError bool + errorMsg string + }{ + { + name: "existing authorizer", + authorizerID: "auth1", + wantError: false, + }, + { + name: "non-existent authorizer", + authorizerID: "unknown", + wantError: true, + errorMsg: "unknown authorizer ID", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pubKey, err := store.getAuthorizerPublicKey(tt.authorizerID) + + if tt.wantError { + if err == nil { + t.Error("getAuthorizerPublicKey() expected error but got none") + } else if tt.errorMsg != "" && !strings.Contains(err.Error(), tt.errorMsg) { + t.Errorf("getAuthorizerPublicKey() error = %v, want error containing %q", err, tt.errorMsg) + } + if pubKey != nil { + t.Error("getAuthorizerPublicKey() expected nil public key on error") + } + } else { + if err != nil { + t.Errorf("getAuthorizerPublicKey() unexpected error = %v", err) + } + if pubKey == nil { + t.Error("getAuthorizerPublicKey() expected non-nil public key") + } else if pubKey.PublicKey != pubKeyHex { + t.Errorf("getAuthorizerPublicKey() public key = %v, want %v", pubKey.PublicKey, pubKeyHex) + } + } + }) + } +} + +// TestAuthorizeInitiatorMessage_MultipleAuthorizersPartialFailure tests failure when one of multiple signatures is invalid +func TestAuthorizeInitiatorMessage_MultipleAuthorizersPartialFailure(t *testing.T) { + // Generate two Ed25519 keys + _, auth1Priv, auth1PubHex := generateTestEd25519Key() + _, _, auth2PubHex := generateTestEd25519Key() + + authorizerKeys := map[AuthorizerID]AuthorizerPublicKey{ + "auth1": { + PublicKey: auth1PubHex, + Algorithm: AlgorithmEd25519, + }, + "auth2": { + PublicKey: auth2PubHex, + Algorithm: AlgorithmEd25519, + }, + } + + store := createTestStore(true, authorizerKeys) + + msg := &mockInitiatorMessage{ + raw: []byte("test message"), + sig: []byte("initiator signature"), + initiatorID: "wallet-123", + } + + authorizerRaw, err := types.ComposeAuthorizerRaw(msg) + if err != nil { + t.Fatalf("Failed to compose authorizer raw: %v", err) + } + + // Create one valid signature and one invalid + validSig := ed25519.Sign(auth1Priv, authorizerRaw) + invalidSig := []byte("invalid signature for auth2") + + msg.authorizerSignatures = []types.AuthorizerSignature{ + {AuthorizerID: "auth1", Signature: validSig}, + {AuthorizerID: "auth2", Signature: invalidSig}, + } + + err = store.AuthorizeInitiatorMessage(msg) + if err == nil { + t.Error("AuthorizeInitiatorMessage() should fail when one of multiple signatures is invalid") + } + if !strings.Contains(err.Error(), "auth2") { + t.Errorf("Expected error to mention 'auth2', got: %v", err) + } +} + +// TestAuthorizeInitiatorMessage_EmptySignature tests behavior with empty signature bytes +func TestAuthorizeInitiatorMessage_EmptySignature(t *testing.T) { + _, _, pubKeyHex := generateTestEd25519Key() + + authorizerKeys := map[AuthorizerID]AuthorizerPublicKey{ + "auth1": { + PublicKey: pubKeyHex, + Algorithm: AlgorithmEd25519, + }, + } + + store := createTestStore(true, authorizerKeys) + + msg := &mockInitiatorMessage{ + raw: []byte("test message"), + sig: []byte("initiator signature"), + initiatorID: "wallet-123", + authorizerSignatures: []types.AuthorizerSignature{ + {AuthorizerID: "auth1", Signature: []byte{}}, + }, + } + + err := store.AuthorizeInitiatorMessage(msg) + if err == nil { + t.Error("AuthorizeInitiatorMessage() should fail with empty signature") + } +} + +// TestAuthorizeInitiatorMessage_NilSignature tests behavior with nil signature +func TestAuthorizeInitiatorMessage_NilSignature(t *testing.T) { + _, _, pubKeyHex := generateTestEd25519Key() + + authorizerKeys := map[AuthorizerID]AuthorizerPublicKey{ + "auth1": { + PublicKey: pubKeyHex, + Algorithm: AlgorithmEd25519, + }, + } + + store := createTestStore(true, authorizerKeys) + + msg := &mockInitiatorMessage{ + raw: []byte("test message"), + sig: []byte("initiator signature"), + initiatorID: "wallet-123", + authorizerSignatures: []types.AuthorizerSignature{ + {AuthorizerID: "auth1", Signature: nil}, + }, + } + + err := store.AuthorizeInitiatorMessage(msg) + if err == nil { + t.Error("AuthorizeInitiatorMessage() should fail with nil signature") + } +} diff --git a/pkg/types/initiator_msg.go b/pkg/types/initiator_msg.go index d770e791..e1ddcb4b 100644 --- a/pkg/types/initiator_msg.go +++ b/pkg/types/initiator_msg.go @@ -16,6 +16,12 @@ const ( EventInitiatorKeyTypeP256 EventInitiatorKeyType = "p256" ) +// AuthorizerSignature represents a single authorizer signature attached to an initiator message. +type AuthorizerSignature struct { + AuthorizerID string `json:"authorizer_id"` + Signature []byte `json:"signature"` +} + // InitiatorMessage is anything that carries a payload to verify and its signature. type InitiatorMessage interface { // Raw returns the canonical byte‐slice that was signed. @@ -24,29 +30,34 @@ type InitiatorMessage interface { Sig() []byte // InitiatorID returns the ID whose public key we have to look up. InitiatorID() string + + GetAuthorizerSignatures() []AuthorizerSignature } 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) { @@ -87,9 +98,14 @@ func (m *GenerateKeyMessage) InitiatorID() string { return m.WalletID } +func (m *GenerateKeyMessage) GetAuthorizerSignatures() []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(©) } @@ -100,3 +116,31 @@ func (m *ResharingMessage) Sig() []byte { func (m *ResharingMessage) InitiatorID() string { return m.WalletID } + +func (m *ResharingMessage) GetAuthorizerSignatures() []AuthorizerSignature { + return m.AuthorizerSignatures +} + +func (m *SignTxMessage) GetAuthorizerSignatures() []AuthorizerSignature { + return m.AuthorizerSignatures +} + +// ComposeAuthorizerRaw composes the raw data to be signed by an authorizer +func ComposeAuthorizerRaw(msg InitiatorMessage) ([]byte, error) { + raw, err := msg.Raw() + if err != nil { + return nil, err + } + + payload := struct { + InitiatorID string `json:"initiator_id"` + InitiatorRaw []byte `json:"initiator_raw"` + InitiatorSig []byte `json:"initiator_sig"` + }{ + InitiatorID: msg.InitiatorID(), + InitiatorRaw: raw, + InitiatorSig: msg.Sig(), + } + + return json.Marshal(payload) +}