Skip to content

Commit

Permalink
feat(signer): add vault pki signer provider
Browse files Browse the repository at this point in the history
  • Loading branch information
mikhailswift committed Aug 8, 2023
1 parent 47e1475 commit cd0c222
Show file tree
Hide file tree
Showing 4 changed files with 553 additions and 0 deletions.
1 change: 1 addition & 0 deletions imports.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,5 @@ import (
_ "github.com/testifysec/go-witness/signer/file"
_ "github.com/testifysec/go-witness/signer/fulcio"
_ "github.com/testifysec/go-witness/signer/spiffe"
_ "github.com/testifysec/go-witness/signer/vault"
)
104 changes: 104 additions & 0 deletions signer/vault/request.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// Copyright 2023 The Witness Contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package vault

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
)

// see https://developer.hashicorp.com/vault/api-docs/secret/pki#issuing-certificates
// for information on the following structs and requests

type issueRequest struct {
CommonName string `json:"common_name,omitempty"`
AltNames []string `json:"alt_names,omitempty"`
Ttl time.Duration `json:"ttl,omitempty"`
RemoveRootsFromChain bool `json:"remove_roots_from_chain,omitempty"`
}

type issueResponseData struct {
Certificate string `json:"certificate"`
IssuingCa string `json:"issuing_ca"`
CaChain []string `json:"ca_chain"`
PrivateKey string `json:"private_key"`
PrivateKeyType string `json:"private_key_type"`
SerialNumber string `json:"serial_number"`
}

type issueResponse struct {
LeaseID string `json:"lease_id"`
Renewable bool `json:"renewable"`
LeaseDuration int `json:"lease_duration"`
Warnings string `json:"warnings"`
Data issueResponseData `json:"data"`
}

func (vsp *VaultSignerProvider) requestCertificate(ctx context.Context) (issueResponse, error) {
url, err := url.JoinPath(vsp.url, "v1", vsp.pkiSecretsEnginePath, "issue", vsp.role)
if err != nil {
return issueResponse{}, err
}

buf := &bytes.Buffer{}
enc := json.NewEncoder(buf)
if err := enc.Encode(issueRequest{
CommonName: vsp.commonName,
AltNames: vsp.altNames,
Ttl: vsp.ttl,
RemoveRootsFromChain: true,
}); err != nil {
return issueResponse{}, err
}

req, err := http.NewRequestWithContext(ctx, "POST", url, buf)
if err != nil {
return issueResponse{}, err
}

req.Header.Set("X-Vault-Token", vsp.token)
if len(vsp.namespace) > 0 {
req.Header.Set("X-Vault-Namespace", vsp.namespace)
}

hc := &http.Client{}
resp, err := hc.Do(req)
if err != nil {
return issueResponse{}, err
}

defer resp.Body.Close()
respBytes, err := io.ReadAll(resp.Body)
if err != nil {
return issueResponse{}, err
}

if resp.StatusCode != http.StatusOK {
return issueResponse{}, fmt.Errorf("failed to issue new certificate: %s", respBytes)
}

issueResp := issueResponse{}
if err := json.Unmarshal(respBytes, &issueResp); err != nil {
return issueResp, err
}

return issueResp, nil
}
262 changes: 262 additions & 0 deletions signer/vault/signerprovider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
// Copyright 2023 The Witness Contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package vault

import (
"context"
"crypto/x509"
"fmt"
"strings"
"time"

"github.com/testifysec/go-witness/cryptoutil"
"github.com/testifysec/go-witness/registry"
"github.com/testifysec/go-witness/signer"
)

const (
defaultPkiSecretsEnginePath = "pki"
)

