diff --git a/go.mod b/go.mod index 8ac9014..5470dda 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module git.cacert.org/cacert-gosigner -go 1.18 +go 1.17 require ( github.com/ThalesIgnite/crypto11 v1.2.5 diff --git a/x509/openssl/repository.go b/x509/openssl/repository.go index f05867c..b06b418 100644 --- a/x509/openssl/repository.go +++ b/x509/openssl/repository.go @@ -2,6 +2,9 @@ package openssl import ( "bufio" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" "errors" "fmt" "math/big" @@ -10,119 +13,296 @@ import ( "strings" "sync" "time" +) + +var OidCRLReason = asn1.ObjectIdentifier{2, 5, 29, 21} - "git.cacert.org/cacert-gosigner/x509/revoking" - "git.cacert.org/cacert-gosigner/x509/signing" +type CRLReason int + +// CRL reason codes as defined in RFC 5280 section 5.3.1 +const ( + CRLReasonUnspecified CRLReason = 0 + CRLReasonKeyCompromise CRLReason = 1 + CRLReasonCACompromise CRLReason = 2 + CRLReasonAffiliationChanged CRLReason = 3 + CRLReasonSuperseded CRLReason = 4 + CRLReasonCessationOfOperation CRLReason = 5 + CRLReasonCertificateHold CRLReason = 6 + CRLReasonRemoveFromCRL CRLReason = 8 + CRLReasonPrivilegeWithdrawn CRLReason = 9 + CRLReasonAACompromise CRLReason = 10 ) -// The FileRepository stores information about signed and revoked certificates -// in an openssl index.txt compatible file. -// -// A reference for the file format can be found at -// https://pki-tutorial.readthedocs.io/en/latest/cadb.html. -type FileRepository struct { - indexFileName string - lock sync.Locker +var crlReasonNames = map[CRLReason]string{ + CRLReasonUnspecified: "unspecified", + CRLReasonKeyCompromise: "keyCompromise", + CRLReasonCACompromise: "CACompromise", + CRLReasonAffiliationChanged: "affiliationChanged", + CRLReasonSuperseded: "superseded", + CRLReasonCessationOfOperation: "cessationOfOperation", + CRLReasonCertificateHold: "certificateHold", + CRLReasonRemoveFromCRL: "removeFromCRL", + CRLReasonPrivilegeWithdrawn: "privilegeWithdrawn", + CRLReasonAACompromise: "AACompromise", +} + +func (r CRLReason) String() string { + if reason, ok := crlReasonNames[r]; ok { + return reason + } + + return crlReasonNames[CRLReasonUnspecified] } +// ParseReason takes a reason string and performs a case-insensitive match to a reason code +func ParseReason(rs string) CRLReason { + for key, name := range crlReasonNames { + if strings.EqualFold(name, rs) { + return key + } + } + + return CRLReasonUnspecified +} + +const TimeSpec = "060102030405Z" + type indexStatus string const ( - CertificateValid indexStatus = "V" - CertificateRevoked = "R" - CertificateExpired = "E" + certificateValid indexStatus = "V" + certificateRevoked indexStatus = "R" + certificateExpired indexStatus = "E" ) -const opensslTimeSpec = "060102030405Z" - +// An indexEntry represents a line in an openssl ca compatible index.txt file +// a format specification is available at https://pki-tutorial.readthedocs.io/en/latest/cadb.html type indexEntry struct { statusFlag indexStatus expiresAt time.Time - revokedAt time.Time - revocationReason string + revokedAt *time.Time + revocationReason CRLReason serialNumber *big.Int fileName string certificateSubjectDN string } -func (e *indexEntry) markRevoked(revocationTime time.Time, reason string) { - if e.statusFlag == CertificateValid { - e.statusFlag = CertificateRevoked - e.revokedAt = revocationTime - e.revocationReason = reason +func (ie *indexEntry) String() string { + var revoked, fileName string + + if ie.revokedAt != nil { + revoked = fmt.Sprintf("%s,%s", ie.revokedAt.Format(TimeSpec), ie.revocationReason) + } + + if ie.fileName == "" { + fileName = "unknown" } + + return strings.Join([]string{ + string(ie.statusFlag), + ie.expiresAt.Format(TimeSpec), + revoked, + strings.ToUpper(ie.serialNumber.Text(16)), + fileName, + ie.certificateSubjectDN, // this is not 100% compatible with openssl that uses a non-RFC syntax + }, "\t") +} + +// The Repository stores information about signed and revoked certificates +// in an openssl index.txt compatible file. +// +// A reference for the file format can be found at +// https://pki-tutorial.readthedocs.io/en/latest/cadb.html. +type Repository struct { + indexFileName string + lock sync.Locker + entries []indexEntry } -type IndexFile struct { - entries []*indexEntry +func (ie *indexEntry) markRevoked(revocationTime time.Time, reason CRLReason) { + if ie.statusFlag == certificateValid { + ie.statusFlag = certificateRevoked + ie.revokedAt = &revocationTime + ie.revocationReason = reason + } } -func (f *IndexFile) findEntry(number *big.Int) (*indexEntry, error) { - for _, entry := range f.entries { - if entry.serialNumber == number { - return entry, nil +func (r *Repository) findEntry(number *big.Int) (*indexEntry, error) { + if number == nil { + return nil, errors.New("serial number must not be nil") + } + + for _, entry := range r.entries { + if entry.serialNumber.Cmp(number) == 0 { + return &entry, nil } } - return nil, fmt.Errorf("no entry for serial number %s found", number) + + return nil, nil +} + +type CannotRevokeUnknown struct { + Serial *big.Int +} + +func (c CannotRevokeUnknown) Error() string { + return fmt.Sprintf("cannot revoke unknown certificate with serial number %s", c.Serial) } // StoreRevocation records information about a revoked certificate. -func (r *FileRepository) StoreRevocation(revoked *revoking.CertificateRevoked) error { +func (r *Repository) StoreRevocation(revoked *pkix.RevokedCertificate) error { r.lock.Lock() defer r.lock.Unlock() - index, err := r.loadIndex() + err := r.loadIndex() if err != nil { return err } - entry, err := index.findEntry(revoked.SerialNumber()) + entry, err := r.findEntry(revoked.SerialNumber) if err != nil { return err } - entry.markRevoked(revoked.RevocationTime(), revoked.Reason()) - err = r.writeIndex(index) + if entry == nil { + return CannotRevokeUnknown{Serial: revoked.SerialNumber} + } + + reason := CRLReasonUnspecified + + for _, ext := range revoked.Extensions { + if ext.Id.Equal(OidCRLReason) { + _, err := asn1.Unmarshal(ext.Value, &reason) + if err != nil { + return fmt.Errorf("could not unmarshal ") + } + } + } + + entry.markRevoked(revoked.RevocationTime, reason) + + err = r.writeIndex() return err } // StoreCertificate records information about a signed certificate. -func (r *FileRepository) StoreCertificate(signed *signing.CertificateSigned) error { +func (r *Repository) StoreCertificate(signed *x509.Certificate) error { + var err error + r.lock.Lock() defer r.lock.Unlock() + err = r.loadIndex() + if err != nil { + return err + } + + entry, err := r.findEntry(signed.SerialNumber) + if err != nil { + return err + } + + if entry != nil { + return fmt.Errorf("certificate with serial %s is already in the index", signed.SerialNumber) + } + + status := certificateValid + + if signed.NotAfter.Before(time.Now().UTC()) { + status = certificateExpired + } + + err = r.addIndexEntry(&indexEntry{ + statusFlag: status, + expiresAt: signed.NotAfter, + serialNumber: signed.SerialNumber, + certificateSubjectDN: signed.Subject.String(), // not openssl compatible + }) + if err != nil { + return err + } + return nil } -func (r *FileRepository) loadIndex() (*IndexFile, error) { +func (r *Repository) loadIndex() error { + entries := make([]indexEntry, 0, 100) + f, err := os.Open(r.indexFileName) - if err != nil { - return nil, err + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("could not load index from %s: %w", r.indexFileName, err) } - defer func() { _ = f.Close() }() - entries := make([]*indexEntry, 0) + if f == nil { + r.entries = entries + + return nil + } + + defer func() { _ = f.Close() }() indexScanner := bufio.NewScanner(f) for indexScanner.Scan() { - indexEntry, err := r.newIndexEntry(indexScanner.Text()) + indexEntry, err := r.newIndexEntryFromLine(indexScanner.Text()) if err != nil { - return nil, err + return err } - entries = append(entries, indexEntry) + entries = append(entries, *indexEntry) } if err := indexScanner.Err(); err != nil { - return nil, err + return err } - return &IndexFile{entries: entries}, nil + r.entries = entries + + return nil } -func (r *FileRepository) writeIndex(index *IndexFile) error { - return errors.New("not implemented") +func (r *Repository) writeIndex() error { + f, err := os.OpenFile(r.indexFileName, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return fmt.Errorf("could not create index file %s: %w", r.indexFileName, err) + } + defer func(f *os.File) { + _ = f.Close() + }(f) + + w := bufio.NewWriter(f) + + for i, entry := range r.entries { + _, err = w.WriteString(entry.String()) + if err != nil { + return fmt.Errorf("could not write entry for serial %s: %w", entry.serialNumber, err) + } + if i < len(r.entries)-1 { + _, err = w.WriteString("\n") + if err != nil { + return fmt.Errorf("could not write linebreak: %w", err) + } + } + } + err = w.Flush() + if err != nil { + return fmt.Errorf("could not write to %s: %w", r.indexFileName, err) + } + + return nil +} + +func (r *Repository) addIndexEntry(ie *indexEntry) error { + r.entries = append(r.entries, *ie) + + err := r.writeIndex() + if err != nil { + return err + } + + return nil } -func (r *FileRepository) newIndexEntry(text string) (*indexEntry, error) { +func (r *Repository) newIndexEntryFromLine(text string) (*indexEntry, error) { + var err error + fields := strings.Split(text, "\t") const expectedFieldNumber = 6 @@ -134,24 +314,24 @@ func (r *FileRepository) newIndexEntry(text string) (*indexEntry, error) { ) } - expirationParsed, err := time.Parse(opensslTimeSpec, fields[1]) + expirationParsed, err := time.Parse(TimeSpec, fields[1]) if err != nil { return nil, err } var revocationTimeParsed time.Time - var revocationReason string + var revocationReason CRLReason if fields[2] != "" { var timeString string if strings.Contains(fields[2], ",") { parts := strings.SplitN(fields[2], ",", 2) timeString = parts[0] - revocationReason = parts[1] + revocationReason = ParseReason(parts[1]) } else { timeString = fields[2] } - revocationTimeParsed, err = time.Parse(opensslTimeSpec, timeString) + revocationTimeParsed, err = time.Parse(TimeSpec, timeString) if err != nil { return nil, err } @@ -165,34 +345,29 @@ func (r *FileRepository) newIndexEntry(text string) (*indexEntry, error) { fileNameParsed := "unknown" if fields[4] != "" { _, err = os.Stat(path.Join(path.Dir(r.indexFileName), fields[4])) - if err != nil { - if !os.IsNotExist(err) { - return nil, err - } - } else { - fileNameParsed = fields[4] + if err != nil && !os.IsNotExist(err) { + return nil, err } + fileNameParsed = fields[4] } - subjectDNParsed := fields[5] - return &indexEntry{ statusFlag: indexStatus(fields[0]), expiresAt: expirationParsed, - revokedAt: revocationTimeParsed, + revokedAt: &revocationTimeParsed, revocationReason: revocationReason, serialNumber: serialParsed, fileName: fileNameParsed, - certificateSubjectDN: subjectDNParsed, + certificateSubjectDN: fields[5], }, nil } -func NewFileRepository(baseDirectory string) (*FileRepository, error) { +func NewFileRepository(baseDirectory string) (*Repository, error) { err := os.Chdir(baseDirectory) if err != nil { return nil, err } - return &FileRepository{ + return &Repository{ indexFileName: path.Join(baseDirectory, "index.txt"), lock: &sync.Mutex{}, }, nil diff --git a/x509/openssl/repository_test.go b/x509/openssl/repository_test.go index 7f03559..48fa186 100644 --- a/x509/openssl/repository_test.go +++ b/x509/openssl/repository_test.go @@ -1,32 +1,103 @@ package openssl_test import ( + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "math/big" + "os" "path" + "strings" "testing" + "time" "git.cacert.org/cacert-gosigner/x509/openssl" - "git.cacert.org/cacert-gosigner/x509/revoking" - "git.cacert.org/cacert-gosigner/x509/signing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestStoreRevocation(t *testing.T) { - fr, err := openssl.NewFileRepository(t.TempDir()) + tempdir := t.TempDir() + + fr, err := openssl.NewFileRepository(tempdir) require.NoError(t, err) - err = fr.StoreRevocation(&revoking.CertificateRevoked{}) + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + t.Errorf("could not create random serial: %v", err) + } + + extBytes, err := asn1.Marshal(openssl.CRLReasonKeyCompromise) + if err != nil { + t.Errorf("could not marshal revocation reason: %v", err) + } + + notAfter := time.Now().UTC().Add(24 * time.Hour).UTC() + + err = fr.StoreRevocation(&pkix.RevokedCertificate{ + SerialNumber: serial, + RevocationTime: notAfter, + Extensions: []pkix.Extension{ + {Id: openssl.OidCRLReason, Value: extBytes}, + }, + }) + + assert.ErrorIs(t, err, openssl.CannotRevokeUnknown{Serial: serial}) + + err = os.WriteFile(path.Join(tempdir, "index.txt"), []byte( + strings.Join( + []string{ + "V", + notAfter.Format(openssl.TimeSpec), + "", + strings.ToUpper(serial.Text(16)), + "unknown", + pkix.Name{CommonName: "test.example.org"}.String(), + }, + "\t", + )+"\n", + ), 0o600) + assert.NoError(t, err) + + err = fr.StoreRevocation(&pkix.RevokedCertificate{ + SerialNumber: serial, + RevocationTime: time.Now(), + Extensions: []pkix.Extension{ + {Id: openssl.OidCRLReason, Value: extBytes}, + }, + }) assert.NoError(t, err) - assert.FileExists(t, path.Join(t.TempDir(), "index.txt")) + assert.FileExists(t, path.Join(tempdir, "index.txt")) } func TestStoreCertificate(t *testing.T) { - fr, err := openssl.NewFileRepository(t.TempDir()) + tempdir := t.TempDir() + + fr, err := openssl.NewFileRepository(tempdir) require.NoError(t, err) - err = fr.StoreCertificate(&signing.CertificateSigned{}) + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + t.Errorf("could not create random serial: %v", err) + } + + err = fr.StoreCertificate(&x509.Certificate{ + SerialNumber: serial, + Issuer: pkix.Name{ + CommonName: "Test CA", + }, + Subject: pkix.Name{ + CommonName: "test.example.org", + }, + NotBefore: time.Now().Add(-1 * time.Hour).UTC(), + NotAfter: time.Now().Add(24 * time.Hour).UTC(), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyAgreement, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + DNSNames: []string{"test.example.org"}, + }) assert.NoError(t, err) - assert.FileExists(t, path.Join(t.TempDir(), "index.txt")) + assert.FileExists(t, path.Join(tempdir, "index.txt")) } diff --git a/x509/revoking/revoking_test.go b/x509/revoking/revoking_test.go index 8840c30..ea8159f 100644 --- a/x509/revoking/revoking_test.go +++ b/x509/revoking/revoking_test.go @@ -1,10 +1,9 @@ package revoking import ( + "crypto/rand" "math/big" - "math/rand" "testing" - "time" "github.com/stretchr/testify/assert" ) @@ -22,9 +21,10 @@ func TestRevoking(t *testing.T) { testRepository := testRepo{revoked: make([]big.Int, 0)} r := NewX509Revoking(&testRepository) - rand.Seed(time.Now().Unix()) - - serial := big.NewInt(rand.Int63()) + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + t.Errorf("could not create random serial: %v", err) + } revoke, err := r.Revoke(&RevokeCertificate{serialNumber: serial, reason: "for testing"}) assert.NoError(t, err)