diff --git a/pkg/ca/ca.go b/pkg/ca/ca.go new file mode 100644 index 0000000..a511f85 --- /dev/null +++ b/pkg/ca/ca.go @@ -0,0 +1,72 @@ +/* +Package ca implements a disk file-backed certificate authority with a built-in +CRL signer. The certificate authority can issue new certificates (including the +creation of intermediate CAs) and revoke existing certificates, resulting in a +fresh CRL. +*/ +package ca + +import ( + "crypto/rsa" + "crypto/sha1" + "crypto/x509" + "encoding/asn1" + "fmt" + "path/filepath" + + "src.lwithers.me.uk/go/rsa/pkg/pemfile" +) + +type CA struct { + dir string + + key *rsa.PrivateKey + root *x509.Certificate + + crl *x509.RevocationList + crlKey *rsa.PrivateKey + crlCert *x509.Certificate +} + +const ( + rootKeyFilename = "root-key.pem" + rootCertFilename = "root-cert.pem" +) + +// Open an existing certificate authority in the given directory. +func Open(dir string) (*CA, error) { + ca := &CA{ + dir: dir, + } + var err error + ca.key, err = pemfile.ReadKey(filepath.Join(dir, rootKeyFilename)) + if err != nil { + return nil, fmt.Errorf("while opening CA (root key): %w", err) + } + ca.root, err = pemfile.ReadCert(filepath.Join(dir, rootCertFilename)) + if err != nil { + return nil, fmt.Errorf("while opening CA (root cert): %w", err) + } + if err = ca.loadCRLState(); err != nil { + return nil, fmt.Errorf("while opening CA (CRL state): %w", err) + } + return ca, nil +} + +func (ca *CA) GetRoot() *x509.Certificate { + return ca.root +} + +func (ca *CA) GetCRLSigner() *x509.Certificate { + return ca.crlCert +} + +func (ca *CA) GetCRL() *x509.RevocationList { + return ca.crl +} + +func ComputeSubjectKeyId(key *rsa.PublicKey) []byte { + der, _ := asn1.Marshal(key) + h := sha1.Sum(der) + return h[:] +} diff --git a/pkg/ca/create.go b/pkg/ca/create.go new file mode 100644 index 0000000..3719c90 --- /dev/null +++ b/pkg/ca/create.go @@ -0,0 +1,162 @@ +/* +Package ca implements a disk file-backed certificate authority with a built-in +CRL signer. The certificate authority can issue new certificates (including the +creation of intermediate CAs) and revoke existing certificates, resulting in a +fresh CRL. +*/ +package ca + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "errors" + "fmt" + "io/fs" + "math/big" + "os" + "path/filepath" + "time" + + "src.lwithers.me.uk/go/rsa/pkg/pemfile" + "src.lwithers.me.uk/go/rsa/pkg/serial" +) + +const ( + crlSignerBits = 3072 +) + +func Create(dir string, template *x509.Certificate, key *rsa.PrivateKey) (*CA, error) { + return createCA(dir, template, template, key, key) +} + +func (ca *CA) CreateIntermediate(dir string, template *x509.Certificate, key *rsa.PrivateKey) (*CA, error) { + return createCA(dir, template, ca.root, key, ca.key) +} + +// createCA holds the shared code between Create and CreateIntermediate. All +// that really differs between those operations is the signing key and whether +// or not there is a parent certificate. +func createCA(dir string, template, parent *x509.Certificate, newRootKey, signingKey *rsa.PrivateKey) (*CA, error) { + // template must have at least the common name set + if template.Subject.CommonName == "" { + return nil, errors.New("template CA certificate must have common name set") + } + + // directory must not exist + if _, err := os.Lstat(dir); !errors.Is(err, fs.ErrNotExist) { + return nil, &fs.PathError{ + Op: "mkdir", + Path: dir, + Err: fs.ErrExist, + } + } + + // create the directory + if err := os.Mkdir(dir, 0700); err != nil { + return nil, err + } + + // write out the private key + der, err := x509.MarshalPKCS8PrivateKey(newRootKey) + if err != nil { + return nil, fmt.Errorf("failed to marshal private key: %w", err) + } + if err = pemfile.Write(filepath.Join(dir, rootKeyFilename), pemfile.TypePKCS8PrivateKey, der); err != nil { + return nil, err + } + + // ensure the template is valid for a root CA + template.BasicConstraintsValid = true + template.IsCA = true + template.KeyUsage |= x509.KeyUsageCertSign + if template.SerialNumber == nil { + template.SerialNumber = serial.Rand() + } + if template.NotBefore.IsZero() { + template.NotBefore = time.Now().Add(-2 * time.Hour) + } + if template.NotAfter.IsZero() { + template.NotAfter = time.Now().Add(24 * 365 * 40 * time.Hour) + } + + // create the self-signed certificate; ensure we can parse it, and write + // it to disk + der, err = x509.CreateCertificate(rand.Reader, template, parent, &newRootKey.PublicKey, signingKey) + if err != nil { + return nil, fmt.Errorf("failed to create/sign new root CA certificate: %w", err) + } + root, err := x509.ParseCertificate(der) + if err != nil { + return nil, fmt.Errorf("failed to parse just-signed root CA certificate: %w", err) + } + if err = pemfile.Write(filepath.Join(dir, rootCertFilename), pemfile.TypeX509Certificate, der); err != nil { + return nil, err + } + + // now create a CRL signer key and write it to disk + crlKey, err := rsa.GenerateKey(rand.Reader, crlSignerBits) + if err != nil { + return nil, fmt.Errorf("failed to generate new CRL signer key: %w", err) + } + der, err = x509.MarshalPKCS8PrivateKey(crlKey) + if err != nil { + return nil, fmt.Errorf("failed to marshal CRL signer private key: %w", err) + } + if err = pemfile.Write(filepath.Join(dir, crlKeyFilename), pemfile.TypePKCS8PrivateKey, der); err != nil { + return nil, err + } + + // create a template for a CRL signing certificate + crlTemplate := &x509.Certificate{ + SerialNumber: serial.Rand(), + Subject: template.Subject, + SubjectKeyId: ComputeSubjectKeyId(&crlKey.PublicKey), + NotBefore: template.NotBefore, + NotAfter: template.NotAfter, + KeyUsage: x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + } + crlTemplate.Subject.CommonName += " CRL signer" + + // create the CRL signing certificate, parse it, and write it to disk + der, err = x509.CreateCertificate(rand.Reader, crlTemplate, root, &crlKey.PublicKey, newRootKey) + if err != nil { + return nil, fmt.Errorf("failed to sign CRL signer certificate: %w", err) + } + crlCert, err := x509.ParseCertificate(der) + if err != nil { + return nil, fmt.Errorf("failed to parse just-signed CRL signer certificate: %w", err) + } + if err = pemfile.Write(filepath.Join(dir, crlCertFilename), pemfile.TypeX509Certificate, der); err != nil { + return nil, err + } + + // finally, create an empty CRL + crl := &x509.RevocationList{ + Number: big.NewInt(1), + ThisUpdate: template.NotBefore, + NextUpdate: template.NotAfter, + } + der, err = x509.CreateRevocationList(rand.Reader, crl, crlCert, crlKey) + if err != nil { + return nil, fmt.Errorf("failed to create empty CRL: %w", err) + } + crl, err = x509.ParseRevocationList(der) + if err != nil { + return nil, fmt.Errorf("failed to parse just-created CRL: %w", err) + } + if err = pemfile.Write(filepath.Join(dir, crlFilename), pemfile.TypeX509CRL, der); err != nil { + return nil, err + } + + // all done + return &CA{ + dir: dir, + key: newRootKey, + root: root, + crl: crl, + crlKey: crlKey, + crlCert: crlCert, + }, nil +} diff --git a/pkg/ca/crl.go b/pkg/ca/crl.go new file mode 100644 index 0000000..94a6a20 --- /dev/null +++ b/pkg/ca/crl.go @@ -0,0 +1,87 @@ +/* +Package ca implements a disk file-backed certificate authority with a built-in +CRL signer. The certificate authority can issue new certificates (including the +creation of intermediate CAs) and revoke existing certificates, resulting in a +fresh CRL. +*/ +package ca + +import ( + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "fmt" + "math/big" + "path/filepath" + "time" + + "src.lwithers.me.uk/go/rsa/pkg/pemfile" +) + +const ( + crlFilename = "crl.pem" + crlKeyFilename = "crl-signer-key.pem" + crlCertFilename = "crl-signer-cert.pem" +) + +func (ca *CA) loadCRLState() error { + var err error + ca.crl, err = pemfile.ReadCRL(filepath.Join(ca.dir, crlFilename)) + if err != nil { + return err + } + ca.crlKey, err = pemfile.ReadKey(filepath.Join(ca.dir, crlKeyFilename)) + if err != nil { + return err + } + ca.crlCert, err = pemfile.ReadCert(filepath.Join(ca.dir, crlCertFilename)) + return err +} + +func (ca *CA) Revoke(serial *big.Int) (*x509.RevocationList, error) { + // if the certificate is already revoked, update its revocation time + var found bool + for i := range ca.crl.RevokedCertificates { + if ca.crl.RevokedCertificates[i].SerialNumber.Cmp(serial) == 0 { + ca.crl.RevokedCertificates[i].RevocationTime = time.Now() + found = true + break + } + } + + // otherwise, add a new entry + if !found { + n := big.NewInt(0) + n.SetBytes(serial.Bytes()) + ca.crl.RevokedCertificates = append(ca.crl.RevokedCertificates, + pkix.RevokedCertificate{ + SerialNumber: n, + RevocationTime: time.Now(), + }) + } + + // increment issue number + ca.crl.Number.Add(ca.crl.Number, big.NewInt(1)) + ca.crl.ThisUpdate = time.Now() + + // create new DER-form certificate + der, err := x509.CreateRevocationList(rand.Reader, ca.crl, ca.crlCert, ca.crlKey) + if err != nil { + return nil, fmt.Errorf("failed to sign new CRL: %w", err) + } + + // ensure we can parse it + newCRL, err := x509.ParseRevocationList(der) + if err != nil { + return nil, fmt.Errorf("failed to parse newly-signed CRL: %w", err) + } + + // save to disk + if err := pemfile.Write(filepath.Join(ca.dir, crlFilename), pemfile.TypeX509CRL, der); err != nil { + return nil, fmt.Errorf("failed to save CRL: %w", err) + } + + // update in-memory copy + ca.crl = newCRL + return ca.crl, nil +} diff --git a/pkg/ca/sign.go b/pkg/ca/sign.go new file mode 100644 index 0000000..40f9074 --- /dev/null +++ b/pkg/ca/sign.go @@ -0,0 +1,57 @@ +/* +Package ca implements a disk file-backed certificate authority with a built-in +CRL signer. The certificate authority can issue new certificates (including the +creation of intermediate CAs) and revoke existing certificates, resulting in a +fresh CRL. +*/ +package ca + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "fmt" + "os" + "path/filepath" + + "src.lwithers.me.uk/go/rsa/pkg/pemfile" + "src.lwithers.me.uk/go/rsa/pkg/serial" +) + +const ( + auditLogDir = "audit.log" +) + +func (ca *CA) Sign(template *x509.Certificate, key *rsa.PublicKey) (*x509.Certificate, string, error) { + if template.SerialNumber == nil { + template.SerialNumber = serial.Rand() + } + + // sign the certificate + der, err := x509.CreateCertificate(rand.Reader, template, ca.root, key, ca.key) + if err != nil { + return nil, "", + fmt.Errorf("failed to sign certificate: %w", err) + } + + // parse the just-signed certificate + cert, err := x509.ParseCertificate(der) + if err != nil { + return nil, "", + fmt.Errorf("failed to parse certificate after signing: %w", err) + } + + // save the newly-signed cert in the audit log + auditDir := filepath.Join(ca.dir, auditLogDir, template.SerialNumber.Text(16)) + if err := os.MkdirAll(auditDir, 0700); err != nil { + return nil, "", + fmt.Errorf("failed to create audit log directory: %w", err) + } + + if err := pemfile.Write(filepath.Join(auditDir, "cert.pem"), pemfile.TypeX509Certificate, der); err != nil { + return nil, "", + fmt.Errorf("failed to save certificate in audit log: %w", err) + } + + return cert, auditDir, nil +} diff --git a/pkg/go.mod b/pkg/go.mod new file mode 100644 index 0000000..b8a8f6c --- /dev/null +++ b/pkg/go.mod @@ -0,0 +1,7 @@ +module src.lwithers.me.uk/go/rsa/pkg + +go 1.20 + +require src.lwithers.me.uk/go/writefile v1.0.1 + +require golang.org/x/sys v0.5.0 // indirect diff --git a/pkg/go.sum b/pkg/go.sum new file mode 100644 index 0000000..fe0b93d --- /dev/null +++ b/pkg/go.sum @@ -0,0 +1,6 @@ +golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8 h1:JA8d3MPx/IToSyXZG/RhwYEtfrKO1Fxrqe8KrkiLXKM= +golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +src.lwithers.me.uk/go/writefile v1.0.1 h1:bwBGtvyZfCxFIM14e1aYgJWlZuowKkwJx53OJlUPd0s= +src.lwithers.me.uk/go/writefile v1.0.1/go.mod h1:NahlmRCtB7kg4ai+zHZgxXdUs+MR8VqWG8mql35TsxA= diff --git a/pkg/inspect/cert.go b/pkg/inspect/cert.go new file mode 100644 index 0000000..7dd7d4a --- /dev/null +++ b/pkg/inspect/cert.go @@ -0,0 +1,283 @@ +package inspect + +import ( + "crypto/rsa" + "crypto/x509" + "errors" + "strings" +) + +// DERCertificate attempts to parse a DER-form X.509 certificate, and returns +// information about it. +func DERCertificateInfo(loc string, der []byte) Info { + c, err := x509.ParseCertificate(der) + if err != nil { + return &BadInfo{ + Typ: TypeX509Certificate, + Loc: loc, + Underlying: err, + } + } + return CertificateInfo(loc, c) +} + +// CertificateInfo returns structured information about the given certificate. +// It can return BadInfo if the certificate is not for an RSA key. Note that +// the returned Info structure holds a reference to the passed certificate. +func CertificateInfo(loc string, cert *x509.Certificate) Info { + pubkey, ok := cert.PublicKey.(*rsa.PublicKey) + if !ok { + return &BadInfo{ + Typ: TypeX509Certificate, + Loc: loc, + Underlying: errors.New("certificate is not for an RSA key"), + } + } + + c := &Certificate{ + Loc: loc, + Key: PublicKeyInfo(loc, pubkey), + Cert: cert, + } + + if (cert.KeyUsage & x509.KeyUsageDigitalSignature) != 0 { + c.ValidUses = append(c.ValidUses, "DigitalSignature") + } + if (cert.KeyUsage & x509.KeyUsageCertSign) != 0 { + c.ValidUses = append(c.ValidUses, "CertSign") + } + if (cert.KeyUsage & x509.KeyUsageCRLSign) != 0 { + c.ValidUses = append(c.ValidUses, "CRLSign") + } + for _, use := range cert.ExtKeyUsage { + switch use { + case x509.ExtKeyUsageAny: + c.ValidUses = append(c.ValidUses, "any") + case x509.ExtKeyUsageServerAuth: + c.ValidUses = append(c.ValidUses, "ServerAuth") + case x509.ExtKeyUsageClientAuth: + c.ValidUses = append(c.ValidUses, "ClientAuth") + } + } + + if err := cert.CheckSignatureFrom(cert); err == nil { + c.IsSelfSigned = true + } + + return c +} + +type Certificate struct { + // Loc is the location the certificate was encountered. + Loc string + + // Key holds information about the public key of the certificate. + Key *PublicKey + + // Cert is the original certificate. + Cert *x509.Certificate + + // The following section contains any computed values that are not + // immediately obtainable from the certificate. + + // ValidUses is a set of named certificate uses. + ValidUses []string + + // IsSelfSigned reports whether the certificate is self-signed. + IsSelfSigned bool +} + +// Type indicates this is an X.509 certificate. +func (c *Certificate) Type() Type { + return TypeX509Certificate +} + +// Location returns the location at which the certificate was encountered. +func (c *Certificate) Location() string { + return c.Loc +} + +// Info returns structured information about the X.509 Certificate. +func (c *Certificate) Info() []Section { + var s []Section + s = append(s, c.UsageSection()) + s = append(s, c.ValiditySection()) + s = append(s, c.SubjectSection()) + s = append(s, c.IssuerSection()) + if c.Cert.IsCA { + s = append(s, c.CASection()) + } + s = append(s, c.Key.PublicKeyInfoSection()) + return s +} + +// UsageSection returns information regarding the key usage bits. +func (c *Certificate) UsageSection() Section { + return Section{ + Title: "Certificate usage", + Fields: []Field{ + Field{ + Key: "Is CA", + Value: c.Cert.IsCA, + }, + Field{ + Key: "Self-signed", + Value: c.IsSelfSigned, + }, + Field{ + Key: "Valid uses", + Value: strings.Join(c.ValidUses, " | "), + }, + }, + } +} + +// ValiditySection returns information regarding the validity of the certificate. +func (c *Certificate) ValiditySection() Section { + return Section{ + Title: "Validity", + Fields: []Field{ + Field{ + Key: "Valid from", + Value: c.Cert.NotBefore, + }, + Field{ + Key: "Valid until", + Value: c.Cert.NotAfter, + }, + }, + } +} + +// SubjectSection returns information about the Subject of the certificate. +func (c *Certificate) SubjectSection() Section { + f := []Field{ + Field{ + Key: "Description", + Value: c.Cert.Subject.CommonName, + }, + } + f = appendX509DNField(f, c.Cert.Subject) + if len(c.Cert.DNSNames) > 0 { + f = append(f, Field{ + Key: "Hostnames", + Value: c.Cert.DNSNames, + }) + } + if len(c.Cert.IPAddresses) > 0 { + var ips []string + for _, ip := range c.Cert.IPAddresses { + ips = append(ips, ip.String()) + } + f = append(f, Field{ + Key: "IP addresses", + Value: ips, + }) + } + if len(c.Cert.SubjectKeyId) > 0 { + f = append(f, Field{ + Key: "Key ID", + Value: FormatHexBytes(c.Cert.SubjectKeyId), + }) + } + + return Section{ + Title: "Subject", + Fields: f, + } +} + +// IssuerSection returns information about the Issuer of the certificate. +func (c *Certificate) IssuerSection() Section { + f := []Field{ + Field{ + Key: "Serial", + Value: FormatHexBytes(c.Cert.SerialNumber.Bytes()), + }, + } + if c.Cert.Issuer.CommonName != "" { + f = append(f, Field{ + Key: "Description", + Value: c.Cert.Issuer.CommonName, + }) + } + f = appendX509DNField(f, c.Cert.Issuer) + if len(c.Cert.AuthorityKeyId) > 0 { + f = append(f, Field{ + Key: "Key ID", + Value: FormatHexBytes(c.Cert.AuthorityKeyId), + }) + } + + return Section{ + Title: "Issuer", + Fields: f, + } +} + +// CASection returns information about a CA certificate. +func (c *Certificate) CASection() Section { + var f []Field + + switch { + case c.Cert.MaxPathLen < 0, + c.Cert.MaxPathLen == 0 && !c.Cert.MaxPathLenZero: + f = append(f, Field{ + Key: "Max path len", + Value: "unlimited", + }) + default: + f = append(f, Field{ + Key: "Max path len", + Value: c.Cert.MaxPathLen, + }) + } + + if len(c.Cert.PermittedDNSDomains) > 0 { + f = append(f, Field{ + Key: "Permitted DNS names", + Value: c.Cert.PermittedDNSDomains, + }) + } + + if len(c.Cert.ExcludedDNSDomains) > 0 { + f = append(f, Field{ + Key: "Excluded DNS names", + Value: c.Cert.ExcludedDNSDomains, + }) + } + + if len(c.Cert.PermittedIPRanges) > 0 { + var ips []string + for _, ip := range c.Cert.PermittedIPRanges { + ips = append(ips, ip.String()) + } + f = append(f, Field{ + Key: "Permitted IP addresses", + Value: ips, + }) + } + + if len(c.Cert.ExcludedIPRanges) > 0 { + var ips []string + for _, ip := range c.Cert.ExcludedIPRanges { + ips = append(ips, ip.String()) + } + f = append(f, Field{ + Key: "Excluded IP addresses", + Value: ips, + }) + } + + if len(c.Cert.CRLDistributionPoints) > 0 { + f = append(f, Field{ + Key: "CRL distribution points", + Value: c.Cert.CRLDistributionPoints, + }) + } + + return Section{ + Title: "Certificate authority", + Fields: f, + } +} diff --git a/pkg/inspect/crl.go b/pkg/inspect/crl.go new file mode 100644 index 0000000..d449046 --- /dev/null +++ b/pkg/inspect/crl.go @@ -0,0 +1,119 @@ +package inspect + +import ( + "crypto/x509" +) + +// DERCRLInfo attempts to parse a DER-form X.509 CRL and returns information +// about it. +func DERCRLInfo(loc string, der []byte) Info { + crl, err := x509.ParseRevocationList(der) + if err != nil { + return &BadInfo{ + Typ: TypeX509CRL, + Loc: loc, + Underlying: err, + } + } + + return CRLInfo(loc, crl) +} + +// CRLInfo returns information about a CRL. Note it holds a reference to the +// crl argument. +func CRLInfo(loc string, crl *x509.RevocationList) *CRL { + return &CRL{ + Loc: loc, + CRL: crl, + } +} + +// CRL holds structured information about a CRL. It implements Info. +type CRL struct { + // Loc is the location the CRL was encountered. + Loc string + + // CRL is the raw CRL. + CRL *x509.RevocationList +} + +// Type indicates this is a private key. +func (crl *CRL) Type() Type { + return TypeX509CRL +} + +// Location returns the location data stored by PrivateKeyInfo. +func (crl *CRL) Location() string { + return crl.Loc +} + +// Info returns structured information about the prvate key. +func (crl *CRL) Info() []Section { + return []Section{ + crl.CRLSection(), + crl.IssuerSection(), + crl.RevocationSection(), + } +} + +// CRLSection returns metadata about the CRL itself. +func (crl *CRL) CRLSection() Section { + return Section{ + Title: "Metadata", + Fields: []Field{ + Field{ + Key: "Serial number", + Value: FormatHexBytes(crl.CRL.Number.Bytes()), + }, + Field{ + Key: "Valid from", + Value: crl.CRL.ThisUpdate, + }, + Field{ + Key: "Valid until", + Value: crl.CRL.NextUpdate, + }, + }, + } +} + +func (crl *CRL) IssuerSection() Section { + f := []Field{ + Field{ + Key: "Description", + Value: crl.CRL.Issuer.CommonName, + }, + } + f = appendX509DNField(f, crl.CRL.Issuer) + if len(crl.CRL.AuthorityKeyId) > 0 { + f = append(f, Field{ + Key: "Key ID", + Value: FormatHexBytes(crl.CRL.AuthorityKeyId), + }) + } + return Section{ + Title: "Issuer", + Fields: f, + } +} + +func (crl *CRL) RevocationSection() Section { + revoked := make([]string, len(crl.CRL.RevokedCertificates)) + for i := range crl.CRL.RevokedCertificates { + revoked[i] = FormatHexBytes(crl.CRL.RevokedCertificates[i].SerialNumber.Bytes()) + } + + return Section{ + Title: "Revoked certificates", + Fields: []Field{ + Field{ + Key: "Count", + Value: len(revoked), + }, + Field{ + Key: "Revoked", + Value: revoked, + }, + }, + } +} diff --git a/pkg/inspect/csr.go b/pkg/inspect/csr.go new file mode 100644 index 0000000..2fc7b64 --- /dev/null +++ b/pkg/inspect/csr.go @@ -0,0 +1,103 @@ +package inspect + +import ( + "crypto/rsa" + "crypto/x509" + "errors" +) + +// DERCSRInfo attempts to parse a DER-form X.509 CSR and returns information +// about it. +func DERCSRInfo(loc string, der []byte) Info { + csr, err := x509.ParseCertificateRequest(der) + if err != nil { + return &BadInfo{ + Typ: TypeX509CSR, + Loc: loc, + Underlying: err, + } + } + + return CSRInfo(loc, csr) +} + +// CSRInfo returns information about a CSR. Note it holds a reference to the +// csr argument. +func CSRInfo(loc string, csr *x509.CertificateRequest) Info { + key, ok := csr.PublicKey.(*rsa.PublicKey) + if !ok { + return &BadInfo{ + Typ: TypeX509CSR, + Loc: loc, + Underlying: errors.New("CSR public key is not RSA"), + } + } + + return &CSR{ + Loc: loc, + CSR: csr, + Public: PublicKeyInfo(loc, key), + } +} + +// CSR holds structured information about a CSR. It implements Info. +type CSR struct { + // Loc is the location the CSR was encountered. + Loc string + + // CSR is the raw CSR. + CSR *x509.CertificateRequest + + // Public holds information about the public portion of the key. + Public *PublicKey +} + +// Type indicates this is a private key. +func (csr *CSR) Type() Type { + return TypeX509CSR +} + +// Location returns the location data stored by PrivateKeyInfo. +func (csr *CSR) Location() string { + return csr.Loc +} + +// Info returns structured information about the prvate key. +func (csr *CSR) Info() []Section { + return []Section{ + csr.SubjectSection(), + csr.Public.PublicKeyInfoSection(), + } +} + +// SubjectSection returns information about the subject. +func (csr *CSR) SubjectSection() Section { + f := []Field{ + Field{ + Key: "Description", + Value: csr.CSR.Subject.CommonName, + }, + } + f = appendX509DNField(f, csr.CSR.Subject) + if len(csr.CSR.DNSNames) > 0 { + f = append(f, Field{ + Key: "Hostnames", + Value: csr.CSR.DNSNames, + }) + } + if len(csr.CSR.IPAddresses) > 0 { + var ips []string + for _, ip := range csr.CSR.IPAddresses { + ips = append(ips, ip.String()) + } + f = append(f, Field{ + Key: "IP addresses", + Value: ips, + }) + } + + return Section{ + Title: "Subject", + Fields: f, + } +} diff --git a/pkg/inspect/inspect.go b/pkg/inspect/inspect.go new file mode 100644 index 0000000..875c377 --- /dev/null +++ b/pkg/inspect/inspect.go @@ -0,0 +1,110 @@ +/* +Package inspect describes RSA-related objects such as private keys and X.509 +certificates using structured, annotated key-value pairs. +*/ +package inspect + +// Type of item. +type Type int + +const ( + TypePrivateKey Type = iota + TypePublicKey + TypeX509Certificate + TypeX509CRL + TypeX509CSR + TypeTLSConnectionState +) + +func (t Type) String() string { + switch t { + case TypePrivateKey: + return "RSA private key" + case TypePublicKey: + return "RSA public key" + case TypeX509Certificate: + return "X.509 certificate" + case TypeX509CRL: + return "certificate revocation list" + case TypeX509CSR: + return "certificate signing request" + case TypeTLSConnectionState: + return "TLS connection state" + default: + return "???" + } +} + +// Info about an item. +type Info interface { + // Type of item. + Type() Type + + // Location where the item was encountered (e.g. line number for a file, + // or position in certificate chain for TLS handshake. + Location() string + + // Info returns a structured set of information about the item. + Info() []Section +} + +// Section is a set of related fields. +type Section struct { + // Title of section. + Title string + + // Fields contain a related set of key, value pairs. + Fields []Field +} + +// Field is an aspect of information about an item. +type Field struct { + // Key is the name of the aspect. + Key string + + // Value is its value. + Value any +} + +// BadInfo is an item which cannot be parsed, e.g. an invalid DER-encoded item +// within a PEM block. It implements both error and Info. +type BadInfo struct { + Typ Type + Loc string + Underlying error +} + +// Type reports the type of item that was being parsed. +func (b *BadInfo) Type() Type { + return b.Typ +} + +// Location reports further information about the location of the item. +func (b *BadInfo) Location() string { + return b.Loc +} + +// Info reports an "Error encountered" section with an "Error" field. +func (b *BadInfo) Info() []Section { + return []Section{ + Section{ + Title: "Error encountered", + Fields: []Field{ + Field{ + Key: "Error", + Value: b.Underlying, + }, + }, + }, + } +} + +// Error returns the underlying error string. +func (b *BadInfo) Error() string { + return b.Underlying.Error() +} + +// Unwrap returns the underlying error. +func (b *BadInfo) Unwrap() error { + return b.Underlying +} diff --git a/pkg/inspect/pem.go b/pkg/inspect/pem.go new file mode 100644 index 0000000..314f2f4 --- /dev/null +++ b/pkg/inspect/pem.go @@ -0,0 +1,44 @@ +package inspect + +import ( + "bytes" + "encoding/pem" + "fmt" + + "src.lwithers.me.uk/go/rsa/pkg/pemfile" +) + +// LoadPEM returns a set of information about a PEM file. +func LoadPEM(data []byte) []Info { + var ( + info []Info + line int = 1 + ) + for { + p, rest := pem.Decode(data) + if p == nil { + return info + } + loc := fmt.Sprintf("line %d", line) + + switch p.Type { + case pemfile.TypePKCS1PrivateKey: + info = append(info, PKCS1PrivateKeyInfo(loc, p.Bytes)) + case pemfile.TypePKCS8PrivateKey: + info = append(info, PKCS8PrivateKeyInfo(loc, p.Bytes)) + case pemfile.TypePKCS1PublicKey: + info = append(info, PKCS1PublicKeyInfo(loc, p.Bytes)) + case pemfile.TypePKIXPublicKey: + info = append(info, PKIXPublicKeyInfo(loc, p.Bytes)) + case pemfile.TypeX509Certificate: + info = append(info, DERCertificateInfo(loc, p.Bytes)) + case pemfile.TypeX509CRL: + info = append(info, DERCRLInfo(loc, p.Bytes)) + case pemfile.TypeX509CSR: + info = append(info, DERCSRInfo(loc, p.Bytes)) + } + + line += bytes.Count(data[:len(data)-len(rest)], []byte{'\n'}) + data = rest + } +} diff --git a/pkg/inspect/private_key.go b/pkg/inspect/private_key.go new file mode 100644 index 0000000..19d1951 --- /dev/null +++ b/pkg/inspect/private_key.go @@ -0,0 +1,112 @@ +package inspect + +import ( + "crypto/rsa" + "crypto/x509" + "errors" +) + +// PKCS1PrivateKeyInfo attempts to parse a DER-form PKCS#1-encoded private RSA +// key, and returns information about it. +func PKCS1PrivateKeyInfo(loc string, der []byte) Info { + key, err := x509.ParsePKCS1PrivateKey(der) + if err != nil { + return &BadInfo{ + Typ: TypePrivateKey, + Loc: loc, + Underlying: err, + } + } + + return PrivateKeyInfo(loc, key) +} + +// PKCS1PrivateKeyInfo attempts to parse a DER-form PKCS#8-encoded private RSA +// key, and returns information about it. +func PKCS8PrivateKeyInfo(loc string, der []byte) Info { + key, err := x509.ParsePKCS8PrivateKey(der) + if err != nil { + return &BadInfo{ + Typ: TypePrivateKey, + Loc: loc, + Underlying: err, + } + } + + rsakey, ok := key.(*rsa.PrivateKey) + if !ok { + return &BadInfo{ + Typ: TypePrivateKey, + Underlying: errors.New("PKCS#8 private key ytpe is not RSA"), + } + } + + return PrivateKeyInfo(loc, rsakey) +} + +// PrivateKeyInfo returns structured information about the given RSA private key. +func PrivateKeyInfo(loc string, key *rsa.PrivateKey) *PrivateKey { + pl := make([]int, len(key.Primes)) + for i, n := range key.Primes { + pl[i] = n.BitLen() + } + + return &PrivateKey{ + Loc: loc, + Primes: len(key.Primes), + PrimeLens: pl, + Public: PublicKeyInfo(loc, &key.PublicKey), + } +} + +// PrivateKey holds structured information about an RSA private key. It +// implements Info. +type PrivateKey struct { + // Loc is the location the key was encountered. + Loc string + + // Primes is the number of primes, ≥ 2. + Primes int + + // PrimeLens holds the bit length of each prime. + PrimeLens []int + + // Public holds information about the public portion of the key. + Public *PublicKey +} + +// Type indicates this is a private key. +func (priv *PrivateKey) Type() Type { + return TypePrivateKey +} + +// Location returns the location data stored by PrivateKeyInfo. +func (priv *PrivateKey) Location() string { + return priv.Loc +} + +// Info returns structured information about the prvate key. +func (priv *PrivateKey) Info() []Section { + return []Section{ + priv.PrivateKeyInfoSection(), + priv.Public.PublicKeyInfoSection(), + } +} + +// PrivateKeyInfoSection returns the RSA private key specific information +// section. +func (priv *PrivateKey) PrivateKeyInfoSection() Section { + return Section{ + Title: "RSA private key", + Fields: []Field{ + Field{ + Key: "Primes", + Value: priv.Primes, + }, + Field{ + Key: "Prime lengths", + Value: priv.PrimeLens, + }, + }, + } +} diff --git a/pkg/inspect/public_key.go b/pkg/inspect/public_key.go new file mode 100644 index 0000000..532b348 --- /dev/null +++ b/pkg/inspect/public_key.go @@ -0,0 +1,145 @@ +package inspect + +import ( + "crypto/rsa" + "crypto/sha512" + "crypto/x509" + "encoding/binary" + "errors" + "strings" +) + +// PKCS1PublicKeyInfo attempts to parse a DER-form PKCS#1-encoded public RSA +// key, and returns information about it. +func PKCS1PublicKeyInfo(loc string, der []byte) Info { + key, err := x509.ParsePKCS1PublicKey(der) + if err != nil { + return &BadInfo{ + Typ: TypePublicKey, + Loc: loc, + Underlying: err, + } + } + + return PublicKeyInfo(loc, key) +} + +// PKCS1PublicKeyInfo attempts to parse a DER-form PKIX-encoded public RSA +// key, and returns information about it. +func PKIXPublicKeyInfo(loc string, der []byte) Info { + key, err := x509.ParsePKIXPublicKey(der) + if err != nil { + return &BadInfo{ + Typ: TypePublicKey, + Loc: loc, + Underlying: err, + } + } + + rsakey, ok := key.(*rsa.PublicKey) + if !ok { + return &BadInfo{ + Typ: TypePublicKey, + Loc: loc, + Underlying: errors.New("PKIX public key type is not RSA"), + } + } + + return PublicKeyInfo(loc, rsakey) +} + +// PublicKeyInfo returns structured information about the given RSA public key. +func PublicKeyInfo(loc string, key *rsa.PublicKey) *PublicKey { + return &PublicKey{ + Loc: loc, + Bits: key.N.BitLen(), + Fingerprint: KeyFingerprint(key), + } +} + +// PublicKey holds structured information about an RSA public key. It +// implements Info. +type PublicKey struct { + // Loc is the location the key was encountered. + Loc string + + // Bits is the key size. + Bits int + + // Fingerprint of the modulus, which can be used to compare keys. + Fingerprint Fingerprint +} + +// Type indicates this a a public key. +func (pub *PublicKey) Type() Type { + return TypePublicKey +} + +// Location returns the location data stored by PublicKeyInfo. +func (pub *PublicKey) Location() string { + return pub.Loc +} + +// Info returns structured information about the public key. +func (pub *PublicKey) Info() []Section { + return []Section{ + pub.PublicKeyInfoSection(), + } +} + +// PublicKeyInfoSection returns the RSA public key specific information +// section. +func (pub *PublicKey) PublicKeyInfoSection() Section { + return Section{ + Title: "RSA public key", + Fields: []Field{ + Field{ + Key: "Bits", + Value: pub.Bits, + }, + Field{ + Key: "Fingerprint", + Value: pub.Fingerprint, + }, + }, + } +} + +// Fingerprint of a key. +type Fingerprint []byte + +// KeyFingerprint computes the fingerprint of an RSA key. It only requires the +// public section of the key for this. +func KeyFingerprint(key *rsa.PublicKey) Fingerprint { + f := sha512.New512_224() + var b [8]byte + E := uint64(key.E) + binary.BigEndian.PutUint64(b[:], E) + f.Write(b[:]) + f.Write(key.N.Bytes()) + return Fingerprint(f.Sum(nil)) +} + +// String returns a nicely-formatted hex string that is easy to read/compare. +func (f Fingerprint) String() string { + return FormatHexBytes([]byte(f)) +} + +const hexDig = "0123456789ABCDEF" + +// FormatHexBytes returns a nicely-formatted hex string that is easy to read/compare. +func FormatHexBytes(b []byte) string { + var s strings.Builder + + for len(b) > 1 { + s.WriteByte(hexDig[b[0]>>4]) + s.WriteByte(hexDig[b[0]&15]) + s.WriteByte(':') + b = b[1:] + } + if len(b) > 0 { + s.WriteByte(hexDig[b[0]>>4]) + s.WriteByte(hexDig[b[0]&15]) + } + return s.String() +} diff --git a/pkg/inspect/tls.go b/pkg/inspect/tls.go new file mode 100644 index 0000000..c310220 --- /dev/null +++ b/pkg/inspect/tls.go @@ -0,0 +1,158 @@ +package inspect + +import ( + "crypto/tls" + "fmt" +) + +// ConnectionState returns a set of information about a TLS ConnectionState. +func ConnectionState(c tls.ConnectionState) []Info { + var info = []Info{ + &TLSConnectionState{ + Version: c.Version, + CipherSuite: c.CipherSuite, + }, + } + + for i, cert := range c.PeerCertificates { + loc := "leaf certificate" + if i > 0 { + loc = fmt.Sprintf("intermediate certificate #%d", i) + } + info = append(info, CertificateInfo(loc, cert)) + } + + return info +} + +// PrivateKeyInfo returns structured information about the given RSA private key. +/* +func PrivateKeyInfo(loc string, key *rsa.PrivateKey) *PrivateKey { + pl := make([]int, len(key.Primes)) + for i, n := range key.Primes { + pl[i] = n.BitLen() + } + + return &PrivateKey{ + Loc: loc, + Primes: len(key.Primes), + PrimeLens: pl, + Public: PublicKeyInfo(loc, &key.PublicKey), + } +} +*/ + +// TLSSecurity provides an indicative security assessment of a negotiated TLS +// connection. +type TLSSecurity int + +const ( + TLSSecurityStrong = iota + TLSSecurityWeak + TLSSecurityInsecure +) + +func (ts TLSSecurity) String() string { + switch ts { + case TLSSecurityStrong: + return "strong" + case TLSSecurityWeak: + return "weak" + case TLSSecurityInsecure: + return "insecure" + } + return "???" +} + +// TLSConnectionState holds structured information about a negotiated TLS +// connection. It does not include the peer's certificate information. +type TLSConnectionState struct { + // Version of TLS protocol used. + Version uint16 + + // CipherSuite negotiated. + CipherSuite uint16 +} + +// Type indicates this is a private key. +func (tcs *TLSConnectionState) Type() Type { + return TypeTLSConnectionState +} + +// Location returns the location data stored by PrivateKeyInfo. +func (tcs *TLSConnectionState) Location() string { + return "TLS handshake" +} + +// Info returns structured information about the prvate key. +func (tcs *TLSConnectionState) Info() []Section { + var security TLSSecurity = TLSSecurityStrong + + ciphersuite := tls.CipherSuiteName(tcs.CipherSuite) + + switch tcs.CipherSuite { + case tls.TLS_RSA_WITH_RC4_128_SHA, + tls.TLS_RSA_WITH_3DES_EDE_CBC_SHA, + tls.TLS_RSA_WITH_AES_128_CBC_SHA, + tls.TLS_RSA_WITH_AES_256_CBC_SHA, + tls.TLS_RSA_WITH_AES_128_CBC_SHA256, + tls.TLS_RSA_WITH_AES_128_GCM_SHA256, + tls.TLS_RSA_WITH_AES_256_GCM_SHA384: + // using plain RSA for key exchange isn't good + security = TLSSecurityInsecure + + case tls.TLS_ECDHE_RSA_WITH_RC4_128_SHA, + tls.TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA: + // broken/bad symmetric ciphers + security = TLSSecurityInsecure + + case tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA, + tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA, + tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256: + // CBC now considered weaker + security = TLSSecurityWeak + + default: + // allow Go's opinion to augment our own + for _, bad := range tls.InsecureCipherSuites() { + if tcs.CipherSuite == bad.ID { + security = TLSSecurityInsecure + break + } + } + } + + var version string + switch tcs.Version { + case tls.VersionTLS10: + version = "TLSv1.0" + security = TLSSecurityInsecure + case tls.VersionTLS11: + version = "TLSv1.1" + security = TLSSecurityInsecure + case tls.VersionTLS12: + version = "TLSv1.2" + case tls.VersionTLS13: + version = "TLSv1.3" + } + + return []Section{ + Section{ + Title: "TLS protocol negotatiated", + Fields: []Field{ + Field{ + Key: "Version", + Value: version, + }, + Field{ + Key: "Cipher suite", + Value: ciphersuite, + }, + Field{ + Key: "Security", + Value: security, + }, + }, + }, + } +} diff --git a/pkg/inspect/x509_dn.go b/pkg/inspect/x509_dn.go new file mode 100644 index 0000000..869ae98 --- /dev/null +++ b/pkg/inspect/x509_dn.go @@ -0,0 +1,18 @@ +package inspect + +import "crypto/x509/pkix" + +// appendX509DNField will append a field containing the X.509 distinguished name +// (approximately) if it contains anything more than a common name. +func appendX509DNField(in []Field, name pkix.Name) []Field { + name.CommonName = "" + s := name.String() + if s == "" { + return in + } + + return append(in, Field{ + Key: "Other", + Value: s, + }) +} diff --git a/pkg/pemfile/pemfile.go b/pkg/pemfile/pemfile.go new file mode 100644 index 0000000..db62372 --- /dev/null +++ b/pkg/pemfile/pemfile.go @@ -0,0 +1,185 @@ +package pemfile + +import ( + "bufio" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "errors" + "io/fs" + "os" + + "src.lwithers.me.uk/go/writefile" +) + +const ( + TypePKCS1PrivateKey = "RSA PRIVATE KEY" + TypePKCS8PrivateKey = "PRIVATE KEY" + TypePKCS1PublicKey = "RSA PUBLIC KEY" + TypePKIXPublicKey = "PUBLIC KEY" + TypeX509Certificate = "CERTIFICATE" + TypeX509CSR = "CERTIFICATE REQUEST" + TypeX509CRL = "X509 CRL" +) + +func Write(filename, typ string, der []byte) error { + finalFname, f, err := writefile.New(filename) + if err != nil { + return err + } + defer writefile.Abort(f) + + out := bufio.NewWriter(f) + pem.Encode(out, &pem.Block{ + Type: typ, + Bytes: der, + }) + if err = out.Flush(); err != nil { + return err + } + return writefile.Commit(finalFname, f) +} + +func ReadKey(filename string) (*rsa.PrivateKey, error) { + raw, err := os.ReadFile(filename) + if err != nil { + return nil, err + } + + for { + block, rest := pem.Decode(raw) + raw = rest + switch { + case block == nil: + return nil, &fs.PathError{ + Op: "decode", + Path: filename, + Err: errors.New("no private key PEM block found"), + } + + case block.Type == TypePKCS1PrivateKey: + key, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return nil, &fs.PathError{ + Op: "parse key", + Path: filename, + Err: err, + } + } + return key, nil + + case block.Type == TypePKCS8PrivateKey: + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, &fs.PathError{ + Op: "parse key", + Path: filename, + Err: err, + } + } + + rsakey, ok := key.(*rsa.PrivateKey) + if !ok { + return nil, &fs.PathError{ + Op: "parse key", + Path: filename, + Err: errors.New("not an RSA key"), + } + } + return rsakey, nil + } + } +} + +func ReadCert(filename string) (*x509.Certificate, error) { + raw, err := os.ReadFile(filename) + if err != nil { + return nil, err + } + + for { + block, rest := pem.Decode(raw) + raw = rest + switch { + case block == nil: + return nil, &fs.PathError{ + Op: "decode", + Path: filename, + Err: errors.New("no certificate PEM block found"), + } + + case block.Type == TypeX509Certificate: + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, &fs.PathError{ + Op: "parse cert", + Path: filename, + Err: err, + } + } + return cert, nil + } + } +} + +func ReadCRL(filename string) (*x509.RevocationList, error) { + raw, err := os.ReadFile(filename) + if err != nil { + return nil, err + } + + for { + block, rest := pem.Decode(raw) + raw = rest + switch { + case block == nil: + return nil, &fs.PathError{ + Op: "decode", + Path: filename, + Err: errors.New("no CRL PEM block found"), + } + + case block.Type == TypeX509CRL: + crl, err := x509.ParseRevocationList(block.Bytes) + if err != nil { + return nil, &fs.PathError{ + Op: "parse cert", + Path: filename, + Err: err, + } + } + return crl, nil + } + } +} + +func ReadCSR(filename string) (*x509.CertificateRequest, error) { + raw, err := os.ReadFile(filename) + if err != nil { + return nil, err + } + + for { + block, rest := pem.Decode(raw) + raw = rest + switch { + case block == nil: + return nil, &fs.PathError{ + Op: "decode", + Path: filename, + Err: errors.New("no CSR PEM block found"), + } + + case block.Type == TypeX509CSR: + csr, err := x509.ParseCertificateRequest(block.Bytes) + if err != nil { + return nil, &fs.PathError{ + Op: "parse CSR", + Path: filename, + Err: err, + } + } + return csr, nil + } + } +} diff --git a/pkg/serial/rand.go b/pkg/serial/rand.go new file mode 100644 index 0000000..21001d8 --- /dev/null +++ b/pkg/serial/rand.go @@ -0,0 +1,21 @@ +/* +Package serial provides serial number related functionality. +*/ +package serial + +import ( + "crypto/rand" + "io" + "math/big" +) + +const randBits = 160 + +// Rand returns a random serial number with 159 bits of entropy. +func Rand() *big.Int { + q := make([]byte, randBits/8) + io.ReadFull(rand.Reader, q) + // this just means all serial numbers are justified to the same bitlen, which is nice for tools! + q[0] |= 0x80 + return big.NewInt(0).SetBytes(q) +}