Merge branch 'master' into hs/scep

This commit is contained in:
Herman Slatman 2021-05-06 22:00:29 +02:00
commit c04f556dc2
No known key found for this signature in database
GPG key ID: F4D8A44EA0A75A4F
14 changed files with 259 additions and 163 deletions

2
.github/needs-triage-labeler.yml vendored Normal file
View file

@ -0,0 +1,2 @@
needs triage:
- "**"

14
.github/workflows/labeler.yml vendored Normal file
View file

@ -0,0 +1,14 @@
name: labeler
on:
pull_request:
branches:
- master
jobs:
label:
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v2
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"
configuration-path: .github/needs-triage-labeler.yml

View file

@ -147,6 +147,8 @@ func LoadConfiguration(filename string) (*Config, error) {
return nil, errors.Wrapf(err, "error parsing %s", filename)
}
c.init()
return &c, nil
}

View file

@ -86,6 +86,21 @@ func (o *OIDC) IsAdmin(email string) bool {
return false
}
// IsAdminGroup returns true if the one group in the given list is in the Admins
// allowlist, false otherwise.
func (o *OIDC) IsAdminGroup(groups []string) bool {
for _, g := range groups {
// The groups and emails can be in the same array for now, but consider
// making a specialized option later.
for _, gadmin := range o.Admins {
if g == gadmin {
return true
}
}
}
return false
}
func sanitizeEmail(email string) string {
if i := strings.LastIndex(email, "@"); i >= 0 {
email = email[:i] + strings.ToLower(email[i:])
@ -372,7 +387,8 @@ func (o *OIDC) AuthorizeSSHSign(ctx context.Context, token string) ([]SignOption
}
// Get the identity using either the default identityFunc or one injected
// externally.
// externally. Note that the PreferredUsername might be empty.
// TBD: Would preferred_username present a safety issue here?
iden, err := o.getIdentityFunc(ctx, o, claims.Email)
if err != nil {
return nil, errs.Wrap(http.StatusInternalServerError, err, "oidc.AuthorizeSSHSign")
@ -395,6 +411,9 @@ func (o *OIDC) AuthorizeSSHSign(ctx context.Context, token string) ([]SignOption
// Use the default template unless no-templates are configured and email is
// an admin, in that case we will use the parameters in the request.
isAdmin := o.IsAdmin(claims.Email)
if !isAdmin && len(claims.Groups) > 0 {
isAdmin = o.IsAdminGroup(claims.Groups)
}
defaultTemplate := sshutil.DefaultTemplate
if isAdmin && !o.Options.GetSSHOptions().HasTemplate() {
defaultTemplate = sshutil.DefaultAdminTemplate

View file

@ -506,6 +506,7 @@ func TestOIDC_AuthorizeSSHSign(t *testing.T) {
p5.getIdentityFunc = func(ctx context.Context, p Interface, email string) (*Identity, error) {
return nil, errors.New("force")
}
// Additional test needed for empty usernames and duplicate email and usernames
t1, err := generateSimpleToken("the-issuer", p1.ClientID, &keys.Keys[0])
assert.FatalError(t, err)
@ -514,7 +515,7 @@ func TestOIDC_AuthorizeSSHSign(t *testing.T) {
failGetIdentityToken, err := generateSimpleToken("the-issuer", p5.ClientID, &keys.Keys[0])
assert.FatalError(t, err)
// Admin email not in domains
okAdmin, err := generateToken("subject", "the-issuer", p3.ClientID, "root@example.com", []string{}, time.Now(), &keys.Keys[0])
okAdmin, err := generateOIDCToken("subject", "the-issuer", p3.ClientID, "root@example.com", "", time.Now(), &keys.Keys[0])
assert.FatalError(t, err)
// Empty email
failEmail, err := generateToken("subject", "the-issuer", p3.ClientID, "", []string{}, time.Now(), &keys.Keys[0])
@ -576,11 +577,11 @@ func TestOIDC_AuthorizeSSHSign(t *testing.T) {
{"ok-options", p1, args{t1, SignSSHOptions{CertType: "user", Principals: []string{"name"}}, pub},
&SignSSHOptions{CertType: "user", Principals: []string{"name", "name@smallstep.com"},
ValidAfter: NewTimeDuration(tm), ValidBefore: NewTimeDuration(tm.Add(userDuration))}, http.StatusOK, false, false},
{"admin-user", p3, args{okAdmin, SignSSHOptions{CertType: "user", KeyID: "root@example.com", Principals: []string{"root", "root@example.com"}}, pub},
{"ok-admin-user", p3, args{okAdmin, SignSSHOptions{CertType: "user", KeyID: "root@example.com", Principals: []string{"root", "root@example.com"}}, pub},
expectedAdminOptions, http.StatusOK, false, false},
{"admin-host", p3, args{okAdmin, SignSSHOptions{CertType: "host", KeyID: "smallstep.com", Principals: []string{"smallstep.com"}}, pub},
{"ok-admin-host", p3, args{okAdmin, SignSSHOptions{CertType: "host", KeyID: "smallstep.com", Principals: []string{"smallstep.com"}}, pub},
expectedHostOptions, http.StatusOK, false, false},
{"admin-options", p3, args{okAdmin, SignSSHOptions{CertType: "user", KeyID: "name", Principals: []string{"name"}}, pub},
{"ok-admin-options", p3, args{okAdmin, SignSSHOptions{CertType: "user", KeyID: "name", Principals: []string{"name"}}, pub},
&SignSSHOptions{CertType: "user", Principals: []string{"name"},
ValidAfter: NewTimeDuration(tm), ValidBefore: NewTimeDuration(tm.Add(userDuration))}, http.StatusOK, false, false},
{"fail-rsa1024", p1, args{t1, SignSSHOptions{}, rsa1024.Public()}, expectedUserOptions, http.StatusOK, false, true},

View file

@ -345,33 +345,50 @@ type Permissions struct {
// GetIdentityFunc is a function that returns an identity.
type GetIdentityFunc func(ctx context.Context, p Interface, email string) (*Identity, error)
// DefaultIdentityFunc return a default identity depending on the provisioner type.
// DefaultIdentityFunc return a default identity depending on the provisioner
// type. For OIDC email is always present and the usernames might
// contain empty strings.
func DefaultIdentityFunc(ctx context.Context, p Interface, email string) (*Identity, error) {
switch k := p.(type) {
case *OIDC:
// OIDC principals would be:
// 1. Sanitized local.
// 2. Raw local (if different).
// 3. Email address.
// ~~1. Preferred usernames.~~ Note: Under discussion, currently disabled
// 2. Sanitized local.
// 3. Raw local (if different).
// 4. Email address.
name := SanitizeSSHUserPrincipal(email)
if !sshUserRegex.MatchString(name) {
return nil, errors.Errorf("invalid principal '%s' from email '%s'", name, email)
}
usernames := []string{name}
if i := strings.LastIndex(email, "@"); i >= 0 {
if local := email[:i]; !strings.EqualFold(local, name) {
usernames = append(usernames, local)
}
usernames = append(usernames, email[:i])
}
usernames = append(usernames, email)
return &Identity{
Usernames: usernames,
Usernames: SanitizeStringSlices(usernames),
}, nil
default:
return nil, errors.Errorf("provisioner type '%T' not supported by identity function", k)
}
}
// SanitizeStringSlices removes duplicated an empty strings.
func SanitizeStringSlices(original []string) []string {
output := []string{}
seen := make(map[string]struct{})
for _, entry := range original {
if entry == "" {
continue
}
if _, value := seen[entry]; !value {
seen[entry] = struct{}{}
output = append(output, entry)
}
}
return output
}
// MockProvisioner for testing
type MockProvisioner struct {
Mret1, Mret2, Mret3 interface{}

View file

@ -62,10 +62,11 @@ func TestSanitizeSSHUserPrincipal(t *testing.T) {
func TestDefaultIdentityFunc(t *testing.T) {
type test struct {
p Interface
email string
err error
identity *Identity
p Interface
email string
usernames []string
err error
identity *Identity
}
tests := map[string]func(*testing.T) test{
"fail/unsupported-provisioner": func(t *testing.T) test {
@ -106,7 +107,7 @@ func TestDefaultIdentityFunc(t *testing.T) {
return test{
p: &OIDC{},
email: "John@smallstep.com",
identity: &Identity{Usernames: []string{"john", "John@smallstep.com"}},
identity: &Identity{Usernames: []string{"john", "John", "John@smallstep.com"}},
}
},
"ok symbol": func(t *testing.T) test {
@ -116,6 +117,30 @@ func TestDefaultIdentityFunc(t *testing.T) {
identity: &Identity{Usernames: []string{"john_doe", "John+Doe", "John+Doe@smallstep.com"}},
}
},
"ok username": func(t *testing.T) test {
return test{
p: &OIDC{},
email: "john@smallstep.com",
usernames: []string{"johnny"},
identity: &Identity{Usernames: []string{"john", "john@smallstep.com"}},
}
},
"ok usernames": func(t *testing.T) test {
return test{
p: &OIDC{},
email: "john@smallstep.com",
usernames: []string{"johnny", "js", "", "johnny", ""},
identity: &Identity{Usernames: []string{"john", "john@smallstep.com"}},
}
},
"ok empty username": func(t *testing.T) test {
return test{
p: &OIDC{},
email: "john@smallstep.com",
usernames: []string{""},
identity: &Identity{Usernames: []string{"john", "john@smallstep.com"}},
}
},
}
for name, get := range tests {
t.Run(name, func(t *testing.T) {

View file

@ -773,6 +773,47 @@ func generateToken(sub, iss, aud string, email string, sans []string, iat time.T
return jose.Signed(sig).Claims(claims).CompactSerialize()
}
func generateOIDCToken(sub, iss, aud string, email string, preferredUsername string, iat time.Time, jwk *jose.JSONWebKey, tokOpts ...tokOption) (string, error) {
so := new(jose.SignerOptions)
so.WithType("JWT")
so.WithHeader("kid", jwk.KeyID)
for _, o := range tokOpts {
if err := o(so); err != nil {
return "", err
}
}
sig, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.ES256, Key: jwk.Key}, so)
if err != nil {
return "", err
}
id, err := randutil.ASCII(64)
if err != nil {
return "", err
}
claims := struct {
jose.Claims
Email string `json:"email"`
PreferredUsername string `json:"preferred_username,omitempty"`
}{
Claims: jose.Claims{
ID: id,
Subject: sub,
Issuer: iss,
IssuedAt: jose.NewNumericDate(iat),
NotBefore: jose.NewNumericDate(iat),
Expiry: jose.NewNumericDate(iat.Add(5 * time.Minute)),
Audience: []string{aud},
},
Email: email,
PreferredUsername: preferredUsername,
}
return jose.Signed(sig).Claims(claims).CompactSerialize()
}
func generateX5CSSHToken(jwk *jose.JSONWebKey, claims *x5cPayload, tokOpts ...tokOption) (string, error) {
so := new(jose.SignerOptions)
so.WithType("JWT")

View file

@ -263,7 +263,7 @@ func (a *Authority) Rekey(oldCert *x509.Certificate, pk crypto.PublicKey) ([]*x5
}
fullchain := append([]*x509.Certificate{resp.Certificate}, resp.CertificateChain...)
if err = a.storeCertificate(fullchain); err != nil {
if err = a.storeRenewedCertificate(oldCert, fullchain); err != nil {
if err != db.ErrNotImplemented {
return nil, errs.Wrap(http.StatusInternalServerError, err, "authority.Rekey; error storing certificate in db", opts...)
}
@ -287,6 +287,19 @@ func (a *Authority) storeCertificate(fullchain []*x509.Certificate) error {
return a.db.StoreCertificate(fullchain[0])
}
// storeRenewedCertificate allows to use an extension of the db.AuthDB interface
// that can log if a certificate has been renewed or rekeyed.
//
// TODO: at some point we should implement this in the standard implementation.
func (a *Authority) storeRenewedCertificate(oldCert *x509.Certificate, fullchain []*x509.Certificate) error {
if s, ok := a.db.(interface {
StoreRenewedCertificate(*x509.Certificate, ...*x509.Certificate) error
}); ok {
return s.StoreRenewedCertificate(oldCert, fullchain...)
}
return a.db.StoreCertificate(fullchain[0])
}
// RevokeOptions are the options for the Revoke API.
type RevokeOptions struct {
Serial string

View file

@ -616,6 +616,36 @@ retry:
return &sign, nil
}
// Rekey performs the rekey request to the CA and returns the api.SignResponse
// struct.
func (c *Client) Rekey(req *api.RekeyRequest, tr http.RoundTripper) (*api.SignResponse, error) {
var retried bool
body, err := json.Marshal(req)
if err != nil {
return nil, errors.Wrap(err, "error marshaling request")
}
u := c.endpoint.ResolveReference(&url.URL{Path: "/rekey"})
client := &http.Client{Transport: tr}
retry:
resp, err := client.Post(u.String(), "application/json", bytes.NewReader(body))
if err != nil {
return nil, errs.Wrapf(http.StatusInternalServerError, err, "client.Rekey; client POST %s failed", u)
}
if resp.StatusCode >= 400 {
if !retried && c.retryOnError(resp) {
retried = true
goto retry
}
return nil, readError(resp.Body)
}
var sign api.SignResponse
if err := readJSON(resp.Body, &sign); err != nil {
return nil, errs.Wrapf(http.StatusInternalServerError, err, "client.Rekey; error reading %s", u)
}
return &sign, nil
}
// Revoke performs the revoke request to the CA and returns the api.RevokeResponse
// struct.
func (c *Client) Revoke(req *api.RevokeRequest, tr http.RoundTripper) (*api.RevokeResponse, error) {

View file

@ -529,6 +529,75 @@ func TestClient_Renew(t *testing.T) {
}
}
func TestClient_Rekey(t *testing.T) {
ok := &api.SignResponse{
ServerPEM: api.Certificate{Certificate: parseCertificate(certPEM)},
CaPEM: api.Certificate{Certificate: parseCertificate(rootPEM)},
CertChainPEM: []api.Certificate{
{Certificate: parseCertificate(certPEM)},
{Certificate: parseCertificate(rootPEM)},
},
}
request := &api.RekeyRequest{
CsrPEM: api.CertificateRequest{CertificateRequest: parseCertificateRequest(csrPEM)},
}
tests := []struct {
name string
request *api.RekeyRequest
response interface{}
responseCode int
wantErr bool
err error
}{
{"ok", request, ok, 200, false, nil},
{"unauthorized", request, errs.Unauthorized("force"), 401, true, errors.New(errs.UnauthorizedDefaultMsg)},
{"empty request", &api.RekeyRequest{}, errs.BadRequest("force"), 400, true, errors.New(errs.BadRequestDefaultMsg)},
{"nil request", nil, errs.BadRequest("force"), 400, true, errors.New(errs.BadRequestDefaultMsg)},
}
srv := httptest.NewServer(nil)
defer srv.Close()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c, err := NewClient(srv.URL, WithTransport(http.DefaultTransport))
if err != nil {
t.Errorf("NewClient() error = %v", err)
return
}
srv.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
api.JSONStatus(w, tt.response, tt.responseCode)
})
got, err := c.Rekey(tt.request, nil)
if (err != nil) != tt.wantErr {
fmt.Printf("%+v", err)
t.Errorf("Client.Renew() error = %v, wantErr %v", err, tt.wantErr)
return
}
switch {
case err != nil:
if got != nil {
t.Errorf("Client.Renew() = %v, want nil", got)
}
sc, ok := err.(errs.StatusCoder)
assert.Fatal(t, ok, "error does not implement StatusCoder interface")
assert.Equals(t, sc.StatusCode(), tt.responseCode)
assert.HasPrefix(t, tt.err.Error(), err.Error())
default:
if !reflect.DeepEqual(got, tt.response) {
t.Errorf("Client.Renew() = %v, want %v", got, tt.response)
}
}
})
}
}
func TestClient_Provisioners(t *testing.T) {
ok := &api.ProvisionersResponse{
Provisioners: provisioner.List{},

View file

@ -134,6 +134,12 @@ func WriteDefaultIdentity(certChain []api.Certificate, key crypto.PrivateKey) er
return nil
}
// WriteIdentityCertificate writes the identity certificate to disk.
func WriteIdentityCertificate(certChain []api.Certificate) error {
filename := filepath.Join(identityDir, "identity.crt")
return writeCertificate(filename, certChain)
}
// writeCertificate writes the given certificate on disk.
func writeCertificate(filename string, certChain []api.Certificate) error {
buf := new(bytes.Buffer)

View file

@ -1,143 +0,0 @@
# Distribution
This section describes how to build and deploy publicly available releases of
the Step CA.
## Creating A New Release
New releases are (almost) entirely built and deployed by Travis-CI. Creating a new
release is as simple as pushing a new github tag.
**Definitions**:
* **Standard Release**: ready for public use. no `-rc*` suffix on the version.
e.g. `v1.0.2`
* **Release Candidate**: not ready for public use, still testing. must have a
`-rc*` suffix. e.g. `v1.0.2-rc` or `v1.0.2-rc.4`
---
1. **Tag it!**
1. Find the most recent tag.
```
$> git fetch --tags
$> git tag
```
The new tag needs to be the logical successor of the most recent existing tag.
See [versioning](#versioning) section for more information on version numbers.
2. Select the type and value of the next tag.
Is the new release a *release candidate* or a *standard release*?
1. **Release Candidate**
If the most recent tag is a standard release, say `v1.0.2`, then the version
of the next release candidate should be `v1.0.3-rc.1`. If the most recent tag
is a release candidate, say `v1.0.2-rc.3`, then the version of the next
release candidate should be `v1.0.2-rc.4`.
2. Standard Release
If the most recent tag is a standard release, say `v1.0.2`, then the version
of the next standard release should be `v1.0.3`. If the most recent tag
is a release candidate, say `v1.0.2-rc.3`, then the version of the next
standard release should be `v1.0.3`.
3. Create a local tag.
```
# standard release
$> git tag v1.0.3
...or
# release candidate
$> git tag v1.0.3-rc.1
```
4. Push the new tag to the remote origin.
```
# standard release
$> git push origin tag v1.0.3
...or
# release candidate
$> git push origin tag v1.0.3-rc.1
```
2. **Check the build status at**
[Travis-CI](https://travis-ci.com/smallstep/certificates/builds/).
Travis will begin by verifying that there are no compilation or linting errors
and then run the unit tests. Assuming all the checks have passed, Travis will
build Darwin and Linux artifacts (for easily installing `step`) and upload them
as part of the [Github Release](https://github.com/smallstep/certificates/releases).
Travis will build and upload the following artifacts:
* **step-ca_1.0.3_amd64.deb**: debian package for installation on linux.
* **step-ca_linux_1.0.3_amd64.tar.gz**: tarball containing a statically compiled linux binary.
* **step-ca_darwin_1.0.3_amd64.tar.gz**: tarball containing a statically compiled darwin binary.
* **step-ca_1.0.3.tar.gz**: tarball containing a git archive of the full repo.
3. **Update the AUR Arch Linux package**
> **NOTE**: if you plan to release `cli` next then you can skip this step.
<pre><code>
<b>$ cd archlinux</b>
# Get up to date...
<b>$ git pull origin master</b>
<b>$ make</b>
<b>$ ./update --ca v1.0.3</b>
</code></pre>
4. **Update the Helm packages**
> **NOTE**: This is an optional step, only necessary if we want to release a
> new helm package.
Once we have the docker images, we can release a new version in our Helm
[repository](https://smallstep.github.io/helm-charts/) we need to pull the
[helm-charts](https://github.com/smallstep/helm-charts) project, and change the
following:
* On step-certificates/Chart.yaml:
* Increase the `version` number (Helm Chart version).
* Set the `appVersion` to the step-certificates version.
* On step-certificates/values.yaml:
* Set the docker tag `image.tag` to the appropriate version.
Then create the step-certificates package running:
<pre><code>
<b>$ helm package ./step-certificates</b>
</code></pre>
A new file like `step-certificates-<version>.tgz` will be created.
Now commit and push your changes (don't commit the tarball) to the master
branch of `smallstep/helm-charts`
Next checkout the `gh-pages` branch. `git add` the new tar-ball and update
the index.yaml using the `helm repo index` command:
<pre><code>
<b>$ git checkout gh-pages</b>
<b>$ git pull origin gh-pages</b>
<b>$ git add "step-certificates-<version>.tgz"</b>
<b>$ helm repo index --merge index.yaml --url https://smallstep.github.io/helm-charts/ .</b>
<b>$ git commit -a -m "Add package for step-certificates vX.Y.Z"</b>
<b>$ git push origin gh-pages</b>
</code></pre>
***All Done!***
## Versioning
We use [SemVer](http://semver.org/) for versioning. See the
[tags on this repository](https://github.com/smallstep/certificates) for all
available versions.

View file

@ -26,7 +26,7 @@ ExecStart=/usr/bin/step ca renew --force $CERT_LOCATION $KEY_LOCATION
; Try to reload or restart the systemd service that relies on this cert-renewer
; If the relying service doesn't exist, forge ahead.
ExecStartPost=-systemctl try-reload-or-restart %i
ExecStartPost=/usr/bin/env bash -c "if ! systemctl --quiet is-enabled %i.service ; then exit 0; fi; systemctl try-reload-or-restart %i"
[Install]
WantedBy=multi-user.target