From 99ed44f285775811b910a17ce8a978ad93d7d8cd Mon Sep 17 00:00:00 2001 From: Brian Nuszkowski Date: Thu, 26 Apr 2018 16:26:22 -0400 Subject: [PATCH 1/2] Adds the ability to enforce particular ssh key types and minimum key lengths when using Signed SSH Certificates via the SSH Secret Engine. --- builtin/logical/ssh/path_roles.go | 11 ++++ builtin/logical/ssh/path_sign.go | 68 +++++++++++++++++++++ builtin/logical/ssh/util.go | 10 +++ website/source/api/secret/ssh/index.html.md | 5 +- 4 files changed, 93 insertions(+), 1 deletion(-) diff --git a/builtin/logical/ssh/path_roles.go b/builtin/logical/ssh/path_roles.go index 76fda0e0ea40..09be5f09640b 100644 --- a/builtin/logical/ssh/path_roles.go +++ b/builtin/logical/ssh/path_roles.go @@ -48,6 +48,7 @@ type sshRole struct { AllowSubdomains bool `mapstructure:"allow_subdomains" json:"allow_subdomains"` AllowUserKeyIDs bool `mapstructure:"allow_user_key_ids" json:"allow_user_key_ids"` KeyIDFormat string `mapstructure:"key_id_format" json:"key_id_format"` + SignedKeyConstraints map[string]int `mapstructure:"signed_key_constraints" json:"signed_key_constraints"` } func pathListRoles(b *backend) *framework.Path { @@ -279,6 +280,13 @@ func pathRoles(b *backend) *framework.Path { '{{public_key_hash}}' - A SHA256 checksum of the public key that is being signed. `, }, + "signed_key_constraints": &framework.FieldSchema{ + Type: framework.TypeMap, + Description: ` + [Not applicable for Dynamic type] [Not applicable for OTP type] [Optional for CA type] + If set, allows the enforcement of key types and minimum key sizes to be signed. + `, + }, }, Callbacks: map[logical.Operation]framework.OperationFunc{ @@ -458,6 +466,7 @@ func (b *backend) createCARole(allowedUsers, defaultUser string, data *framework defaultCriticalOptions := convertMapToStringValue(data.Get("default_critical_options").(map[string]interface{})) defaultExtensions := convertMapToStringValue(data.Get("default_extensions").(map[string]interface{})) + signedKeyConstraints := convertMapToIntValue(data.Get("signed_key_constraints").(map[string]interface{})) if ttl != 0 && maxTTL != 0 && ttl > maxTTL { return nil, logical.ErrorResponse( @@ -469,6 +478,7 @@ func (b *backend) createCARole(allowedUsers, defaultUser string, data *framework role.MaxTTL = maxTTL.String() role.DefaultCriticalOptions = defaultCriticalOptions role.DefaultExtensions = defaultExtensions + role.SignedKeyConstraints = signedKeyConstraints return role, nil } @@ -534,6 +544,7 @@ func (b *backend) parseRole(role *sshRole) (map[string]interface{}, error) { "key_bits": role.KeyBits, "default_critical_options": role.DefaultCriticalOptions, "default_extensions": role.DefaultExtensions, + "signed_key_constraints": role.SignedKeyConstraints, } case KeyTypeDynamic: result = map[string]interface{}{ diff --git a/builtin/logical/ssh/path_sign.go b/builtin/logical/ssh/path_sign.go index 68bd1efe6410..b11daa186a7a 100644 --- a/builtin/logical/ssh/path_sign.go +++ b/builtin/logical/ssh/path_sign.go @@ -2,7 +2,10 @@ package ssh import ( "context" + "crypto/dsa" + "crypto/ecdsa" "crypto/rand" + "crypto/rsa" "crypto/sha256" "errors" "fmt" @@ -16,6 +19,7 @@ import ( "github.com/hashicorp/vault/helper/strutil" "github.com/hashicorp/vault/logical" "github.com/hashicorp/vault/logical/framework" + "golang.org/x/crypto/ed25519" "golang.org/x/crypto/ssh" ) @@ -110,6 +114,11 @@ func (b *backend) pathSignCertificate(ctx context.Context, req *logical.Request, return logical.ErrorResponse(fmt.Sprintf("failed to parse public_key as SSH key: %s", err)), nil } + err = b.validateSignedKeyRequirements(userPublicKey, role) + if err != nil { + return logical.ErrorResponse(fmt.Sprintf("public_key failed to meet the key requirements: %s", err)), nil + } + // Note that these various functions always return "user errors" so we pass // them as 4xx values keyId, err := b.calculateKeyId(data, req, role, userPublicKey) @@ -384,6 +393,65 @@ func (b *backend) calculateTTL(data *framework.FieldData, role *sshRole) (time.D return ttl, nil } +func (b *backend) validateSignedKeyRequirements(publickey ssh.PublicKey, role *sshRole) error { + if len(role.SignedKeyConstraints) != 0 { + var kstr string + var kbits int + + switch k := publickey.(type) { + case ssh.CryptoPublicKey: + ff := k.CryptoPublicKey() + switch k := ff.(type) { + case *rsa.PublicKey: + kstr = "rsa" + kbits = k.N.BitLen() + case *dsa.PublicKey: + kstr = "dsa" + kbits = k.Parameters.P.BitLen() + case *ecdsa.PublicKey: + kstr = "ecdsa" + kbits = k.Curve.Params().BitSize + case ed25519.PublicKey: + kstr = "ed25519" + default: + return fmt.Errorf("your public key type of %s is not allowed", kstr) + } + default: + return fmt.Errorf("pubkey not suitable for crypto (expected ssh.CryptoPublicKey but found %T)", k) + } + + if value, ok := role.SignedKeyConstraints[kstr]; ok { + var pass bool + switch kstr { + case "rsa": + if kbits >= value { + pass = true + } + case "dsa": + if kbits >= value { + pass = true + } + case "ecdsa": + if kbits >= value { + pass = true + } + case "ed25519": + // ed25519 public keys are always 256 bits in length, + // so there is no need to inspect their value + pass = true + } + + if !pass { + return fmt.Errorf("your key is of an invalid size: %v", kbits) + } + + } else { + return fmt.Errorf("your key type of %s is not allowed", kstr) + } + } + return nil +} + func (b *creationBundle) sign() (retCert *ssh.Certificate, retErr error) { defer func() { if r := recover(); r != nil { diff --git a/builtin/logical/ssh/util.go b/builtin/logical/ssh/util.go index 98a7036a46a6..3833c16236b9 100644 --- a/builtin/logical/ssh/util.go +++ b/builtin/logical/ssh/util.go @@ -7,6 +7,7 @@ import ( "crypto/rsa" "crypto/x509" "encoding/base64" + "encoding/json" "encoding/pem" "fmt" "net" @@ -215,6 +216,15 @@ func convertMapToStringValue(initial map[string]interface{}) map[string]string { return result } +func convertMapToIntValue(initial map[string]interface{}) map[string]int { + result := map[string]int{} + for key, value := range initial { + i, _ := value.(json.Number).Int64() + result[key] = int(i) + } + return result +} + // Serve a template processor for custom format inputs func substQuery(tpl string, data map[string]string) string { for k, v := range data { diff --git a/website/source/api/secret/ssh/index.html.md b/website/source/api/secret/ssh/index.html.md index 090ef7feef2b..e033d371070d 100644 --- a/website/source/api/secret/ssh/index.html.md +++ b/website/source/api/secret/ssh/index.html.md @@ -201,7 +201,10 @@ This endpoint creates or updates a named role. available for use: '{{token_display_name}}' - The display name of the token used to make the request. '{{role_name}}' - The name of the role signing the request. '{{public_key_hash}}' - A SHA256 checksum of the public key that is being signed. - e.g. "custom-keyid-{{token_display_name}}", + e.g. "custom-keyid-{{token_display_name}}" + +- `signed_key_constraints` `(map: "")` – Specifies a map of ssh key types + and their minimum sizes which are allowed to be signed by the CA type. ### Sample Payload From 82478d50045c552acc18e6fdc460f6aa0a58a25c Mon Sep 17 00:00:00 2001 From: Brian Nuszkowski Date: Thu, 26 Apr 2018 18:18:51 -0400 Subject: [PATCH 2/2] Add tests for signed key constraints --- builtin/logical/ssh/backend_test.go | 54 +++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/builtin/logical/ssh/backend_test.go b/builtin/logical/ssh/backend_test.go index 140f0306f2f2..2e2b1615cd61 100644 --- a/builtin/logical/ssh/backend_test.go +++ b/builtin/logical/ssh/backend_test.go @@ -5,12 +5,14 @@ import ( "fmt" "os/user" "reflect" + "strconv" "testing" "time" "golang.org/x/crypto/ssh" "encoding/base64" + "encoding/json" "errors" "strings" @@ -691,6 +693,58 @@ func TestBackend_OptionsOverrideDefaults(t *testing.T) { logicaltest.Test(t, testCase) } +func TestBackend_SignedKeyConstraints(t *testing.T) { + config := logical.TestBackendConfig() + + b, err := Factory(context.Background(), config) + if err != nil { + t.Fatalf("Cannot create backend: %s", err) + } + testCase := logicaltest.TestCase{ + Backend: b, + Steps: []logicaltest.TestStep{ + configCaStep(), + createRoleStep("weakkey", map[string]interface{}{ + "key_type": "ca", + "allow_user_certificates": true, + "signed_key_constraints": map[string]interface{}{ + "rsa": json.Number(strconv.FormatInt(4096, 10)), + }, + }), + logicaltest.TestStep{ + Operation: logical.UpdateOperation, + Path: "sign/weakkey", + Data: map[string]interface{}{ + "public_key": publicKey, + }, + ErrorOk: true, + Check: func(resp *logical.Response) error { + if resp.Data["error"] != "public_key failed to meet the key requirements: your key is of an invalid size: 2048" { + return errors.New("a smaller key (2048) was allowed, when the minimum was set for 4096") + } + return nil + }, + }, + createRoleStep("stdkey", map[string]interface{}{ + "key_type": "ca", + "allow_user_certificates": true, + "signed_key_constraints": map[string]interface{}{ + "rsa": json.Number(strconv.FormatInt(2048, 10)), + }, + }), + logicaltest.TestStep{ + Operation: logical.UpdateOperation, + Path: "sign/stdkey", + Data: map[string]interface{}{ + "public_key": publicKey, + }, + }, + }, + } + + logicaltest.Test(t, testCase) +} + func TestBackend_CustomKeyIDFormat(t *testing.T) { config := logical.TestBackendConfig()