Skip to content

Commit 88c2e1b

Browse files
Enable Google KMS and Azure Key Vault for publisher login tool (#696)
<!-- Provide a brief summary of your changes --> ## Motivation and Context This allows the private key used for DNS or HTTP based authentication to be stored securely in a cloud key management system. These services often provide HSM storage which makes it very hard to leak the private key. The full context is described in my design document here: #482 (comment) ## How Has This Been Tested? I have tested it against PROD using an Ed25519 key stored in Google KMS. I have added unit tests. I have tested both ECDSA P-384 and Ed25519 with Az KV and Google KMS against a locally running server. ## Breaking Changes None intended. ## Types of changes <!-- What types of changes does your code introduce? Put an `x` in all the boxes that apply: --> - [ ] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [x] Documentation update ## Checklist <!-- Go over all the following points, and put an `x` in all the boxes that apply. --> - [x] I have read the [MCP Documentation](https://modelcontextprotocol.io) - [x] My code follows the repository's style guidelines - [x] New and existing tests pass locally - [x] I have added appropriate error handling - [x] I have added or updated documentation as needed ## Additional context <!-- Add any other context, implementation notes, or design decisions --> --------- Co-authored-by: adam jones <[email protected]>
1 parent 3d375e6 commit 88c2e1b

File tree

11 files changed

+641
-91
lines changed

11 files changed

+641
-91
lines changed

cmd/publisher/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ make dev-compose # Start local registry
3232
- **`http`** - Domain verification via HTTPS endpoints
3333
- **`none`** - No auth (testing only)
3434

35+
### Signing Providers
36+
Optional: enables `dns` and `http` methods to sign out-of-process without direct access to the private key.
37+
38+
- **`google-kms`** - Google KMS signing
39+
- **`azure-key-vault`** - Azure Key Vault signing
40+
3541
## Key Files
3642

3743
- **`main.go`** - CLI setup and command routing
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package azurekeyvault
2+
3+
import (
4+
"context"
5+
"crypto/ecdsa"
6+
"crypto/elliptic"
7+
"crypto/sha512"
8+
"fmt"
9+
"math/big"
10+
"os"
11+
12+
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
13+
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys"
14+
"github.com/modelcontextprotocol/registry/cmd/publisher/auth"
15+
)
16+
17+
func GetSignatureProvider(vaultName, keyName string) (auth.Signer, error) {
18+
if vaultName == "" {
19+
return nil, fmt.Errorf("--vault option (vault name) is required")
20+
}
21+
22+
if keyName == "" {
23+
return nil, fmt.Errorf("--key option (key name) is required")
24+
}
25+
26+
return Signer{
27+
vaultName: vaultName,
28+
keyName: keyName,
29+
}, nil
30+
}
31+
32+
type Signer struct {
33+
vaultName string
34+
keyName string
35+
}
36+
37+
func (d Signer) GetSignedTimestamp(ctx context.Context) (*string, []byte, error) {
38+
fmt.Fprintf(os.Stdout, "Signing using Azure Key Vault %s and key %s\n", d.vaultName, d.keyName)
39+
40+
cred, err := azidentity.NewDefaultAzureCredential(nil)
41+
if err != nil {
42+
return nil, nil, fmt.Errorf("authentication to Azure failed: %w", err)
43+
}
44+
45+
vaultURL := fmt.Sprintf("https://%s.vault.azure.net/", d.vaultName)
46+
client, err := azkeys.NewClient(vaultURL, cred, nil)
47+
if err != nil {
48+
return nil, nil, fmt.Errorf("failed to create Key Vault client: %w", err)
49+
}
50+
51+
keyResp, err := client.GetKey(ctx, d.keyName, "", nil)
52+
if err != nil {
53+
return nil, nil, fmt.Errorf("failed to retrieve key for public parameters: %w", err)
54+
}
55+
56+
if *keyResp.Key.Kty != azkeys.KeyTypeEC && *keyResp.Key.Kty != azkeys.KeyTypeECHSM {
57+
return nil, nil, fmt.Errorf("unsupported key type: kty: %s (only EC or EC-HSM keys are supported)", *keyResp.Key.Kty)
58+
}
59+
60+
if *keyResp.Key.Crv != azkeys.CurveNameP384 {
61+
return nil, nil, fmt.Errorf("unsupported curve: %s (only P-384 is supported)", *keyResp.Key.Crv)
62+
}
63+
64+
fmt.Fprintln(os.Stdout, "Successfully read the public key from Key Vault.")
65+
auth.PrintEcdsaP384KeyInfo(ecdsa.PublicKey{
66+
Curve: elliptic.P384(),
67+
X: new(big.Int).SetBytes(keyResp.Key.X),
68+
Y: new(big.Int).SetBytes(keyResp.Key.Y),
69+
})
70+
71+
timestamp := auth.GetTimestamp()
72+
digest := sha512.Sum384([]byte(timestamp))
73+
alg := azkeys.SignatureAlgorithmES384
74+
fmt.Fprintln(os.Stdout, "Executing the sign request...")
75+
signResp, err := client.Sign(ctx, d.keyName, "", azkeys.SignParameters{
76+
Algorithm: &alg,
77+
Value: digest[:],
78+
}, nil)
79+
80+
if err != nil {
81+
return nil, nil, fmt.Errorf("failed to sign message: %w", err)
82+
}
83+
84+
return &timestamp, signResp.Result, nil
85+
}

cmd/publisher/auth/common.go

Lines changed: 82 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@ import (
88
"crypto/elliptic"
99
"crypto/rand"
1010
"crypto/sha512"
11+
"encoding/base64"
1112
"encoding/hex"
1213
"encoding/json"
1314
"fmt"
1415
"io"
1516
"math/big"
1617
"net/http"
18+
"os"
1719
"time"
1820
)
1921

@@ -30,78 +32,105 @@ const (
3032

3133
// CryptoProvider provides common functionality for DNS and HTTP authentication
3234
type CryptoProvider struct {
33-
registryURL string
34-
domain string
35-
privateKey string
36-
cryptoAlgorithm CryptoAlgorithm
37-
authMethod string
35+
registryURL string
36+
domain string
37+
signer Signer
38+
authMethod string
3839
}
3940

40-
// GetToken retrieves the registry JWT token using cryptographic authentication
41-
func (c *CryptoProvider) GetToken(ctx context.Context) (string, error) {
42-
if c.domain == "" {
43-
return "", fmt.Errorf("%s domain is required", c.authMethod)
44-
}
41+
type Signer interface {
42+
GetSignedTimestamp(ctx context.Context) (*string, []byte, error)
43+
}
44+
45+
func GetTimestamp() string {
46+
return time.Now().UTC().Format(time.RFC3339)
47+
}
4548

46-
if c.privateKey == "" {
47-
return "", fmt.Errorf("%s private key (hex) is required", c.authMethod)
49+
func NewInProcessSigner(privateKey string, algorithm CryptoAlgorithm) (Signer, error) {
50+
if privateKey == "" {
51+
return nil, fmt.Errorf("%s private key (hex) is required", algorithm)
4852
}
4953

5054
// Decode private key from hex
51-
privateKeyBytes, err := hex.DecodeString(c.privateKey)
55+
privateKeyBytes, err := hex.DecodeString(privateKey)
5256
if err != nil {
53-
return "", fmt.Errorf("invalid hex private key format: %w", err)
57+
return nil, fmt.Errorf("invalid hex private key format: %w", err)
58+
}
59+
60+
return &InProcessSigner{
61+
privateKey: privateKeyBytes,
62+
cryptoAlgorithm: algorithm,
63+
}, nil
64+
}
65+
66+
// GetToken retrieves the registry JWT token using cryptographic authentication
67+
func (c *CryptoProvider) GetToken(ctx context.Context) (string, error) {
68+
if c.domain == "" {
69+
return "", fmt.Errorf("%s domain is required", c.authMethod)
5470
}
5571

5672
// Generate current timestamp
57-
timestamp := time.Now().UTC().Format(time.RFC3339)
58-
signedTimestamp, err := c.signMessage(privateKeyBytes, []byte(timestamp))
73+
timestamp, signedTimestamp, err := c.signer.GetSignedTimestamp(ctx)
5974
if err != nil {
6075
return "", fmt.Errorf("failed to sign timestamp: %w", err)
6176
}
6277
signedTimestampHex := hex.EncodeToString(signedTimestamp)
6378

6479
// Exchange signature for registry token
65-
registryToken, err := c.exchangeTokenForRegistry(ctx, c.domain, timestamp, signedTimestampHex)
80+
registryToken, err := c.exchangeTokenForRegistry(ctx, c.domain, *timestamp, signedTimestampHex)
6681
if err != nil {
6782
return "", fmt.Errorf("failed to exchange %s signature: %w", c.authMethod, err)
6883
}
6984

7085
return registryToken, nil
7186
}
7287

73-
func (c *CryptoProvider) signMessage(privateKeyBytes []byte, message []byte) ([]byte, error) {
88+
type InProcessSigner struct {
89+
privateKey []byte
90+
cryptoAlgorithm CryptoAlgorithm
91+
}
92+
93+
func (c *InProcessSigner) GetSignedTimestamp(_ context.Context) (*string, []byte, error) {
94+
fmt.Fprintf(os.Stdout, "Signing in process using key algorithm %s\n", c.cryptoAlgorithm)
95+
96+
timestamp := GetTimestamp()
97+
7498
switch c.cryptoAlgorithm {
7599
case AlgorithmEd25519:
76-
if len(privateKeyBytes) != ed25519.SeedSize {
77-
return nil, fmt.Errorf("invalid seed length: expected %d bytes, got %d", ed25519.SeedSize, len(privateKeyBytes))
100+
if len(c.privateKey) != ed25519.SeedSize {
101+
return nil, nil, fmt.Errorf("invalid seed length: expected %d bytes, got %d", ed25519.SeedSize, len(c.privateKey))
78102
}
79103

80-
privateKey := ed25519.NewKeyFromSeed(privateKeyBytes)
81-
signature := ed25519.Sign(privateKey, message)
82-
return signature, nil
104+
privateKey := ed25519.NewKeyFromSeed(c.privateKey)
105+
106+
PrintEd25519KeyInfo(privateKey.Public().(ed25519.PublicKey))
107+
108+
signature := ed25519.Sign(privateKey, []byte(timestamp))
109+
return &timestamp, signature, nil
83110
case AlgorithmECDSAP384:
84-
if len(privateKeyBytes) != 48 {
85-
return nil, fmt.Errorf("invalid seed length for ECDSA P-384: expected 48 bytes, got %d", len(privateKeyBytes))
111+
if len(c.privateKey) != 48 {
112+
return nil, nil, fmt.Errorf("invalid seed length for ECDSA P-384: expected 48 bytes, got %d", len(c.privateKey))
86113
}
87114

88-
digest := sha512.Sum384(message)
115+
digest := sha512.Sum384([]byte(timestamp))
89116
curve := elliptic.P384()
90117

91118
// Parse the raw private key (compatible with Go 1.24)
92-
privateKey, err := parseRawPrivateKey(curve, privateKeyBytes)
119+
privateKey, err := parseRawPrivateKey(curve, c.privateKey)
93120
if err != nil {
94-
return nil, fmt.Errorf("failed to parse ECDSA private key: %w", err)
121+
return nil, nil, fmt.Errorf("failed to parse ECDSA private key: %w", err)
95122
}
96123

124+
PrintEcdsaP384KeyInfo(privateKey.PublicKey)
125+
97126
r, s, err := ecdsa.Sign(rand.Reader, privateKey, digest[:])
98127
if err != nil {
99-
return nil, fmt.Errorf("failed to sign message: %w", err)
128+
return nil, nil, fmt.Errorf("failed to sign message: %w", err)
100129
}
101130
signature := append(r.Bytes(), s.Bytes()...)
102-
return signature, nil
131+
return &timestamp, signature, nil
103132
default:
104-
return nil, fmt.Errorf("unsupported crypto algorithm: %s", c.cryptoAlgorithm)
133+
return nil, nil, fmt.Errorf("unsupported crypto algorithm: %s", c.cryptoAlgorithm)
105134
}
106135
}
107136

@@ -112,6 +141,11 @@ func parseRawPrivateKey(curve elliptic.Curve, privateKeyBytes []byte) (*ecdsa.Pr
112141
return nil, fmt.Errorf("nil curve")
113142
}
114143

144+
expectedBytes := (curve.Params().N.BitLen() + 7) / 8
145+
if len(privateKeyBytes) != expectedBytes {
146+
return nil, fmt.Errorf("invalid private key length: expected %d bytes, got %d", expectedBytes, len(privateKeyBytes))
147+
}
148+
115149
// Only standard NIST curves supported
116150
switch curve {
117151
case elliptic.P224(), elliptic.P256(), elliptic.P384(), elliptic.P521():
@@ -147,6 +181,23 @@ func (c *CryptoProvider) Login(_ context.Context) error {
147181
return nil
148182
}
149183

184+
func PrintEd25519KeyInfo(pubKey ed25519.PublicKey) {
185+
pubKeyString := base64.StdEncoding.EncodeToString(pubKey)
186+
fmt.Fprint(os.Stdout, "Expected proof record:\n")
187+
fmt.Fprintf(os.Stdout, "v=MCPv1; k=ed25519; p=%s\n", pubKeyString)
188+
}
189+
190+
func PrintEcdsaP384KeyInfo(pubKey ecdsa.PublicKey) {
191+
printEcdsaKeyInfo("ecdsap384", pubKey)
192+
}
193+
194+
func printEcdsaKeyInfo(k string, pubKey ecdsa.PublicKey) {
195+
compressed := elliptic.MarshalCompressed(pubKey.Curve, pubKey.X, pubKey.Y)
196+
pubKeyString := base64.StdEncoding.EncodeToString(compressed)
197+
fmt.Fprint(os.Stdout, "Expected proof record:\n")
198+
fmt.Fprintf(os.Stdout, "v=MCPv1; k=%s; p=%s\n", k, pubKeyString)
199+
}
200+
150201
// exchangeTokenForRegistry exchanges signature for a registry JWT token
151202
func (c *CryptoProvider) exchangeTokenForRegistry(ctx context.Context, domain, timestamp, signedTimestamp string) (string, error) {
152203
if c.registryURL == "" {

cmd/publisher/auth/dns.go

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,13 @@ type DNSProvider struct {
55
}
66

77
// NewDNSProvider creates a new DNS-based auth provider
8-
func NewDNSProvider(registryURL, domain, privateKey string, cryptoAlgorithm CryptoAlgorithm) Provider {
8+
func NewDNSProvider(registryURL, domain string, signer *Signer) Provider {
99
return &DNSProvider{
1010
CryptoProvider: &CryptoProvider{
11-
registryURL: registryURL,
12-
domain: domain,
13-
privateKey: privateKey,
14-
cryptoAlgorithm: cryptoAlgorithm,
15-
authMethod: "dns",
11+
registryURL: registryURL,
12+
domain: domain,
13+
signer: *signer,
14+
authMethod: "dns",
1615
},
1716
}
1817
}

0 commit comments

Comments
 (0)