Skip to content

Commit

Permalink
Merge pull request #47 from mikesimons/finer-expiry
Browse files Browse the repository at this point in the history
Adds expiry parser with ability to specify hours, days, months and years
  • Loading branch information
csstaub authored Sep 20, 2017
2 parents 610ca27 + 8a56a6e commit 20bf3b1
Show file tree
Hide file tree
Showing 11 changed files with 204 additions and 20 deletions.
51 changes: 51 additions & 0 deletions cmd/expiry.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package cmd

import (
"fmt"
"regexp"
"strconv"
"time"
)

var nowFunc = time.Now

func parseExpiry(fromNow string) (time.Time, error) {
now := nowFunc().UTC()
re := regexp.MustCompile("\\s*(\\d+)\\s*(day|month|year|hour)s?")
matches := re.FindAllStringSubmatch(fromNow, -1)
addDate := map[string]int{
"day": 0,
"month": 0,
"year": 0,
"hour": 0,
}
for _, r := range matches {
number, err := strconv.ParseInt(r[1], 10, 32)
if err != nil {
return now, err
}
addDate[r[2]] = int(number)
}

// Ensure that we do not overflow time.Duration.
// Doing so is silent and causes signed integer overflow like issues.
if _, err := time.ParseDuration(fmt.Sprintf("%dh", addDate["hour"])); err != nil {
return now, fmt.Errorf("hour unit too large to process")
}

result := now.
AddDate(addDate["year"], addDate["month"], addDate["day"]).
Add(time.Duration(addDate["hour"]) * time.Hour)

if now == result {
return now, fmt.Errorf("invalid or empty format")
}

// ASN.1 (encoding format used by SSL) only supports up to year 9999
// https://www.openssl.org/docs/man1.1.0/crypto/ASN1_TIME_check.html
if result.Year() > 9999 {
return now, fmt.Errorf("proposed date too far in to the future: %s. Expiry year must be less than or equal to 9999", result)
}

return result, nil
}
104 changes: 104 additions & 0 deletions cmd/expiry_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package cmd

import (
"fmt"
"regexp"
"testing"
"time"
)

const dateFormat = "2006-01-02"

func init() {
nowFunc = func() time.Time {
t, _ := time.Parse(dateFormat, "2017-01-01")
return t
}
}

func TestParseExpiryWithDays(t *testing.T) {
t1, _ := parseExpiry("1 day")
t2, _ := parseExpiry("1 days")
expected, _ := time.Parse(dateFormat, "2017-01-02")

if t1 != expected {
t.Fatalf("Parsing expiry 1 day from now (singular) did not return expected value (wanted: %s, got: %s)", expected, t1)
}

if t2 != expected {
t.Fatalf("Parsing expiry 1 day from now (plural) did not return expected value (wanted: %s, got: %s)", expected, t2)
}
}

func TestParseExpiryWithMonths(t *testing.T) {
t1, _ := parseExpiry("1 month")
t2, _ := parseExpiry("1 months")
expected, _ := time.Parse(dateFormat, "2017-02-01")

if t1 != expected {
t.Fatalf("Parsing expiry 1 month from now (singular) did not return expected value (wanted: %s, got: %s)", expected, t1)
}

if t2 != expected {
t.Fatalf("Parsing expiry 1 month from now (plural) did not return expected value (wanted: %s, got: %s)", expected, t2)
}
}

func TestParseExpiryWithYears(t *testing.T) {
t1, _ := parseExpiry("1 year")
t2, _ := parseExpiry("1 years")
expected, _ := time.Parse(dateFormat, "2018-01-01")

if t1 != expected {
t.Fatalf("Parsing expiry 1 year from now (singular) did not return expected value (wanted: %s, got: %s)", expected, t1)
}

if t2 != expected {
t.Fatalf("Parsing expiry 1 year from now (plural) did not return expected value (wanted: %s, got: %s)", expected, t2)
}
}

func TestParseExpiryWithMixed(t *testing.T) {
t1, _ := parseExpiry("2 days 3 months 1 year")
t2, _ := parseExpiry("5 years 5 days 6 months")
expectedt1, _ := time.Parse(dateFormat, "2018-04-03")
expectedt2, _ := time.Parse(dateFormat, "2022-07-06")

if t1 != expectedt1 {
t.Fatalf("Parsing expiry for mixed format t1 did not return expected value (wanted: %s, got: %s)", expectedt1, t1)
}

if t2 != expectedt2 {
t.Fatalf("Parsing expiry for mixed format t2 did not return expected value (wanted: %s, got: %s)", expectedt2, t2)
}
}

