diff --git a/pkg/pgp/key.go b/pkg/pgp/key.go index 62c7502..ace7750 100644 --- a/pkg/pgp/key.go +++ b/pkg/pgp/key.go @@ -7,8 +7,6 @@ package pgp import ( "crypto" - "fmt" - "net/mail" "time" "github.com/ProtonMail/go-crypto/openpgp" @@ -16,12 +14,6 @@ import ( 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 @@ -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) { diff --git a/pkg/pgp/key_test.go b/pkg/pgp/key_test.go index 1ea29ee..2291a31 100644 --- a/pkg/pgp/key_test.go +++ b/pkg/pgp/key_test.go @@ -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, @@ -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) @@ -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) ", + }, + { + 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) diff --git a/pkg/pgp/validate.go b/pkg/pgp/validate.go new file mode 100644 index 0000000..f17b082 --- /dev/null +++ b/pkg/pgp/validate.go @@ -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 +}