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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<name>.authorizer.key.age`; omit it to write the plain hex key.
- The identity JSON is saved as `<name>.authorizer.identity.json`; copy the `public_key` into `authorization.authorizer_public_keys`.

### Testing

## 1. Unit tests
Expand Down
213 changes: 213 additions & 0 deletions cmd/mpcium-cli/generate-authorizer.go
Original file line number Diff line number Diff line change
@@ -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
}
37 changes: 37 additions & 0 deletions cmd/mpcium-cli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 16 additions & 0 deletions config.yaml.template
Original file line number Diff line number Diff line change
Expand Up @@ -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"
8 changes: 5 additions & 3 deletions e2e/go.mod
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
12 changes: 6 additions & 6 deletions e2e/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
Loading
Loading