Skip to content

Commit

Permalink
feat: allow custom validations on PGP key
Browse files Browse the repository at this point in the history
Allow custom max allowed key lifetime, clock skew and email validation for the PGP keys.

Signed-off-by: Utku Ozdemir <utku.ozdemir@siderolabs.com>
  • Loading branch information
utkuozdemir committed Jan 3, 2023
1 parent 63d4da3 commit 69886dc
Show file tree
Hide file tree
Showing 3 changed files with 165 additions and 69 deletions.
54 changes: 0 additions & 54 deletions pkg/pgp/key.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,13 @@ package pgp

import (
"crypto"
"fmt"
"net/mail"
"time"

"github.com/ProtonMail/go-crypto/openpgp"
"github.com/ProtonMail/go-crypto/openpgp/packet"
pgpcrypto "github.com/ProtonMail/gopenpgp/v2/crypto"
)

// Time-related key settings.
const (
MaxAllowedLifetime = 8 * time.Hour
AllowedClockSkew = 5 * time.Minute
)

// Key represents a PGP key. It can be a public key or a private & public key pair.
type Key struct {
key *pgpcrypto.Key
Expand Down Expand Up @@ -126,52 +118,6 @@ func (p *Key) IsExpired(clockSkew time.Duration) bool {
return expired(now.Add(clockSkew)) && expired(now.Add(-clockSkew))
}

// Validate validates the key.
func (p *Key) Validate() error {
if p.key.IsRevoked() {
return fmt.Errorf("key is revoked")
}

entity := p.key.GetEntity()
if entity == nil {
return fmt.Errorf("key does not contain an entity")
}

identity := entity.PrimaryIdentity()
if identity == nil {
return fmt.Errorf("key does not contain a primary identity")
}

if p.IsExpired(AllowedClockSkew) {
return fmt.Errorf("key expired")
}

_, err := mail.ParseAddress(identity.Name)
if err != nil {
return fmt.Errorf("key does not contain a valid email address: %w: %s", err, identity.Name)
}

return p.validateLifetime()
}

func (p *Key) validateLifetime() error {
entity := p.key.GetEntity()
identity := entity.PrimaryIdentity()
sig := identity.SelfSignature

if sig.KeyLifetimeSecs == nil || *sig.KeyLifetimeSecs == 0 {
return fmt.Errorf("key does not contain a valid key lifetime")
}

expiration := time.Now().Add(MaxAllowedLifetime)

if !entity.PrimaryKey.KeyExpired(sig, expiration) {
return fmt.Errorf("key lifetime is too long: %s", time.Duration(*sig.KeyLifetimeSecs)*time.Second)
}

return nil
}

// generateEntity generates a new PGP entity.
// Adapted from crypto.generateKey to be able to set the expiration.
func generateEntity(name, comment, email string, lifetimeSecs uint32) (*openpgp.Entity, error) {
Expand Down
70 changes: 55 additions & 15 deletions pkg/pgp/key_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ func TestKeyFlow(t *testing.T) {
assert.Error(t, key.Verify(message, signature[:len(signature)-1]))
}

func genKey(t *testing.T, lifetimeSecs uint32, now func() time.Time) *pgp.Key {
func genKey(t *testing.T, lifetimeSecs uint32, email string, now func() time.Time) *pgp.Key {
cfg := &packet.Config{
Algorithm: packet.PubKeyAlgoEdDSA,
DefaultHash: crypto.SHA256,
Expand All @@ -46,7 +46,7 @@ func genKey(t *testing.T, lifetimeSecs uint32, now func() time.Time) *pgp.Key {
Time: now,
}

entity, err := openpgp.NewEntity("test", "test", "keytest@example.com", cfg)
entity, err := openpgp.NewEntity("test", "test", email, cfg)
require.NoError(t, err)

key, err := pgpcrypto.NewKeyFromEntity(entity)
Expand All @@ -58,55 +58,95 @@ func genKey(t *testing.T, lifetimeSecs uint32, now func() time.Time) *pgp.Key {
return pgpKey
}

func TestKeyExpiration(t *testing.T) {
func TestKeyValidation(t *testing.T) {
for _, tt := range []struct { //nolint:govet
name string
lifetime time.Duration
shift time.Duration
expectedError string
email string
opts []pgp.ValidationOption
}{
{
name: "no expiration",
email: "keytest@example.com",
expectedError: "key does not contain a valid key lifetime",
},
{
name: "expiration too long",
lifetime: pgp.MaxAllowedLifetime + 1*time.Hour,
email: "keytest@example.com",
lifetime: pgp.DefaultMaxAllowedLifetime + 1*time.Hour,
expectedError: "key lifetime is too long: 9h0m0s",
},
{
name: "generated in the future",
lifetime: pgp.MaxAllowedLifetime / 2,
shift: pgp.AllowedClockSkew * 2,
email: "keytest@example.com",
lifetime: pgp.DefaultMaxAllowedLifetime / 2,
shift: pgp.DefaultAllowedClockSkew * 2,
expectedError: "key expired",
},
{
name: "generated in the future - custom skew validation",
email: "keytest@example.com",
lifetime: pgp.DefaultMaxAllowedLifetime / 2,
shift: pgp.DefaultAllowedClockSkew * 2,
opts: []pgp.ValidationOption{
pgp.WithAllowedClockSkew(pgp.DefaultAllowedClockSkew * 3),
},
},
{
name: "already expired",
lifetime: pgp.MaxAllowedLifetime / 2,
shift: -pgp.AllowedClockSkew*2 - pgp.MaxAllowedLifetime/2,
email: "keytest@example.com",
lifetime: pgp.DefaultMaxAllowedLifetime / 2,
shift: -pgp.DefaultAllowedClockSkew*2 - pgp.DefaultMaxAllowedLifetime/2,
expectedError: "key expired",
},
{
name: "within clock skew -",
lifetime: pgp.MaxAllowedLifetime / 2,
shift: -pgp.AllowedClockSkew / 2,
email: "keytest@example.com",
lifetime: pgp.DefaultMaxAllowedLifetime / 2,
shift: -pgp.DefaultAllowedClockSkew / 2,
},
{
name: "within clock skew +",
lifetime: pgp.MaxAllowedLifetime / 2,
shift: pgp.AllowedClockSkew / 2,
email: "keytest@example.com",
lifetime: pgp.DefaultMaxAllowedLifetime / 2,
shift: pgp.DefaultAllowedClockSkew / 2,
},
{
name: "short-lived key",
lifetime: pgp.AllowedClockSkew / 2,
email: "keytest@example.com",
lifetime: pgp.DefaultAllowedClockSkew / 2,
},
{
name: "long-lived key - custom lifetime validation",
email: "keytest@example.com",
lifetime: 30 * 24 * time.Hour,
opts: []pgp.ValidationOption{
pgp.WithMaxAllowedLifetime(31 * 24 * time.Hour),
},
},
{
name: "invalid email",
email: "invalid",
lifetime: pgp.DefaultMaxAllowedLifetime / 2,
expectedError: "key does not contain a valid email address: mail: missing @ in addr-spec: test (test) <invalid>",
},
{
name: "invalid email - skipped validation",
email: "invalid",
lifetime: pgp.DefaultMaxAllowedLifetime / 2,
opts: []pgp.ValidationOption{
pgp.WithValidEmailAsName(false),
},
},
} {
t.Run(tt.name, func(t *testing.T) {
key := genKey(t, uint32(tt.lifetime/time.Second), func() time.Time {
key := genKey(t, uint32(tt.lifetime/time.Second), tt.email, func() time.Time {
return time.Now().Add(tt.shift)
})

err := key.Validate()
err := key.Validate(tt.opts...)

if tt.expectedError != "" {
assert.Error(t, err)
Expand Down
110 changes: 110 additions & 0 deletions pkg/pgp/validate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.

package pgp

import (
"fmt"
"net/mail"
"time"
)

// Key validation defaults.
const (
DefaultMaxAllowedLifetime = 8 * time.Hour
DefaultAllowedClockSkew = 5 * time.Minute
DefaultValidEmailAsName = true
)

type validationOptions struct {
maxAllowedLifetime time.Duration
validEmailAsName bool
allowedClockSkew time.Duration
}

func newDefaultValidationOptions() validationOptions {
return validationOptions{
maxAllowedLifetime: DefaultMaxAllowedLifetime,
allowedClockSkew: DefaultAllowedClockSkew,
validEmailAsName: DefaultValidEmailAsName,
}
}

// ValidationOption represents a functional validation option.
type ValidationOption func(*validationOptions)

// WithMaxAllowedLifetime customizes the max allowed key lifetime in the validation.
func WithMaxAllowedLifetime(maxAllowedLifetime time.Duration) ValidationOption {
return func(o *validationOptions) {
o.maxAllowedLifetime = maxAllowedLifetime
}
}

// WithValidEmailAsName sets whether the validation should be performed on the name to be a valid email address.
func WithValidEmailAsName(validEmailAsName bool) ValidationOption {
return func(o *validationOptions) {
o.validEmailAsName = validEmailAsName
}
}

// WithAllowedClockSkew sets the allowed clock skew in the key expiration validation.
func WithAllowedClockSkew(allowedClockSkew time.Duration) ValidationOption {
return func(o *validationOptions) {
o.allowedClockSkew = allowedClockSkew
}
}

// Validate validates the key.
func (p *Key) Validate(opt ...ValidationOption) error {
options := newDefaultValidationOptions()

for _, o := range opt {
o(&options)
}

if p.key.IsRevoked() {
return fmt.Errorf("key is revoked")
}

entity := p.key.GetEntity()
if entity == nil {
return fmt.Errorf("key does not contain an entity")
}

identity := entity.PrimaryIdentity()
if identity == nil {
return fmt.Errorf("key does not contain a primary identity")
}

if p.IsExpired(options.allowedClockSkew) {
return fmt.Errorf("key expired")
}

if options.validEmailAsName {
_, err := mail.ParseAddress(identity.Name)
if err != nil {
return fmt.Errorf("key does not contain a valid email address: %w: %s", err, identity.Name)
}
}

return p.validateLifetime(&options)
}

func (p *Key) validateLifetime(opts *validationOptions) error {
entity := p.key.GetEntity()
identity := entity.PrimaryIdentity()
sig := identity.SelfSignature

if sig.KeyLifetimeSecs == nil || *sig.KeyLifetimeSecs == 0 {
return fmt.Errorf("key does not contain a valid key lifetime")
}

expiration := time.Now().Add(opts.maxAllowedLifetime)

if !entity.PrimaryKey.KeyExpired(sig, expiration) {
return fmt.Errorf("key lifetime is too long: %s", time.Duration(*sig.KeyLifetimeSecs)*time.Second)
}

return nil
}

0 comments on commit 69886dc

Please sign in to comment.