Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add signed key constraints to SSH CA #4468

Closed
wants to merge 2 commits into from
Closed
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
54 changes: 54 additions & 0 deletions builtin/logical/ssh/backend_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import (
"fmt"
"os/user"
"reflect"
"strconv"
"testing"
"time"

"golang.org/x/crypto/ssh"

"encoding/base64"
"encoding/json"
"errors"
"strings"

Expand Down Expand Up @@ -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()

Expand Down
11 changes: 11 additions & 0 deletions builtin/logical/ssh/path_roles.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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(
Expand All @@ -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
}
Expand Down Expand Up @@ -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{}{
Expand Down
68 changes: 68 additions & 0 deletions builtin/logical/ssh/path_sign.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ package ssh

import (
"context"
"crypto/dsa"
"crypto/ecdsa"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"errors"
"fmt"
Expand All @@ -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"
)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
10 changes: 10 additions & 0 deletions builtin/logical/ssh/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"net"
Expand Down Expand Up @@ -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 {
Expand Down
5 changes: 4 additions & 1 deletion website/source/api/secret/ssh/index.html.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string|int>: "")` – Specifies a map of ssh key types
and their minimum sizes which are allowed to be signed by the CA type.

### Sample Payload

Expand Down