func TestParseInvalidExpiry(t *testing.T) {
errorTime := onlyTime(time.Parse(dateFormat, "2017-01-01"))
cases := []struct {
Input string
Expected time.Time
ExpectedErr string
}{
{"53257284647843897", errorTime, "invalid or empty format"},
{"5y", errorTime, "invalid or empty format"},
{"53257284647843897 days", errorTime, ".*value out of range"},
{"2147483647 hours", errorTime, ".*hour unit too large.*"},
{"2147483647 days", errorTime, ".*proposed date too far in to the future.*"},
}

for _, c := range cases {
result, err := parseExpiry(c.Input)
if result != c.Expected {
t.Fatalf("Invalid expiry '%s' did not have expected value (wanted: %s, got: %s)", c.Input, c.Expected, result)
}

if match, _ := regexp.MatchString(c.ExpectedErr, fmt.Sprintf("%s", err)); !match {
t.Fatalf("Invalid expiry '%s' did not have expected error (wanted: %s, got: %s)", c.Input, c.ExpectedErr, err)
}
}
}

func onlyTime(a time.Time, b error) time.Time {
return a
}
22 changes: 18 additions & 4 deletions cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ func NewInitCommand() cli.Command {
Flags: []cli.Flag{
cli.StringFlag{"passphrase", "", "Passphrase to encrypt private-key PEM block", ""},
cli.IntFlag{"key-bits", 4096, "Bit size of RSA keypair to generate", ""},
cli.IntFlag{"years", 10, "How long until the CA certificate expires", ""},
cli.IntFlag{"years", 0, "DEPRECATED; Use --expires instead", ""},
cli.StringFlag{"expires", "18 months", "How long until the certificate expires. Example: 1 year 2 days 3 months 4 hours", ""},
cli.StringFlag{"organization, o", "", "CA Certificate organization", ""},
cli.StringFlag{"organizational-unit, ou", "", "CA Certificate organizational unit", ""},
cli.StringFlag{"country, c", "", "CA Certificate country", ""},
Expand Down Expand Up @@ -65,8 +66,21 @@ func initAction(c *cli.Context) {
os.Exit(1)
}

var passphrase []byte
var err error
expires := c.String("expires")
if years := c.Int("years"); years != 0 {
expires = fmt.Sprintf("%s %s years", expires, years)
}

// Expiry parsing is a naive regex implementation
// Token based parsing would provide better feedback but
expiresTime, err := parseExpiry(expires)
if err != nil {
fmt.Fprintf(os.Stderr, "Invalid expiry: %s\n", err)
os.Exit(1)
}

var passphrase []byte
if c.IsSet("passphrase") {
passphrase = []byte(c.String("passphrase"))
} else {
Expand Down Expand Up @@ -99,7 +113,7 @@ func initAction(c *cli.Context) {
}
}

crt, err := pkix.CreateCertificateAuthority(key, c.String("organizational-unit"), c.Int("years"), c.String("organization"), c.String("country"), c.String("province"), c.String("locality"), c.String("common-name"))
crt, err := pkix.CreateCertificateAuthority(key, c.String("organizational-unit"), expiresTime, c.String("organization"), c.String("country"), c.String("province"), c.String("locality"), c.String("common-name"))
if err != nil {
fmt.Fprintln(os.Stderr, "Create certificate error:", err)
os.Exit(1)
Expand Down Expand Up @@ -130,7 +144,7 @@ func initAction(c *cli.Context) {
}

// Create an empty CRL, this is useful for Java apps which mandate a CRL.
crl, err := pkix.CreateCertificateRevocationList(key, crt, c.Int("years"))
crl, err := pkix.CreateCertificateRevocationList(key, crt, expiresTime)
if err != nil {
fmt.Fprintln(os.Stderr, "Create CRL error:", err)
os.Exit(1)
Expand Down
21 changes: 18 additions & 3 deletions cmd/sign.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ func NewSignCommand() cli.Command {
Description: "Sign certificate request with CA, and generate certificate for the host.",
Flags: []cli.Flag{
cli.StringFlag{"passphrase", "", "Passphrase to decrypt private-key PEM block of CA", ""},
cli.IntFlag{"years", 2, "How long until the certificate expires", ""},
cli.IntFlag{"years", 0, "DEPRECATED; Use --expires instead", ""},
cli.StringFlag{"expires", "2 years", "How long until the certificate expires. Example: 1 year 2 days 3 months 4 hours", ""},
cli.StringFlag{"CA", "", "CA to sign cert", ""},
cli.BoolFlag{"stdout", "Print certificate to stdout in addition to saving file", ""},
cli.BoolFlag{"intermediate", "Generated certificate should be a intermediate", ""},
Expand All @@ -49,6 +50,7 @@ func newSignAction(c *cli.Context) {
fmt.Fprintln(os.Stderr, "One host name must be provided.")
os.Exit(1)
}

formattedReqName := strings.Replace(c.Args()[0], " ", "_", -1)
formattedCAName := strings.Replace(c.String("CA"), " ", "_", -1)

Expand All @@ -57,6 +59,19 @@ func newSignAction(c *cli.Context) {
os.Exit(1)
}

expires := c.String("expires")
if years := c.Int("years"); years != 0 {
expires = fmt.Sprintf("%s %s years", expires, years)
}

// Expiry parsing is a naive regex implementation
// Token based parsing would provide better feedback but
expiresTime, err := parseExpiry(expires)
if err != nil {
fmt.Fprintf(os.Stderr, "Invalid expiry: %s\n", err)
os.Exit(1)
}

csr, err := depot.GetCertificateSigningRequest(d, formattedReqName)
if err != nil {
fmt.Fprintln(os.Stderr, "Get certificate request error:", err)
Expand Down Expand Up @@ -93,9 +108,9 @@ func newSignAction(c *cli.Context) {
var crtOut *pkix.Certificate
if c.Bool("intermediate") {
fmt.Fprintf(os.Stderr, "Building intermediate")
crtOut, err = pkix.CreateIntermediateCertificateAuthority(crt, key, csr, c.Int("years"))
crtOut, err = pkix.CreateIntermediateCertificateAuthority(crt, key, csr, expiresTime)
} else {
crtOut, err = pkix.CreateCertificateHost(crt, key, csr, c.Int("years"))
crtOut, err = pkix.CreateCertificateHost(crt, key, csr, expiresTime)
}

if err != nil {
Expand Down
7 changes: 3 additions & 4 deletions pkix/cert_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,13 @@ var (

// CreateCertificateAuthority creates Certificate Authority using existing key.
// CertificateAuthorityInfo returned is the extra infomation required by Certificate Authority.
func CreateCertificateAuthority(key *Key, organizationalUnit string, years int, organization string, country string, province string, locality string, commonName string) (*Certificate, error) {
func CreateCertificateAuthority(key *Key, organizationalUnit string, expiry time.Time, organization string, country string, province string, locality string, commonName string) (*Certificate, error) {
subjectKeyID, err := GenerateSubjectKeyID(key.Public)
if err != nil {
return nil, err
}
authTemplate.SubjectKeyId = subjectKeyID
authTemplate.NotAfter = time.Now().AddDate(years, 0, 0).UTC()
authTemplate.NotAfter = expiry
if len(country) > 0 {
authTemplate.Subject.Country = []string{country}
}
Expand Down Expand Up @@ -113,7 +113,7 @@ func CreateCertificateAuthority(key *Key, organizationalUnit string, years int,

// CreateIntermediateCertificateAuthority creates an intermediate
// CA certificate signed by the given authority.
func CreateIntermediateCertificateAuthority(crtAuth *Certificate, keyAuth *Key, csr *CertificateSigningRequest, years int) (*Certificate, error) {
func CreateIntermediateCertificateAuthority(crtAuth *Certificate, keyAuth *Key, csr *CertificateSigningRequest, proposedExpiry time.Time) (*Certificate, error) {
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil {
Expand All @@ -130,7 +130,6 @@ func CreateIntermediateCertificateAuthority(crtAuth *Certificate, keyAuth *Key,
authTemplate.RawSubject = rawCsr.RawSubject

caExpiry := time.Now().Add(crtAuth.GetExpirationDuration())
proposedExpiry := time.Now().AddDate(years, 0, 0).UTC()
// ensure cert doesn't expire after issuer
if caExpiry.Before(proposedExpiry) {
authTemplate.NotAfter = caExpiry
Expand Down
2 changes: 1 addition & 1 deletion pkix/cert_auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ func TestCreateCertificateAuthority(t *testing.T) {
t.Fatal("Failed creating rsa key:", err)
}

crt, err := CreateCertificateAuthority(key, "OU", 5, "test", "US", "California", "San Francisco", "CA Name")
crt, err := CreateCertificateAuthority(key, "OU", time.Now().AddDate(5, 0, 0), "test", "US", "California", "San Francisco", "CA Name")
if err != nil {
t.Fatal("Failed creating certificate authority:", err)
}
Expand Down
3 changes: 1 addition & 2 deletions pkix/cert_host.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ var (

// CreateCertificateHost creates certificate for host.
// The arguments include CA certificate, CA key, certificate request.
func CreateCertificateHost(crtAuth *Certificate, keyAuth *Key, csr *CertificateSigningRequest, years int) (*Certificate, error) {
func CreateCertificateHost(crtAuth *Certificate, keyAuth *Key, csr *CertificateSigningRequest, proposedExpiry time.Time) (*Certificate, error) {
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil {
Expand All @@ -81,7 +81,6 @@ func CreateCertificateHost(crtAuth *Certificate, keyAuth *Key, csr *CertificateS
hostTemplate.RawSubject = rawCsr.RawSubject

caExpiry := time.Now().Add(crtAuth.GetExpirationDuration())
proposedExpiry := time.Now().AddDate(years, 0, 0).UTC()
// ensure cert doesn't expire after issuer
if caExpiry.Before(proposedExpiry) {
hostTemplate.NotAfter = caExpiry
Expand Down
3 changes: 2 additions & 1 deletion pkix/cert_host_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ package pkix
import (
"bytes"
"testing"
"time"
)

func TestCreateCertificateHost(t *testing.T) {
Expand All @@ -38,7 +39,7 @@ func TestCreateCertificateHost(t *testing.T) {
t.Fatal("Failed parsing certificate request from PEM:", err)
}

crt, err := CreateCertificateHost(crtAuth, key, csr, 5000)
crt, err := CreateCertificateHost(crtAuth, key, csr, time.Now().AddDate(5000, 0, 0))
if err != nil {
t.Fatal("Failed creating certificate for host:", err)
}
Expand Down
4 changes: 2 additions & 2 deletions pkix/crl.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,13 @@ const (
crlPEMBlockType = "X509 CRL"
)

func CreateCertificateRevocationList(key *Key, ca *Certificate, years int) (*CertificateRevocationList, error) {
func CreateCertificateRevocationList(key *Key, ca *Certificate, expiry time.Time) (*CertificateRevocationList, error) {
rawCrt, err := ca.GetRawCertificate()
if err != nil {
return nil, err
}

crlBytes, err := rawCrt.CreateCRL(rand.Reader, key.Private, []pkix.RevokedCertificate{}, time.Now(), time.Now().AddDate(years, 0, 0))
crlBytes, err := rawCrt.CreateCRL(rand.Reader, key.Private, []pkix.RevokedCertificate{}, time.Now(), expiry)
if err != nil {
return nil, err
}
Expand Down
5 changes: 3 additions & 2 deletions pkix/crl_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ package pkix
import (
"bytes"
"testing"
"time"
)

const (
Expand Down Expand Up @@ -48,11 +49,11 @@ func TestCreateCertificateRevocationList(t *testing.T) {
t.Fatal("Failed creating rsa key:", err)
}

crt, err := CreateCertificateAuthority(key, "OU", 5, "test", "US", "California", "San Francisco", "CA Name")
crt, err := CreateCertificateAuthority(key, "OU", time.Now().AddDate(5, 0, 0), "test", "US", "California", "San Francisco", "CA Name")
if err != nil {
t.Fatal("Failed creating certificate authority:", err)
}
_, err = CreateCertificateRevocationList(key, crt, 5)
_, err = CreateCertificateRevocationList(key, crt, time.Now().AddDate(5, 0, 0))
if err != nil {
t.Fatal("Failed creating crl:", err)
}
Expand Down
2 changes: 1 addition & 1 deletion test
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
source ./build

if [ -z "$PKG" ]; then
PKG="depot pkix tests"
PKG="cmd depot pkix tests"
fi

# Unit tests
Expand Down

0 comments on commit 20bf3b1

Please sign in to comment.