func init() {
signer.Register("vault", func() signer.SignerProvider { return New() },
registry.StringConfigOption(
"url",
"Base url of the Vault instance to connect to",
"",
func(sp signer.SignerProvider, url string) (signer.SignerProvider, error) {
vsp, ok := sp.(*VaultSignerProvider)
if !ok {
return sp, fmt.Errorf("provided signer provider is not a vault signer provider")
}

WithUrl(url)(vsp)
return vsp, nil
},
),
registry.StringConfigOption(
"pki-secrets-engine-path",
"Path to the Vault PKI Secrets Engine to use",
defaultPkiSecretsEnginePath,
func(sp signer.SignerProvider, pkiSecretsEnginePath string) (signer.SignerProvider, error) {
vsp, ok := sp.(*VaultSignerProvider)
if !ok {
return sp, fmt.Errorf("provided signer provider is not a vault signer provider")
}

WithPkiSecretsEnginePath(pkiSecretsEnginePath)(vsp)
return vsp, nil
},
),

registry.StringConfigOption(
"token",
"Token to use to connect to Vault",
"",
func(sp signer.SignerProvider, token string) (signer.SignerProvider, error) {
vsp, ok := sp.(*VaultSignerProvider)
if !ok {
return sp, fmt.Errorf("provided signer provider is not a vault signer provider")
}

WithToken(token)(vsp)
return vsp, nil
},
),
registry.StringConfigOption(
"namespace",
"Vault namespace to use",
"",
func(sp signer.SignerProvider, namespace string) (signer.SignerProvider, error) {
vsp, ok := sp.(*VaultSignerProvider)
if !ok {
return sp, fmt.Errorf("provided signer provider is not a vault signer provider")
}

WithNamespace(namespace)(vsp)
return vsp, nil
},
),
registry.StringConfigOption(
"role",
"Name of the Vault role to generate the certificate for",
"",
func(sp signer.SignerProvider, role string) (signer.SignerProvider, error) {
vsp, ok := sp.(*VaultSignerProvider)
if !ok {
return sp, fmt.Errorf("provided signer provider is not a vault signer provider")
}

WithRole(role)(vsp)
return vsp, nil
},
),
registry.StringConfigOption(
"commonname",
"Common name to use for the generated certificate. Must be allowed by the vault role policy",
"",
func(sp signer.SignerProvider, cn string) (signer.SignerProvider, error) {
vsp, ok := sp.(*VaultSignerProvider)
if !ok {
return sp, fmt.Errorf("provided signer provider is not a vault signer provider")
}

WithCommonName(cn)(vsp)
return vsp, nil
},
),
registry.StringSliceConfigOption(
"altnames",
"Alt names to use for the generated certificate. All alt names must be allowed by the vault role policy",
[]string{},
func(sp signer.SignerProvider, ans []string) (signer.SignerProvider, error) {
vsp, ok := sp.(*VaultSignerProvider)
if !ok {
return sp, fmt.Errorf("provided signer provider is not a vault signer provider")
}

WithAltNames(ans)(vsp)
return vsp, nil
},
),
registry.DurationConfigOption(
"ttl",
"Time to live for the generated certificate. Defaults to the vault role policy's configured TTL if not provided",
time.Duration(0),
func(sp signer.SignerProvider, ttl time.Duration) (signer.SignerProvider, error) {
vsp, ok := sp.(*VaultSignerProvider)
if !ok {
return sp, fmt.Errorf("provided signer provider is not a vault signer provider")
}

WithTtl(ttl)(vsp)
return vsp, nil
},
),
)
}

type VaultSignerProvider struct {
requestIssuer func(context.Context) (issueResponse, error)
url string
pkiSecretsEnginePath string
token string
namespace string
role string
commonName string
altNames []string
ttl time.Duration
}

type Option func(*VaultSignerProvider)

func WithUrl(url string) Option {
return func(vsp *VaultSignerProvider) {
vsp.url = url
}
}

func WithPkiSecretsEnginePath(pkiSecretsEnginePath string) Option {
return func(vsp *VaultSignerProvider) {
vsp.pkiSecretsEnginePath = pkiSecretsEnginePath
}
}

func WithToken(token string) Option {
return func(vsp *VaultSignerProvider) {
vsp.token = token
}
}

func WithNamespace(namespace string) Option {
return func(vsp *VaultSignerProvider) {
vsp.namespace = namespace
}
}

func WithRole(role string) Option {
return func(vsp *VaultSignerProvider) {
vsp.role = role
}
}

func WithCommonName(cn string) Option {
return func(vsp *VaultSignerProvider) {
vsp.commonName = cn
}
}

func WithAltNames(ans []string) Option {
return func(vsp *VaultSignerProvider) {
vsp.altNames = ans
}
}

func WithTtl(ttl time.Duration) Option {
return func(vsp *VaultSignerProvider) {
vsp.ttl = ttl
}
}

func New(opts ...Option) *VaultSignerProvider {
vsp := VaultSignerProvider{}
vsp.requestIssuer = vsp.requestCertificate

for _, opt := range opts {
opt(&vsp)
}

return &vsp
}

func (vsp *VaultSignerProvider) Signer(ctx context.Context) (cryptoutil.Signer, error) {
if len(vsp.url) == 0 {
return nil, fmt.Errorf("url is a required option")
}

if len(vsp.token) == 0 {
return nil, fmt.Errorf("token is a required option")
}

if len(vsp.role) == 0 {
return nil, fmt.Errorf("role is a required option")
}

resp, err := vsp.requestIssuer(ctx)
if err != nil {
return nil, fmt.Errorf("failed to issue certificate: %w", err)
}

cert, err := cryptoutil.TryParseCertificate([]byte(resp.Data.Certificate))
if err != nil {
return nil, fmt.Errorf("could not parse certificate from response: %w", err)
}

intermediates := make([]*x509.Certificate, 0)
for _, i := range resp.Data.CaChain {
intermediate, err := cryptoutil.TryParseCertificate([]byte(i))
if err != nil {
return nil, fmt.Errorf("could not parse intermediate certificate from response: %w", err)
}

intermediates = append(intermediates, intermediate)
}

return cryptoutil.NewSignerFromReader(
strings.NewReader(resp.Data.PrivateKey),
cryptoutil.SignWithCertificate(cert),
cryptoutil.SignWithIntermediates(intermediates),
)
}
Loading

0 comments on commit cd0c222

Please sign in to comment.