From 3fff766e395a39008ccf0cb51a199d757bd8a374 Mon Sep 17 00:00:00 2001 From: xiexianbin Date: Sun, 20 Jul 2025 16:07:06 +0800 Subject: [PATCH 1/5] feature: support ec/refactor cmd Signed-off-by: xiexianbin --- .gitignore | 3 +- Makefile | 28 +-- README.md | 65 ++++++- ca/base.go | 175 ++++++++++++++++++- ca/common.go | 186 ++++++++++++++++++++ ca/{util_test.go => common_test.go} | 0 ca/root.go | 253 ---------------------------- ca/rootca.go | 133 +++++++++++++++ ca/tls.go | 185 +++++++++++++++----- ca/util.go | 84 --------- cmd/createca.go | 98 +++++++++++ cmd/root.go | 71 ++++++++ cmd/sign.go | 109 ++++++++++++ cmd/xca.go | 19 +++ go.mod | 9 +- go.sum | 10 ++ main.go | 198 ---------------------- version.go | 2 +- 18 files changed, 1030 insertions(+), 598 deletions(-) create mode 100644 ca/common.go rename ca/{util_test.go => common_test.go} (100%) delete mode 100644 ca/root.go create mode 100644 ca/rootca.go delete mode 100644 ca/util.go create mode 100644 cmd/createca.go create mode 100644 cmd/root.go create mode 100644 cmd/sign.go create mode 100644 cmd/xca.go create mode 100644 go.sum delete mode 100644 main.go diff --git a/.gitignore b/.gitignore index 509f3fe..5e24b1c 100644 --- a/.gitignore +++ b/.gitignore @@ -14,8 +14,9 @@ # Dependency directories (remove the comment below to include it) # vendor/ .DS_Store -bin/ .history/ .idea/ .lh/ +bin/ +vendor x-ca diff --git a/Makefile b/Makefile index bf25439..44cde7e 100644 --- a/Makefile +++ b/Makefile @@ -69,40 +69,40 @@ clean: ## Run clean bin files .PHONY: build build: ## Build for current os - ${SUB_BUILD_CMD} -o bin/$(BINARY_NAME) + ${SUB_BUILD_CMD} -o bin/$(BINARY_NAME) ./cmd/... .PHONY: linux-amd64 linux-amd64: ## Build linux amd64 - CGO_ENABLED=0 ${GOARGS} ${SUB_BUILD_CMD} -o bin/${BINARY_NAME}-$@ + CGO_ENABLED=0 ${GOARGS} ${SUB_BUILD_CMD} -o bin/${BINARY_NAME}-$@ ./cmd/... .PHONY: linux-arm64 linux-arm64: ## Build linux arm64 - CGO_ENABLED=0 ${GOARGS} ${SUB_BUILD_CMD} -o bin/${BINARY_NAME}-$@ + CGO_ENABLED=0 ${GOARGS} ${SUB_BUILD_CMD} -o bin/${BINARY_NAME}-$@ ./cmd/... .PHONY: linux-ppc64le linux-ppc64le: ## Build linux ppc64le - CGO_ENABLED=0 ${GOARGS} ${SUB_BUILD_CMD} -o bin/${BINARY_NAME}-$@ + CGO_ENABLED=0 ${GOARGS} ${SUB_BUILD_CMD} -o bin/${BINARY_NAME}-$@ ./cmd/... .PHONY: linux-s390x linux-s390x: ## Build linux s390x - CGO_ENABLED=0 ${GOARGS} ${SUB_BUILD_CMD} -o bin/${BINARY_NAME}-$@ + CGO_ENABLED=0 ${GOARGS} ${SUB_BUILD_CMD} -o bin/${BINARY_NAME}-$@ ./cmd/... .PHONY: darwin-amd64 darwin-amd64: ## Build darwin amd64 - CGO_ENABLED=0 ${GOARGS} ${SUB_BUILD_CMD} -o bin/${BINARY_NAME}-$@ + CGO_ENABLED=0 ${GOARGS} ${SUB_BUILD_CMD} -o bin/${BINARY_NAME}-$@ ./cmd/... .PHONY: darwin-arm64 darwin-arm64: ## Build darwin arm64 - CGO_ENABLED=0 ${GOARGS} ${SUB_BUILD_CMD} -o bin/${BINARY_NAME}-$@ + CGO_ENABLED=0 ${GOARGS} ${SUB_BUILD_CMD} -o bin/${BINARY_NAME}-$@ ./cmd/... .PHONY: windows-amd64 windows-amd64: ## Build windows amd64 - CGO_ENABLED=0 ${GOARGS} ${SUB_BUILD_CMD} -o bin/${BINARY_NAME}-$@.exe + CGO_ENABLED=0 ${GOARGS} ${SUB_BUILD_CMD} -o bin/${BINARY_NAME}-$@.exe ./cmd/... -.PHONY: docker-build -docker-build: test ## Build docker image - docker build -t ${IMG} . +# .PHONY: docker-build +# docker-build: test ## Build docker image +# docker build -t ${IMG} . -.PHONY: docker-push -docker-push: ## Push docker image - docker push ${IMG} +# .PHONY: docker-push +# docker-push: ## Push docker image +# docker push ${IMG} diff --git a/README.md b/README.md index da200ce..a0df7ba 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ mv xca /usr/local/bin/ ``` $ xca --help Create Root CA and TLS CA: -xca -create-ca true \ +xca -create-ca \ -root-cert x-ca/ca/root-ca.crt \ -root-key x-ca/ca/root-ca/private/root-ca.key \ -tls-cert x-ca/ca/tls-ca.crt \ @@ -67,10 +67,37 @@ Source Code: ## Usage Demo -- create ca +### create EC CA + +You can specify the key type (`-key-type`) and curve (`-curve`) to create an EC root CA and TLS CA: + +``` +./xca -create-ca \ + -root-cert x-ca/ca/root-ca.crt \ + -root-key x-ca/ca/root-ca/private/root-ca.key \ + -tls-cert x-ca/ca/tls-ca.crt \ + -tls-key x-ca/ca/tls-ca/private/tls-ca.key \ + -tls-chain x-ca/ca/tls-ca-chain.pem \ + -key-type ec \ + -curve P256 +``` + +To sign a certificate with an EC key: + +``` +./xca -cn example.com \ + --domains "example.com" \ + -tls-cert x-ca/ca/tls-ca.crt \ + -tls-key x-ca/ca/tls-ca/private/tls-ca.key \ + -tls-chain x-ca/ca/tls-ca-chain.pem \ + -key-type ec \ + -curve P256 +``` + +### create RSA CA ``` -xca -create-ca true \ +xca -create-ca \ -root-cert x-ca/ca/root-ca.crt \ -root-key x-ca/ca/root-ca/private/root-ca.key \ -tls-cert x-ca/ca/tls-ca.crt \ @@ -117,3 +144,35 @@ Use `openssl rsa -in root-ca.key -des3` change cipher ## Ref - [基于OpenSSL签署根CA、二级CA](https://www.xiexianbin.cn/s/ca/) + +``` +go.mod - Added cobra dependency +ca/baseca.go - Common CA functionality +ca/common.go - Shared utilities +cmd/create.go - create-ca command +cmd/sign.go - sign command +cmd/root.go - root cobra command +cmd/xca.go - main entry point (refactored) +``` + +## Usage Examples + +``` + +go build -o bin/xca ./cmd/... + +# Create CA certificates +xca create-ca --key-type ec --curve P256 + +# Sign domain certificate +xca sign example.com --domains "example.com,www.example.com" + +# Sign IP certificate +xca sign 192.168.1.1 --ips "192.168.1.1" + +# Get help +xca --help +xca create-ca --help +xca sign --help + +``` diff --git a/ca/base.go b/ca/base.go index 53f7389..cdacae2 100644 --- a/ca/base.go +++ b/ca/base.go @@ -13,9 +13,180 @@ limitations under the License. package ca +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "os" + "path" + "strings" +) + type CA interface { - CreateKey() error + GenerateKey() error CreateCert() error Write(keyPath, certPath, chainPath string) error - //Load(keyPath, certPath string) (interface{}, error) + //Load(keyPath, certPath string) (any, error) +} + +// BaseCA represents common functionality for all CA types +type BaseCA struct { + Key any // *rsa.PrivateKey or *ecdsa.PrivateKey + Cert *x509.Certificate + KeyBits int + Curve string +} + +// GenerateKey generates a new private key based on key type +func (b *BaseCA) GenerateKey(keyType string) error { + switch strings.ToLower(keyType) { + case "ec", "ecdsa": + var curve elliptic.Curve + switch b.Curve { + case "P224": + curve = elliptic.P224() + case "P256": + curve = elliptic.P256() + case "P384": + curve = elliptic.P384() + case "P521": + curve = elliptic.P521() + default: + return fmt.Errorf("unsupported curve %s", b.Curve) + } + key, err := ecdsa.GenerateKey(curve, rand.Reader) + if err != nil { + return err + } + b.Key = key + case "rsa": + key, err := rsa.GenerateKey(rand.Reader, b.KeyBits) + if err != nil { + return err + } + b.Key = key + default: + return fmt.Errorf("unsupported key type %s", keyType) + } + return nil +} + +// GetPublicKey extracts the public key from the private key +func (b *BaseCA) GetPublicKey() (any, error) { + switch k := b.Key.(type) { + case *rsa.PrivateKey: + return k.Public(), nil + case *ecdsa.PrivateKey: + return k.Public(), nil + default: + return nil, fmt.Errorf("unsupported key type") + } +} + +// WriteKey writes the private key to a PEM file +func (b *BaseCA) WriteKey(keyPath string) error { + // Create directory if it doesn't exist + err := os.MkdirAll(path.Dir(keyPath), 0700) + if err != nil && !os.IsExist(err) { + return err + } + + keyFile, err := os.OpenFile(keyPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err != nil { + return err + } + defer keyFile.Close() + + var keyType string + var keyBytes []byte + switch k := b.Key.(type) { + case *rsa.PrivateKey: + keyType = "RSA PRIVATE KEY" + keyBytes = x509.MarshalPKCS1PrivateKey(k) + case *ecdsa.PrivateKey: + keyType = "EC PRIVATE KEY" + var err error + keyBytes, err = x509.MarshalECPrivateKey(k) + if err != nil { + return err + } + default: + return fmt.Errorf("unsupported key type") + } + + return pem.Encode(keyFile, &pem.Block{ + Type: keyType, + Bytes: keyBytes, + }) +} + +// WriteCert writes the certificate to a PEM file +func (b *BaseCA) WriteCert(certPath string) error { + // Create directory if it doesn't exist + err := os.MkdirAll(path.Dir(certPath), 0700) + if err != nil && !os.IsExist(err) { + return err + } + + certFile, err := os.OpenFile(certPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err != nil { + return err + } + defer certFile.Close() + + return pem.Encode(certFile, &pem.Block{ + Type: "CERTIFICATE", + Bytes: b.Cert.Raw, + }) +} + +// LoadKey loads a private key from a PEM file +func (b *BaseCA) LoadKey(keyPath string) error { + keyBytes, err := os.ReadFile(keyPath) + if err != nil { + return err + } + + keyBlock, _ := pem.Decode(keyBytes) + if keyBlock == nil { + return fmt.Errorf("decode key is nil") + } + + // Check if encrypted + isEncrypted := len(keyBlock.Headers) > 0 && keyBlock.Headers["Proc-Type"] == "4,ENCRYPTED" + if isEncrypted { + return fmt.Errorf("encrypted PEM blocks are not supported - please decrypt your key first, using: openssl rsa -in encrypted.key -out decrypted.key") + } + + switch keyBlock.Type { + case "RSA PRIVATE KEY": + b.Key, err = x509.ParsePKCS1PrivateKey(keyBlock.Bytes) + case "EC PRIVATE KEY": + b.Key, err = x509.ParseECPrivateKey(keyBlock.Bytes) + default: + return fmt.Errorf("unsupported PEM type %s", keyBlock.Type) + } + return err +} + +// LoadCert loads a certificate from a PEM file +func (b *BaseCA) LoadCert(certPath string) error { + certBytes, err := os.ReadFile(certPath) + if err != nil { + return err + } + + certBlock, _ := pem.Decode(certBytes) + if certBlock == nil { + return fmt.Errorf("decode cert is nil") + } else if certBlock.Type != "CERTIFICATE" { + return fmt.Errorf("unsupported PEM type %s", certBlock.Type) + } + + b.Cert, err = x509.ParseCertificate(certBlock.Bytes) + return err } diff --git a/ca/common.go b/ca/common.go new file mode 100644 index 0000000..0fb3ea3 --- /dev/null +++ b/ca/common.go @@ -0,0 +1,186 @@ +/* +Copyright © 2022 xiexianbin.cn +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package ca + +import ( + "bytes" + "crypto" + "crypto/ecdsa" + "crypto/rand" + "crypto/rsa" + "crypto/sha1" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/pem" + "fmt" + "math" + "math/big" + "net" + "os" + "path" + "regexp" +) + +// Common CA constants +const ( + DefaultKeyBits = 2048 + DefaultCurve = "P256" + + RootCertCountry = "CN" + RootCertOrganization = "X CA" + RootCertOrganizationalUnit = "www.xiexianbin.cn" + RootCertCN = "X Root CA - R1" + RootCertYears = 60 +) + +// CreateCertificateChain writes a certificate chain to file +func CreateCertificateChain(chainPath string, certs ...*x509.Certificate) error { + if len(certs) == 0 { + return fmt.Errorf("no certificates provided") + } + + // Create directory if it doesn't exist + err := os.MkdirAll(path.Dir(chainPath), 0700) + if err != nil && !os.IsExist(err) { + return err + } + + chainFile, err := os.OpenFile(chainPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err != nil { + return err + } + defer chainFile.Close() + + for _, cert := range certs { + err = pem.Encode(chainFile, &pem.Block{ + Type: "CERTIFICATE", + Bytes: cert.Raw, + }) + if err != nil { + return err + } + } + + return nil +} + +// ValidateKeyCertMatch validates that a private key matches a certificate +func ValidateKeyCertMatch(privateKey any, cert *x509.Certificate) error { + var pubKey crypto.PublicKey + switch k := privateKey.(type) { + case *rsa.PrivateKey: + pubKey = k.Public() + case *ecdsa.PrivateKey: + pubKey = k.Public() + default: + return fmt.Errorf("unsupported key type") + } + + keyPKBytes, err := x509.MarshalPKIXPublicKey(pubKey) + if err != nil { + return err + } + + certPKBytes, err := x509.MarshalPKIXPublicKey(cert.PublicKey) + if err != nil { + return err + } + + if !bytes.Equal(keyPKBytes, certPKBytes) { + return fmt.Errorf("public key in certificate doesn't match private key") + } + + return nil +} + +// CheckFileExists checks if a file exists +func CheckFileExists(filePath string) (bool, error) { + _, err := os.ReadFile(filePath) + if err == nil { + return true, nil + } + if os.IsNotExist(err) { + return false, nil + } + return false, err +} + +// EnsureDirectory creates a directory if it doesn't exist +func EnsureDirectory(dirPath string) error { + return os.MkdirAll(dirPath, 0700) +} + +// CreateFile creates a file with exclusive creation mode +func CreateFile(filePath string) (*os.File, error) { + return os.OpenFile(filePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) +} + +func randSerial(x int64) *big.Int { + if x > 0 { + return big.NewInt(x) + } + + b, err := rand.Int(rand.Reader, big.NewInt(math.MaxInt64)) + if err != nil { + return big.NewInt(1) + } + return b +} + +func calculateKeyID(pubKey crypto.PublicKey) ([]byte, error) { + pkixByte, err := x509.MarshalPKIXPublicKey(pubKey) + if err != nil { + return nil, err + } + + var pkiInfo struct { + Algorithm pkix.AlgorithmIdentifier + SubjectPublicKey asn1.BitString + } + _, err = asn1.Unmarshal(pkixByte, &pkiInfo) + if err != nil { + return nil, err + } + skid := sha1.Sum(pkiInfo.SubjectPublicKey.Bytes) + return skid[:], nil +} + +func ParseDomains(domainStr []string) ([]string, error) { + var domainSlice []string + re := regexp.MustCompile("^[A-Za-z0-9-.*]+$") + for _, s := range domainStr { + if re.MatchString(s) { + domainSlice = append(domainSlice, s) + } else { + return nil, fmt.Errorf("invalid domain %s", s) + } + } + + return domainSlice, nil +} + +func ParseIPs(ipStr []string) (ipSlice []net.IP, err error) { + for _, s := range ipStr { + if len(s) == 0 { + continue + } + p := net.ParseIP(s) + if p == nil { + return nil, fmt.Errorf("invalid IP %s", s) + } + ipSlice = append(ipSlice, p) + } + return ipSlice, nil +} diff --git a/ca/util_test.go b/ca/common_test.go similarity index 100% rename from ca/util_test.go rename to ca/common_test.go diff --git a/ca/root.go b/ca/root.go deleted file mode 100644 index aade3eb..0000000 --- a/ca/root.go +++ /dev/null @@ -1,253 +0,0 @@ -/* -Copyright © 2022 xiexianbin.cn -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - http://www.apache.org/licenses/LICENSE-2.0 -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package ca - -import ( - "bytes" - "crypto/rand" - "crypto/rsa" - "crypto/x509" - "crypto/x509/pkix" - "encoding/pem" - "fmt" - "os" - "path" - "sort" - "time" -) - -const ( - rootCertCountry = "CN" - rootCertOrganization = "X CA" - rootCertOrganizationalUnit = "www.xiexianbin.cn" - rootCertCN = "X Root CA - R1" - rootCertYears = 60 -) - -var ( - // sort.StringsAreSorted(supportPemType) == true - supportPemType = []string{"ECDSA PRIVATE KEY", "RSA PRIVATE KEY"} -) - -type RootCA struct { - Key *rsa.PrivateKey - Cert *x509.Certificate - KeyBits int // 1024 * 2^x -} - -// NewRootCA create new root CA -func NewRootCA(keyBits int) (*RootCA, error) { - rootCA := &RootCA{ - KeyBits: keyBits, - } - - if err := rootCA.CreateKey(); err != nil { - return nil, err - } - - if err := rootCA.CreateCert(); err != nil { - return nil, err - } - - return rootCA, nil -} - -// LoadRootCA create new tls CA -func LoadRootCA(keyPath, certPath, password string) (*RootCA, error) { - keyBytes, kErr := os.ReadFile(keyPath) - certBytes, cErr := os.ReadFile(certPath) - if kErr != nil { - return nil, kErr - } else if cErr != nil { - return nil, cErr - } - - // parse key - keyBlock, _ := pem.Decode(keyBytes) - if keyBlock == nil { - return nil, fmt.Errorf("decode key is nil") - } else if supportPemType[sort.SearchStrings(supportPemType, keyBlock.Type)] != keyBlock.Type { - return nil, fmt.Errorf("unsupport PEM type %s", keyBlock.Type) - } - - /* Fix x-ca/ca root/tls key Problem - * https://github.com/x-ca/ca/blob/f82f6cc529662d5a751b79d87698a13c65f342ec/etc/root-ca.conf#L15 - * https://security.stackexchange.com/questions/93417/what-encryption-is-applied-on-a-key-generated-by-openssl-req - * https://rfc-editor.org/rfc/rfc1423.html - * openssl asn1parse -in root-ca.key -i | cut -c-90 - * - golang code - * - * if x509.IsEncryptedPEMBlock(keyBlock) == true { - * der, err := x509.DecryptPEMBlock(keyBlock, []byte("pwd")) - * key, _ = x509.ParsePKCS1PrivateKey(der) - * } else { - * key, err = x509.ParsePKCS1PrivateKey(keyBlock.Bytes) - * } - * - * Raise error: `Error: fromPEMBytes: x509: no DEK-Info header in block` - * - * - fix run: `openssl rsa -in root-ca.key -des3` - */ - var key *rsa.PrivateKey - var err error - if x509.IsEncryptedPEMBlock(keyBlock) == true { - // https://pkg.go.dev/crypto/x509@go1.22.2#IsEncryptedPEMBlock - fmt.Println("Legacy PEM encryption as specified in RFC 1423 is insecure by design. " + - "Since it does not authenticate the ciphertext, it is vulnerable to padding " + - "oracle attacks that can let an attacker recover the plaintext.\n" + - "https://pkg.go.dev/crypto/x509@go1.22.2#IsEncryptedPEMBlock") - der, err := x509.DecryptPEMBlock(keyBlock, []byte(password)) - if err != nil { - return nil, err - } - key, _ = x509.ParsePKCS1PrivateKey(der) - } else { - key, err = x509.ParsePKCS1PrivateKey(keyBlock.Bytes) - } - if err != nil { - return nil, fmt.Errorf("load private key %s, error %s", keyPath, err) - } - - // parse cert - certBlock, _ := pem.Decode(certBytes) - if certBlock == nil { - return nil, fmt.Errorf("decode cert is nil") - } else if certBlock.Type != "CERTIFICATE" { - return nil, fmt.Errorf("unsupport PEM type %s", certBlock.Type) - } - cert, err := x509.ParseCertificate(certBlock.Bytes) - if err != nil { - return nil, fmt.Errorf("parse CA certificate %s, error %s", certPath, err) - } - - // compare key and cert match? - keyPKBytes, keyPKErr := x509.MarshalPKIXPublicKey(key.Public()) - if keyPKErr != nil { - return nil, keyPKErr - } - certPKBytes, certPKErr := x509.MarshalPKIXPublicKey(cert.PublicKey) - if certPKErr != nil { - return nil, certPKErr - } - if !bytes.Equal(keyPKBytes, certPKBytes) { - return nil, fmt.Errorf("public key in CA certificate %s don't match private key in %s", certPath, keyPath) - } - - rootCA := &RootCA{ - Key: key, - Cert: cert, - } - - return rootCA, nil -} - -// CreateKey create root key -func (c *RootCA) CreateKey() error { - rootKey, err := rsa.GenerateKey(rand.Reader, c.KeyBits) - if err != nil { - return err - } - c.Key = rootKey - return nil -} - -// CreateCert create root cert -func (c *RootCA) CreateCert() error { - rootKeyID, err := calculateKeyID(c.Key.Public()) - if err != nil { - return err - } - - rootCSR := &x509.Certificate{ - Version: 3, - SerialNumber: randSerial(1), // default tls serial number is 1 - Subject: pkix.Name{ - Country: []string{rootCertCountry}, - Organization: []string{rootCertOrganization}, - OrganizationalUnit: []string{rootCertOrganizationalUnit}, - CommonName: rootCertCN, - }, - - NotBefore: time.Now(), - NotAfter: time.Now().AddDate(rootCertYears, 0, 0), - - SubjectKeyId: rootKeyID, - AuthorityKeyId: rootKeyID, - BasicConstraintsValid: true, - IsCA: true, - MaxPathLen: 1, - MaxPathLenZero: false, - KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign | x509.KeyUsageDigitalSignature, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, - } - - der, err := x509.CreateCertificate(rand.Reader, rootCSR, rootCSR, c.Key.Public(), c.Key) - if err != nil { - return err - } - - certificate, err := x509.ParseCertificate(der) - if err != nil { - return err - } - c.Cert = certificate - return nil -} - -// Write root key/cert to file -func (c *RootCA) Write(rootCAKeyPath, rootCACertPath, chainPath string) error { - var err error - // mkdir - err = os.MkdirAll(path.Dir(rootCAKeyPath), 0700) - if err != nil && !os.IsExist(err) { - return err - } - - err = os.MkdirAll(path.Dir(rootCACertPath), 0700) - if err != nil && !os.IsExist(err) { - return err - } - - // write key - keyFile, err := os.OpenFile(rootCAKeyPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) - if err != nil { - return err - } - defer keyFile.Close() - - err = pem.Encode(keyFile, &pem.Block{ - Type: "RSA PRIVATE KEY", - Bytes: x509.MarshalPKCS1PrivateKey(c.Key), - }) - if err != nil { - return err - } - - // write cert - certFile, err := os.OpenFile(rootCACertPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) - if err != nil { - return err - } - defer certFile.Close() - - err = pem.Encode(certFile, &pem.Block{ - Type: "CERTIFICATE", - Bytes: c.Cert.Raw, - }) - if err != nil { - return err - } - - return nil -} diff --git a/ca/rootca.go b/ca/rootca.go new file mode 100644 index 0000000..9091e6a --- /dev/null +++ b/ca/rootca.go @@ -0,0 +1,133 @@ +/* +Copyright © 2022 xiexianbin.cn +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package ca + +import ( + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "fmt" + "time" +) + +var ( + supportPemType = []string{"ECDSA PRIVATE KEY", "RSA PRIVATE KEY"} +) + +// RootCA represents a root certificate authority +type RootCA struct { + BaseCA +} + +// NewRootCA creates a new root CA +func NewRootCA(keyType string, keyBits int, curve string) (*RootCA, error) { + rootCA := &RootCA{ + BaseCA: BaseCA{ + KeyBits: keyBits, + Curve: curve, + }, + } + + if err := rootCA.GenerateKey(keyType); err != nil { + return nil, fmt.Errorf("failed to generate key: %w", err) + } + + if err := rootCA.CreateCert(); err != nil { + return nil, fmt.Errorf("failed to create certificate: %w", err) + } + + return rootCA, nil +} + +// LoadRootCA loads an existing root CA from files +func LoadRootCA(keyPath, certPath, password string) (*RootCA, error) { + rootCA := &RootCA{ + BaseCA: BaseCA{}, + } + + if err := rootCA.LoadKey(keyPath); err != nil { + return nil, fmt.Errorf("failed to load key: %w", err) + } + + if err := rootCA.LoadCert(certPath); err != nil { + return nil, fmt.Errorf("failed to load certificate: %w", err) + } + + // Validate key and certificate match + if err := ValidateKeyCertMatch(rootCA.Key, rootCA.Cert); err != nil { + return nil, fmt.Errorf("key and certificate don't match: %w", err) + } + + return rootCA, nil +} + +// CreateCert creates the root CA certificate +func (c *RootCA) CreateCert() error { + pubKey, err := c.GetPublicKey() + if err != nil { + return fmt.Errorf("failed to get public key: %w", err) + } + + rootKeyID, err := calculateKeyID(pubKey) + if err != nil { + return fmt.Errorf("failed to calculate key ID: %w", err) + } + + rootCSR := &x509.Certificate{ + Version: 3, + SerialNumber: randSerial(1), // default root serial number is 1 + Subject: pkix.Name{ + Country: []string{RootCertCountry}, + Organization: []string{RootCertOrganization}, + OrganizationalUnit: []string{RootCertOrganizationalUnit}, + CommonName: RootCertCN, + }, + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(RootCertYears, 0, 0), + SubjectKeyId: rootKeyID, + AuthorityKeyId: rootKeyID, + BasicConstraintsValid: true, + IsCA: true, + MaxPathLen: 1, + MaxPathLenZero: false, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, + } + + der, err := x509.CreateCertificate(rand.Reader, rootCSR, rootCSR, pubKey, c.Key) + if err != nil { + return fmt.Errorf("failed to create certificate: %w", err) + } + + cert, err := x509.ParseCertificate(der) + if err != nil { + return fmt.Errorf("failed to parse certificate: %w", err) + } + + c.Cert = cert + return nil +} + +// Write writes the root CA key and certificate to files +func (c *RootCA) Write(rootKeyPath, rootCertPath, chainPath string) error { + if err := c.WriteKey(rootKeyPath); err != nil { + return fmt.Errorf("failed to write key: %w", err) + } + + if err := c.WriteCert(rootCertPath); err != nil { + return fmt.Errorf("failed to write certificate: %w", err) + } + + return nil +} diff --git a/ca/tls.go b/ca/tls.go index ca60a82..d323cba 100644 --- a/ca/tls.go +++ b/ca/tls.go @@ -15,6 +15,8 @@ package ca import ( "bytes" + "crypto/ecdsa" + "crypto/elliptic" "crypto/rand" "crypto/rsa" "crypto/x509" @@ -39,18 +41,20 @@ const ( ) type TLSCA struct { - Key *rsa.PrivateKey + Key any // *rsa.PrivateKey or *ecdsa.PrivateKey Cert *x509.Certificate - KeyBits int // 1024 * 2^x + KeyBits int // 1024 * 2^x + Curve string // P224, P256, P384, P521 RootCert *x509.Certificate - RootKey *rsa.PrivateKey + RootKey any // *rsa.PrivateKey or *ecdsa.PrivateKey } // NewTLSCA create new tls CA -func NewTLSCA(keyBits int, rootCert *x509.Certificate, rootKey *rsa.PrivateKey) (*TLSCA, error) { +func NewTLSCA(keyType string, keyBits int, curve string, rootCert *x509.Certificate, rootKey any) (*TLSCA, error) { tlsCA := &TLSCA{ KeyBits: keyBits, + Curve: curve, } if rootCert != nil { tlsCA.RootCert = rootCert @@ -59,7 +63,7 @@ func NewTLSCA(keyBits int, rootCert *x509.Certificate, rootKey *rsa.PrivateKey) tlsCA.RootKey = rootKey } - if err := tlsCA.CreateKey(); err != nil { + if err := tlsCA.CreateKey(keyType); err != nil { return nil, err } @@ -93,29 +97,28 @@ func LoadTLSCA(keyPath, certPath, password string) (*TLSCA, error) { * https://security.stackexchange.com/questions/93417/what-encryption-is-applied-on-a-key-generated-by-openssl-req * https://rfc-editor.org/rfc/rfc1423.html * openssl asn1parse -in root-ca.key -i | cut -c-90 - * - golang code * - * if x509.IsEncryptedPEMBlock(keyBlock) == true { - * der, err := x509.DecryptPEMBlock(keyBlock, []byte("pwd")) - * key, _ = x509.ParsePKCS1PrivateKey(der) - * } else { - * key, err = x509.ParsePKCS1PrivateKey(keyBlock.Bytes) - * } - * - * Raise error: `Error: fromPEMBytes: x509: no DEK-Info header in block` - * - * - fix run: `openssl rsa -in root-ca.key -des3` + * Note: The deprecated x509.IsEncryptedPEMBlock and x509.DecryptPEMBlock functions + * have been replaced with direct PEM header checking */ - var key *rsa.PrivateKey + var key any var err error - if x509.IsEncryptedPEMBlock(keyBlock) == true { - der, err := x509.DecryptPEMBlock(keyBlock, []byte(password)) - if err != nil { - return nil, err - } - key, _ = x509.ParsePKCS1PrivateKey(der) - } else { + + // Check if the PEM block is encrypted by checking for headers + isEncrypted := len(keyBlock.Headers) > 0 && keyBlock.Headers["Proc-Type"] == "4,ENCRYPTED" + if isEncrypted { + // Since x509.DecryptPEMBlock is deprecated, we recommend users decrypt their keys first + return nil, fmt.Errorf("encrypted PEM blocks are not supported - please decrypt your key first using: openssl rsa -in encrypted.key -out decrypted.key") + } + + // Parse the unencrypted key + switch keyBlock.Type { + case "RSA PRIVATE KEY": key, err = x509.ParsePKCS1PrivateKey(keyBlock.Bytes) + case "EC PRIVATE KEY": + key, err = x509.ParseECPrivateKey(keyBlock.Bytes) + default: + return nil, fmt.Errorf("unsupport PEM type %s", keyBlock.Type) } if err != nil { return nil, fmt.Errorf("load private key %s, error %s", keyPath, err) @@ -134,7 +137,14 @@ func LoadTLSCA(keyPath, certPath, password string) (*TLSCA, error) { } // compare key and cert match? - keyPKBytes, keyPKErr := x509.MarshalPKIXPublicKey(key.Public()) + var pubKey any + switch k := key.(type) { + case *rsa.PrivateKey: + pubKey = k.Public() + case *ecdsa.PrivateKey: + pubKey = k.Public() + } + keyPKBytes, keyPKErr := x509.MarshalPKIXPublicKey(pubKey) if keyPKErr != nil { return nil, keyPKErr } @@ -155,12 +165,36 @@ func LoadTLSCA(keyPath, certPath, password string) (*TLSCA, error) { } // CreateKey create tls key -func (c *TLSCA) CreateKey() error { - tlsKey, err := rsa.GenerateKey(rand.Reader, c.KeyBits) - if err != nil { - return err +func (c *TLSCA) CreateKey(keyType string) error { + switch strings.ToLower(keyType) { + case "ec", "ecdsa": + var curve elliptic.Curve + switch c.Curve { + case "P224": + curve = elliptic.P224() + case "P256": + curve = elliptic.P256() + case "P384": + curve = elliptic.P384() + case "P521": + curve = elliptic.P521() + default: + return fmt.Errorf("unsupport curve %s", c.Curve) + } + tlsKey, err := ecdsa.GenerateKey(curve, rand.Reader) + if err != nil { + return err + } + c.Key = tlsKey + case "rsa": + tlsKey, err := rsa.GenerateKey(rand.Reader, c.KeyBits) + if err != nil { + return err + } + c.Key = tlsKey + default: + return fmt.Errorf("unsupport key type %s", keyType) } - c.Key = tlsKey return nil } @@ -186,7 +220,15 @@ func (c *TLSCA) CreateCert() error { KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, } - der, err := x509.CreateCertificate(rand.Reader, tlsCSR, c.RootCert, c.Key.Public(), c.RootKey) + var pubKey any + switch k := c.Key.(type) { + case *rsa.PrivateKey: + pubKey = k.Public() + case *ecdsa.PrivateKey: + pubKey = k.Public() + } + + der, err := x509.CreateCertificate(rand.Reader, tlsCSR, c.RootCert, pubKey, c.RootKey) if err != nil { return err } @@ -220,9 +262,23 @@ func (c *TLSCA) Write(keyPath, certPath, chainPath string) error { } defer keyFile.Close() + var keyType string + var keyBytes []byte + switch k := c.Key.(type) { + case *rsa.PrivateKey: + keyType = "RSA PRIVATE KEY" + keyBytes = x509.MarshalPKCS1PrivateKey(k) + case *ecdsa.PrivateKey: + keyType = "EC PRIVATE KEY" + keyBytes, err = x509.MarshalECPrivateKey(k) + if err != nil { + return err + } + } + err = pem.Encode(keyFile, &pem.Block{ - Type: "RSA PRIVATE KEY", - Bytes: x509.MarshalPKCS1PrivateKey(c.Key), + Type: keyType, + Bytes: keyBytes, }) if err != nil { return err @@ -271,7 +327,7 @@ func (c *TLSCA) Write(keyPath, certPath, chainPath string) error { return nil } -func (c *TLSCA) Sign(commonName string, domains []string, ips []net.IP, days, keyBits int) (*rsa.PrivateKey, *x509.Certificate, error) { +func (c *TLSCA) Sign(commonName string, domains []string, ips []net.IP, days int, keyType string, keyBits int, curve string) (any, *x509.Certificate, error) { if keyBits%1024 != 0 { keyBits = 1024 * 4 } @@ -280,9 +336,34 @@ func (c *TLSCA) Sign(commonName string, domains []string, ips []net.IP, days, ke } // generate key - key, err := rsa.GenerateKey(rand.Reader, keyBits) - if err != nil { - return nil, nil, err + var key any + var err error + switch strings.ToLower(keyType) { + case "ec", "ecdsa": + var ecCurve elliptic.Curve + switch curve { + case "P224": + ecCurve = elliptic.P224() + case "P256": + ecCurve = elliptic.P256() + case "P384": + ecCurve = elliptic.P384() + case "P521": + ecCurve = elliptic.P521() + default: + return nil, nil, fmt.Errorf("unsupport curve %s", curve) + } + key, err = ecdsa.GenerateKey(ecCurve, rand.Reader) + if err != nil { + return nil, nil, err + } + case "rsa": + key, err = rsa.GenerateKey(rand.Reader, keyBits) + if err != nil { + return nil, nil, err + } + default: + return nil, nil, fmt.Errorf("unsupport key type %s", keyType) } // create csr @@ -308,8 +389,16 @@ func (c *TLSCA) Sign(commonName string, domains []string, ips []net.IP, days, ke csr.IPAddresses = ips } + var pubKey any + switch k := key.(type) { + case *rsa.PrivateKey: + pubKey = k.Public() + case *ecdsa.PrivateKey: + pubKey = k.Public() + } + // create cert - der, err := x509.CreateCertificate(rand.Reader, csr, c.Cert, key.Public(), c.Key) + der, err := x509.CreateCertificate(rand.Reader, csr, c.Cert, pubKey, c.Key) if err != nil { return nil, nil, err } @@ -322,7 +411,7 @@ func (c *TLSCA) Sign(commonName string, domains []string, ips []net.IP, days, ke return key, cert, nil } -func (c *TLSCA) WriteCert(commonName string, key *rsa.PrivateKey, cert *x509.Certificate, tlsChainPath string) error { +func (c *TLSCA) WriteCert(commonName string, key any, cert *x509.Certificate, tlsChainPath string) error { // mkdir var dir = strings.Replace(commonName, "*.", "", -1) err := os.MkdirAll(fmt.Sprintf("x-ca/certs/%s", dir), 0700) @@ -338,9 +427,23 @@ func (c *TLSCA) WriteCert(commonName string, key *rsa.PrivateKey, cert *x509.Cer } defer keyFile.Close() + var keyType string + var keyBytes []byte + switch k := key.(type) { + case *rsa.PrivateKey: + keyType = "RSA PRIVATE KEY" + keyBytes = x509.MarshalPKCS1PrivateKey(k) + case *ecdsa.PrivateKey: + keyType = "EC PRIVATE KEY" + keyBytes, err = x509.MarshalECPrivateKey(k) + if err != nil { + return err + } + } + err = pem.Encode(keyFile, &pem.Block{ - Type: "RSA PRIVATE KEY", - Bytes: x509.MarshalPKCS1PrivateKey(key), + Type: keyType, + Bytes: keyBytes, }) if err != nil { return err diff --git a/ca/util.go b/ca/util.go deleted file mode 100644 index d02ce55..0000000 --- a/ca/util.go +++ /dev/null @@ -1,84 +0,0 @@ -/* -Copyright © 2022 xiexianbin.cn -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - http://www.apache.org/licenses/LICENSE-2.0 -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package ca - -import ( - "crypto" - "crypto/rand" - "crypto/sha1" - "crypto/x509" - "crypto/x509/pkix" - "encoding/asn1" - "fmt" - "math" - "math/big" - "net" - "regexp" -) - -func randSerial(x int64) *big.Int { - if x > 0 { - return big.NewInt(x) - } - - b, err := rand.Int(rand.Reader, big.NewInt(math.MaxInt64)) - if err != nil { - return big.NewInt(1) - } - return b -} - -func calculateKeyID(pubKey crypto.PublicKey) ([]byte, error) { - pkixByte, err := x509.MarshalPKIXPublicKey(pubKey) - if err != nil { - return nil, err - } - - var pkiInfo struct { - Algorithm pkix.AlgorithmIdentifier - SubjectPublicKey asn1.BitString - } - _, err = asn1.Unmarshal(pkixByte, &pkiInfo) - if err != nil { - return nil, err - } - skid := sha1.Sum(pkiInfo.SubjectPublicKey.Bytes) - return skid[:], nil -} - -func ParseDomains(domainStr []string) ([]string, error) { - var domainSlice []string - re := regexp.MustCompile("^[A-Za-z0-9-.*]+$") - for _, s := range domainStr { - if re.MatchString(s) { - domainSlice = append(domainSlice, s) - } else { - return nil, fmt.Errorf("invalid domain %s", s) - } - } - - return domainSlice, nil -} - -func ParseIPs(ipStr []string) ([]net.IP, error) { - var ipSlice []net.IP - for _, s := range ipStr { - p := net.ParseIP(s) - if p == nil { - return nil, fmt.Errorf("invalid IP %s", s) - } - ipSlice = append(ipSlice, p) - } - return ipSlice, nil -} diff --git a/cmd/createca.go b/cmd/createca.go new file mode 100644 index 0000000..ffea0a6 --- /dev/null +++ b/cmd/createca.go @@ -0,0 +1,98 @@ +/* +Copyright © 2022 xiexianbin.cn +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "go.xiexianbin.cn/x-ca/ca" +) + +var ( + createCaRootCert string + createCaRootKey string + createCaTlsCert string + createCaTlsKey string + createCaTlsChain string + createCaKeyType string + createCaKeyBits int + createCaCurve string +) + +// createCaCmd represents the create-ca command +var createCaCmd = &cobra.Command{ + Use: "create-ca", + Short: "Create root and TLS CA certificates", + Long: `Create a new root CA and TLS CA with the specified parameters. + +Examples: + xca create-ca --key-type ec --curve P256 + xca create-ca --root-cert custom-root.crt --root-key custom-root.key + xca create-ca --key-type rsa --key-bits 4096`, + Run: func(cmd *cobra.Command, args []string) { + if err := runCreateCa(); err != nil { + fmt.Printf("Error: %v\n", err) + os.Exit(1) + } + }, +} + +func initCreateCACmd() { + createCaCmd.Flags().StringVar(&createCaRootCert, "root-cert", "x-ca/ca/root-ca.crt", "Root certificate file path") + createCaCmd.Flags().StringVar(&createCaRootKey, "root-key", "x-ca/ca/root-ca/private/root-ca.key", "Root private key file path") + createCaCmd.Flags().StringVar(&createCaTlsCert, "tls-cert", "x-ca/ca/tls-ca.crt", "TLS certificate file path") + createCaCmd.Flags().StringVar(&createCaTlsKey, "tls-key", "x-ca/ca/tls-ca/private/tls-ca.key", "TLS private key file path") + createCaCmd.Flags().StringVar(&createCaTlsChain, "tls-chain", "x-ca/ca/tls-ca-chain.pem", "TLS CA chain file path") + createCaCmd.Flags().StringVar(&createCaKeyType, "key-type", "rsa", "Key type (rsa or ec)") + createCaCmd.Flags().IntVar(&createCaKeyBits, "key-bits", ca.DefaultKeyBits, "RSA key bits") + createCaCmd.Flags().StringVar(&createCaCurve, "curve", ca.DefaultCurve, "EC curve (P224, P256, P384, P521)") +} + +func runCreateCa() error { + // Check if files already exist + files := []string{createCaRootKey, createCaRootCert, createCaTlsKey, createCaTlsCert} + for _, file := range files { + if exists, _ := ca.CheckFileExists(file); exists { + return fmt.Errorf("%s already exists", file) + } + } + + // Create root CA + rootCA, err := ca.NewRootCA(createCaKeyType, createCaKeyBits, createCaCurve) + if err != nil { + return fmt.Errorf("failed to create root CA: %w", err) + } + + // Write root CA + if err := rootCA.Write(createCaRootKey, createCaRootCert, ""); err != nil { + return fmt.Errorf("failed to write root CA: %w", err) + } + + // Create TLS CA + tlsCA, err := ca.NewTLSCA(createCaKeyType, createCaKeyBits, createCaCurve, rootCA.Cert, rootCA.Key) + if err != nil { + return fmt.Errorf("failed to create TLS CA: %w", err) + } + + // Write TLS CA + if err := tlsCA.Write(createCaTlsKey, createCaTlsCert, createCaTlsChain); err != nil { + return fmt.Errorf("failed to write TLS CA: %w", err) + } + + fmt.Println("Successfully created root and TLS CA certificates") + return nil +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..0301fcb --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,71 @@ +/* +Copyright © 2022 xiexianbin.cn +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + xca "go.xiexianbin.cn/x-ca" +) + +var ( + showVersion bool +) + +// rootCmd represents the base command +var rootCmd = &cobra.Command{ + Use: "xca", + Short: "X Certificate Authority management tool", + Long: `XCA is a command-line tool for creating and managing Certificate Authorities (CAs) +and signing certificates for domains and IP addresses. + +Available Commands: + create-ca Create root and TLS CA certificates + sign Sign a certificate for domains and/or IPs + version Show version information + +Examples: + xca create-ca --key-type ec --curve P256 + xca sign example.com --domains "example.com,www.example.com" + xca sign 192.168.1.1 --ips "192.168.1.1"`, + PersistentPreRun: func(cmd *cobra.Command, args []string) { + if showVersion { + v := xca.GetVersion() + xca.PrintVersion("xca", v, false) + os.Exit(0) + } + }, +} + +func init() { + rootCmd.PersistentFlags().BoolVar(&showVersion, "version", false, "show version information") + + // Add subcommands + rootCmd.AddCommand(createCaCmd) + rootCmd.AddCommand(signCmd) +} + +func initCommands() { + initCreateCACmd() + initSignCmd() +} + +func Execute() { + if err := rootCmd.Execute(); err != nil { + fmt.Println(err) + os.Exit(1) + } +} diff --git a/cmd/sign.go b/cmd/sign.go new file mode 100644 index 0000000..00947ba --- /dev/null +++ b/cmd/sign.go @@ -0,0 +1,109 @@ +/* +Copyright © 2022 xiexianbin.cn +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "go.xiexianbin.cn/x-ca/ca" +) + +var ( + signCommonName string + signDomains string + signIPs string + signTlsKey string + signTlsCert string + signTlsChain string + signKeyType string + signKeyBits int + signCurve string + signDays int + signKeyPassword string +) + +// signCmd represents the sign command +var signCmd = &cobra.Command{ + Use: "sign [common-name]", + Short: "Sign a certificate for domains and/or IPs", + Long: `Sign a new certificate using the TLS CA for the specified common name and domains/IPs. + +Examples: + xca sign example.com --domains "example.com" + xca sign api.example.com --domains "api.example.com,*.example.com" + xca sign 192.168.1.1 --ips "192.168.1.1" + xca sign multi.example.com --domains "example.com,www.example.com" --ips "10.0.0.1"`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + signCommonName = args[0] + if err := runSign(); err != nil { + fmt.Printf("Error: %v\n", err) + os.Exit(1) + } + }, +} + +func initSignCmd() { + signCmd.Flags().StringVar(&signDomains, "domains", "", "Comma-separated domain names") + signCmd.Flags().StringVar(&signIPs, "ips", "", "Comma-separated IP addresses") + signCmd.Flags().StringVar(&signTlsKey, "tls-key", "x-ca/ca/tls-ca/private/tls-ca.key", "TLS CA private key file path") + signCmd.Flags().StringVar(&signTlsCert, "tls-cert", "x-ca/ca/tls-ca.crt", "TLS CA certificate file path") + signCmd.Flags().StringVar(&signTlsChain, "tls-chain", "x-ca/ca/tls-ca-chain.pem", "TLS CA chain file path") + signCmd.Flags().StringVar(&signKeyType, "key-type", "ec", "Key type (rsa or ec)") + signCmd.Flags().IntVar(&signKeyBits, "key-bits", ca.DefaultKeyBits, "RSA key bits") + signCmd.Flags().StringVar(&signCurve, "curve", ca.DefaultCurve, "EC curve (P224, P256, P384, P521)") + signCmd.Flags().IntVar(&signDays, "days", 825, "Certificate validity in days") + signCmd.Flags().StringVar(&signKeyPassword, "tls-key-password", "", "TLS key password (if encrypted)") +} + +func runSign() error { + // Parse domains and IPs + domainList, err := ca.ParseDomains(strings.Split(signDomains, ",")) + if err != nil { + return fmt.Errorf("invalid domains: %w", err) + } + + ipList, err := ca.ParseIPs(strings.Split(signIPs, ",")) + if err != nil { + return fmt.Errorf("invalid IPs: %w", err) + } + + if len(domainList) == 0 && len(ipList) == 0 { + return fmt.Errorf("at least one domain or IP must be specified") + } + + // Load TLS CA + tlsCA, err := ca.LoadTLSCA(signTlsKey, signTlsCert, signKeyPassword) + if err != nil { + return fmt.Errorf("failed to load TLS CA: %w", err) + } + + // Sign certificate + key, cert, err := tlsCA.Sign(signCommonName, domainList, ipList, signDays, signKeyType, signKeyBits, signCurve) + if err != nil { + return fmt.Errorf("failed to sign certificate: %w", err) + } + + // Write certificate + if err := tlsCA.WriteCert(signCommonName, key, cert, signTlsChain); err != nil { + return fmt.Errorf("failed to write certificate: %w", err) + } + + fmt.Printf("Successfully signed certificate for %s\n", signCommonName) + return nil +} diff --git a/cmd/xca.go b/cmd/xca.go new file mode 100644 index 0000000..e154972 --- /dev/null +++ b/cmd/xca.go @@ -0,0 +1,19 @@ +/* +Copyright © 2025 xiexianbin.cn +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +func main() { + initCommands() + Execute() +} diff --git a/go.mod b/go.mod index b21b753..278a302 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,10 @@ -module github.com/x-ca/go-ca +module go.xiexianbin.cn/x-ca go 1.21 + +require github.com/spf13/cobra v1.8.0 + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d0e8c2c --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go deleted file mode 100644 index bcc496d..0000000 --- a/main.go +++ /dev/null @@ -1,198 +0,0 @@ -/* -Copyright © 2022 xiexianbin.cn -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - http://www.apache.org/licenses/LICENSE-2.0 -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package main - -import ( - "flag" - "fmt" - "net" - "os" - "strings" - - "github.com/x-ca/go-ca/ca" -) - -var ( - createCa bool - rootKeyPath string - rootCertPath string - tlsKeyPath string - tlsCertPath string - tlsChainPath string - tlsKeyPassword string - domainStr string - commonName string - domains []string - ipStr string - ips []net.IP - help bool - showVersion bool -) - -func init() { - flag.BoolVar(&createCa, "create-ca", false, "Create Root CA.") - flag.StringVar(&rootKeyPath, "root-key", "x-ca/ca/root-ca/private/root-ca.key", "Root private key file path, PEM format.") - flag.StringVar(&rootCertPath, "root-cert", "x-ca/ca/root-ca.crt", "Root certificate file path, PEM format.") - flag.StringVar(&tlsKeyPath, "tls-key", "x-ca/ca/tls-ca/private/tls-ca.key", "Second-Level private key file path, PEM format.") - flag.StringVar(&tlsCertPath, "tls-cert", "x-ca/ca/tls-ca.crt", "Second-Level certificate file path, PEM format.") - flag.StringVar(&tlsChainPath, "tls-chain", "x-ca/ca/tls-ca-chain.pem", "Root/Second-Level CA Chain file path, PEM format.") - flag.StringVar(&tlsKeyPassword, "tls-key-password", "", "tls key password, only work for load github.com/x-ca/x-ca.") - flag.StringVar(&domainStr, "domains", "", "Comma-Separated domain names.") - flag.StringVar(&commonName, "cn", "", "sign cert common name.") - flag.StringVar(&ipStr, "ips", "", "Comma-Separated IP addresses.") - flag.BoolVar(&help, "help", false, "show help message.") - flag.BoolVar(&showVersion, "version", false, "show version info.") - - flag.Parse() - - flag.Usage = func() { - fmt.Print(`Create Root CA and TLS CA: -xca -create-ca true \ - -root-cert x-ca/ca/root-ca.crt \ - -root-key x-ca/ca/root-ca/private/root-ca.key \ - -tls-cert x-ca/ca/tls-ca.crt \ - -tls-key x-ca/ca/tls-ca/private/tls-ca.key \ - -tls-chain x-ca/ca/tls-ca-chain.pem - -Sign Domains or Ips: -xca -cn xxxx \ - --domains "xxx,xxx" --ips "xxx,xxx" \ - -tls-cert x-ca/ca/tls-ca.crt \ - -tls-key x-ca/ca/tls-ca/private/tls-ca.key \ - -tls-chain x-ca/ca/tls-ca-chain.pem -`) - fmt.Println() - fmt.Println("Usage:") - flag.PrintDefaults() - fmt.Println() - fmt.Println(`Source Code: - https://github.com/x-ca/go-ca`) - } -} - -func check() error { - // check domains and ips - if !createCa { - if domainStr == "" && ipStr == "" { - return fmt.Errorf("domains and ips is empty") - } - if _, err := os.ReadFile(tlsKeyPath); err != nil { - return err - } - if _, err := os.ReadFile(tlsCertPath); err != nil { - return err - } - } - - var err error - // check domain - domains, err = ca.ParseDomains(strings.Split(domainStr, ",")) - if err != nil { - return err - } - - // check ips - ips, err = ca.ParseIPs(strings.Split(ipStr, ",")) - if err != nil { - return err - } - - return nil -} - -func doCreateCa() error { - var err error - - // if file is exist skip - for _, path := range []string{rootKeyPath, rootCertPath, tlsKeyPath, tlsCertPath} { - _, err := os.ReadFile(path) - if err != nil && os.IsNotExist(err) { - continue - } else { - return fmt.Errorf("%s is already exist", path) - } - } - - // create - rootCA, err := ca.NewRootCA(1024 * 4) - if err != nil { - return err - } - err = rootCA.Write(rootKeyPath, rootCertPath, "") - if err != nil { - return err - } - - tlsca, err := ca.NewTLSCA(1024*4, rootCA.Cert, rootCA.Key) - if err != nil { - return err - } - err = tlsca.Write(tlsKeyPath, tlsCertPath, tlsChainPath) - if err != nil { - return err - } - - return nil -} - -func doSign() error { - var err error - - tlsCA, err := ca.LoadTLSCA(tlsKeyPath, tlsCertPath, tlsKeyPassword) - if err != nil { - return err - } - - key, cert, err := tlsCA.Sign(commonName, domains, ips, 825, 1024*4) - if err != nil { - return err - } - err = tlsCA.WriteCert(commonName, key, cert, tlsChainPath) - if err != nil { - return err - } - - return nil -} - -func main() { - if help || len(os.Args) == 1 { - flag.Usage() - return - } - - if showVersion { - v := GetVersion() - PrintVersion("go-ca", v, false) - return - } - - // create CA - if createCa { - if err := doCreateCa(); err != nil { - fmt.Println(err.Error()) - os.Exit(1) - } - } else { - if err := check(); err != nil { - fmt.Println(err.Error()) - os.Exit(1) - } - - if err := doSign(); err != nil { - fmt.Println(err.Error()) - os.Exit(1) - } - } -} diff --git a/version.go b/version.go index 5a223d7..ac2a8b1 100644 --- a/version.go +++ b/version.go @@ -1,4 +1,4 @@ -package main +package xca import ( "fmt" From c4330cdb5f543d7d302b62a94bf7ae0c1097fb77 Mon Sep 17 00:00:00 2001 From: xiexianbin Date: Sun, 20 Jul 2025 18:02:35 +0800 Subject: [PATCH 2/5] feat: info subcommand --- cmd/info.go | 355 ++++++++++++++++++++++++++++++++++++++++++++++++++++ cmd/root.go | 1 + 2 files changed, 356 insertions(+) create mode 100644 cmd/info.go diff --git a/cmd/info.go b/cmd/info.go new file mode 100644 index 0000000..e16fd13 --- /dev/null +++ b/cmd/info.go @@ -0,0 +1,355 @@ +/* +Copyright © 2025 xiexianbin.cn +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "crypto/ecdsa" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/pem" + "fmt" + "math/big" + "os" + "strings" + + "github.com/spf13/cobra" +) + +var ( + certfilePath string +) + +// OIDs for various X.509 extensions. +var ( + oidExtensionSubjectKeyId = asn1.ObjectIdentifier{2, 5, 29, 14} + oidExtensionKeyUsage = asn1.ObjectIdentifier{2, 5, 29, 15} + oidExtensionExtendedKeyUsage = asn1.ObjectIdentifier{2, 5, 29, 37} + oidExtensionAuthorityKeyId = asn1.ObjectIdentifier{2, 5, 29, 35} + oidExtensionSubjectAltName = asn1.ObjectIdentifier{2, 5, 29, 17} + oidExtensionBasicConstraints = asn1.ObjectIdentifier{2, 5, 29, 19} + oidExtensionCRLDistributionPoints = asn1.ObjectIdentifier{2, 5, 29, 31} + oidExtensionCertificatePolicies = asn1.ObjectIdentifier{2, 5, 29, 32} + oidAuthorityInfoAccess = asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 1, 1} +) + +// Maps OIDs to their string representations for better readability. +var oidToStringMap = map[string]string{ + "2.5.29.14": "X509v3 Subject Key Identifier", + "2.5.29.15": "X509v3 Key Usage", + "2.5.29.19": "X509v3 Basic Constraints", + "2.5.29.31": "X509v3 CRL Distribution Points", + "2.5.29.32": "X509v3 Certificate Policies", + "2.5.29.35": "X509v3 Authority Key Identifier", + "2.5.29.37": "X509v3 Extended Key Usage", + "2.5.29.17": "X509v3 Subject Alternative Name", + "1.3.6.1.5.5.7.1.1": "Authority Information Access", +} + +func printCertificateInfo(cert *x509.Certificate) { + fmt.Println("Certificate:") + fmt.Println(" Data:") + // Version + fmt.Printf(" Version: %d (0x%x)\n", cert.Version, cert.Version-1) + + // Serial Number + fmt.Printf(" Serial Number:\n %s\n", formatSerialNumber(cert.SerialNumber)) + + // Signature Algorithm + fmt.Printf(" Signature Algorithm: %s\n", cert.SignatureAlgorithm.String()) + + // Issuer + fmt.Printf(" Issuer: %s\n", formatName(cert.Issuer)) + + // Validity + fmt.Println(" Validity") + fmt.Printf(" Not Before: %s\n", cert.NotBefore.UTC().Format("Jan 2 15:04:05 2006 GMT")) + fmt.Printf(" Not After : %s\n", cert.NotAfter.UTC().Format("Jan 2 15:04:05 2006 GMT")) + + // Subject + fmt.Printf(" Subject: %s\n", formatName(cert.Subject)) + + // Public Key + fmt.Println(" Subject Public Key Info:") + printPublicKeyInfo(cert.PublicKey) + + // Extensions + if len(cert.Extensions) > 0 { + fmt.Println(" X509v3 extensions:") + printExtensions(cert) + } + + // Signature + fmt.Printf(" Signature Algorithm: %s\n", cert.SignatureAlgorithm.String()) + printHexBlock(" ", cert.Signature, 18) +} + +func formatName(name pkix.Name) string { + var parts []string + if len(name.Country) > 0 { + parts = append(parts, "C="+strings.Join(name.Country, ",")) + } + if len(name.Province) > 0 { + parts = append(parts, "ST="+strings.Join(name.Province, ",")) + } + if len(name.Locality) > 0 { + parts = append(parts, "L="+strings.Join(name.Locality, ",")) + } + if len(name.Organization) > 0 { + parts = append(parts, "O="+strings.Join(name.Organization, ",")) + } + if len(name.OrganizationalUnit) > 0 { + parts = append(parts, "OU="+strings.Join(name.OrganizationalUnit, ",")) + } + if name.CommonName != "" { + parts = append(parts, "CN="+name.CommonName) + } + return strings.Join(parts, ", ") +} + +func formatSerialNumber(serial *big.Int) string { + hex := fmt.Sprintf("%x", serial) + if len(hex)%2 != 0 { + hex = "0" + hex + } + var parts []string + for i := 0; i < len(hex); i += 2 { + parts = append(parts, hex[i:i+2]) + } + return strings.Join(parts, ":") +} + +func printPublicKeyInfo(pub interface{}) { + switch pub := pub.(type) { + case *rsa.PublicKey: + fmt.Printf(" Public Key Algorithm: %s\n", x509.RSA.String()) + fmt.Printf(" RSA Public-Key: (%d bit)\n", pub.N.BitLen()) + fmt.Println(" Modulus:") + printHexBlock(" ", pub.N.Bytes(), 15) + fmt.Printf(" Exponent: %d (0x%x)\n", pub.E, pub.E) + + case *ecdsa.PublicKey: + fmt.Printf(" Public Key Algorithm: %s\n", x509.ECDSA.String()) + fmt.Printf(" Public-Key: (%d bit)\n", pub.Curve.Params().BitSize) + printHexBlock(" ", pub.X.Bytes(), 15) // Just showing the X coordinate for brevity, similar to some tools + fmt.Printf(" Curve: %s\n", pub.Curve.Params().Name) + default: + fmt.Println(" Public Key Algorithm: Unknown") + } +} + +func printExtensions(cert *x509.Certificate) { + for _, ext := range cert.Extensions { + oidStr := ext.Id.String() + extName, ok := oidToStringMap[oidStr] + if !ok { + extName = oidStr // Fallback to OID if not in map + } + + criticalStr := "" + if ext.Critical { + criticalStr = "critical" + } + fmt.Printf(" %s: %s\n", extName, criticalStr) + + // Parse and print specific extension details + printExtensionValue(ext, cert) + } +} + +func printExtensionValue(ext pkix.Extension, cert *x509.Certificate) { + indent := " " + switch { + case ext.Id.Equal(oidExtensionKeyUsage): + printKeyUsage(cert.KeyUsage, indent) + case ext.Id.Equal(oidExtensionExtendedKeyUsage): + printExtendedKeyUsage(cert.ExtKeyUsage, indent) + case ext.Id.Equal(oidExtensionBasicConstraints): + fmt.Printf("%sCA:%t\n", indent, cert.IsCA) + case ext.Id.Equal(oidExtensionSubjectKeyId): + fmt.Printf("%s%s\n", indent, formatHex(cert.SubjectKeyId)) + case ext.Id.Equal(oidExtensionAuthorityKeyId): + fmt.Printf("%skeyid:%s\n", indent, formatHex(cert.AuthorityKeyId)) + case ext.Id.Equal(oidExtensionSubjectAltName): + printSAN(cert, indent) + case ext.Id.Equal(oidAuthorityInfoAccess): + printAIA(cert, indent) + case ext.Id.Equal(oidExtensionCRLDistributionPoints): + for _, point := range cert.CRLDistributionPoints { + fmt.Printf("%sFull Name:\n%s URI:%s\n", indent, indent, point) + } + case ext.Id.Equal(oidExtensionCertificatePolicies): + for _, policy := range cert.PolicyIdentifiers { + fmt.Printf("%sPolicy: %s\n", indent, policy.String()) + } + } +} + +func printKeyUsage(ku x509.KeyUsage, indent string) { + var usages []string + if ku&x509.KeyUsageDigitalSignature != 0 { + usages = append(usages, "Digital Signature") + } + if ku&x509.KeyUsageContentCommitment != 0 { + usages = append(usages, "Content Commitment") + } + if ku&x509.KeyUsageKeyEncipherment != 0 { + usages = append(usages, "Key Encipherment") + } + if ku&x509.KeyUsageDataEncipherment != 0 { + usages = append(usages, "Data Encipherment") + } + if ku&x509.KeyUsageKeyAgreement != 0 { + usages = append(usages, "Key Agreement") + } + if ku&x509.KeyUsageCertSign != 0 { + usages = append(usages, "Certificate Sign") + } + if ku&x509.KeyUsageCRLSign != 0 { + usages = append(usages, "CRL Sign") + } + if ku&x509.KeyUsageEncipherOnly != 0 { + usages = append(usages, "Encipher Only") + } + if ku&x509.KeyUsageDecipherOnly != 0 { + usages = append(usages, "Decipher Only") + } + fmt.Printf("%s%s\n", indent, strings.Join(usages, ", ")) +} + +func printExtendedKeyUsage(ekus []x509.ExtKeyUsage, indent string) { + var usages []string + for _, eku := range ekus { + switch eku { + case x509.ExtKeyUsageAny: + usages = append(usages, "Any") + case x509.ExtKeyUsageServerAuth: + usages = append(usages, "TLS Web Server Authentication") + case x509.ExtKeyUsageClientAuth: + usages = append(usages, "TLS Web Client Authentication") + case x509.ExtKeyUsageCodeSigning: + usages = append(usages, "Code Signing") + case x509.ExtKeyUsageEmailProtection: + usages = append(usages, "E-mail Protection") + case x509.ExtKeyUsageIPSECEndSystem: + usages = append(usages, "IPSEC End System") + case x509.ExtKeyUsageIPSECTunnel: + usages = append(usages, "IPSEC Tunnel") + case x509.ExtKeyUsageIPSECUser: + usages = append(usages, "IPSEC User") + case x509.ExtKeyUsageTimeStamping: + usages = append(usages, "Time Stamping") + case x509.ExtKeyUsageOCSPSigning: + usages = append(usages, "OCSP Signing") + case x509.ExtKeyUsageMicrosoftServerGatedCrypto: + usages = append(usages, "Microsoft Server Gated Crypto") + case x509.ExtKeyUsageNetscapeServerGatedCrypto: + usages = append(usages, "Netscape Server Gated Crypto") + default: + usages = append(usages, "Unknown") + } + } + fmt.Printf("%s%s\n", indent, strings.Join(usages, ", ")) +} + +func printSAN(cert *x509.Certificate, indent string) { + var san []string + for _, name := range cert.DNSNames { + san = append(san, "DNS:"+name) + } + for _, email := range cert.EmailAddresses { + san = append(san, "email:"+email) + } + for _, ip := range cert.IPAddresses { + san = append(san, "IP Address:"+ip.String()) + } + for _, uri := range cert.URIs { + san = append(san, "URI:"+uri.String()) + } + fmt.Printf("%s%s\n", indent, strings.Join(san, ", ")) +} + +func printAIA(cert *x509.Certificate, indent string) { + if len(cert.OCSPServer) > 0 { + fmt.Printf("%sOCSP - URI:%s\n", indent, strings.Join(cert.OCSPServer, ", ")) + } + if len(cert.IssuingCertificateURL) > 0 { + fmt.Printf("%sCA Issuers - URI:%s\n", indent, strings.Join(cert.IssuingCertificateURL, ", ")) + } +} + +func formatHex(data []byte) string { + var parts []string + for i, b := range data { + if i > 0 { + parts = append(parts, ":") + } + parts = append(parts, fmt.Sprintf("%02X", b)) + } + return strings.Join(parts, "") +} + +func printHexBlock(prefix string, data []byte, wrap int) { + var parts []string + for i, b := range data { + if i > 0 && i%wrap == 0 { + parts = append(parts, "\n"+prefix) + } + parts = append(parts, fmt.Sprintf("%02x:", b)) + } + // remove last ":" + if len(parts) > 0 { + str := strings.Join(parts, "") + fmt.Printf("%s%s\n", prefix, str[:len(str)-1]) + } +} + +// infoCmd represents the info command +var infoCmd = &cobra.Command{ + Use: "info", + Short: "Display information about the XCA tool", + Long: `Display information about Certificate, like 'openssl x509 -noout -text -in xxx.crt' including version, and basic information. + +Examples: + xca info /root-ca.crt`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + certfilePath = args[0] + + // Read the certificate file + certPEM, err := os.ReadFile(certfilePath) + if err != nil { + fmt.Fprintf(os.Stderr, "Error reading certificate file: %v\n", err) + os.Exit(1) + } + + // Decode the PEM-encoded certificate + block, _ := pem.Decode(certPEM) + if block == nil || block.Type != "CERTIFICATE" { + fmt.Fprintf(os.Stderr, "Failed to decode PEM block containing certificate\n") + os.Exit(1) + } + + // Parse the certificate + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + fmt.Fprintf(os.Stderr, "Error parsing certificate: %v\n", err) + os.Exit(1) + } + + // Print certificate details + printCertificateInfo(cert) + }, +} diff --git a/cmd/root.go b/cmd/root.go index 0301fcb..04131c4 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -55,6 +55,7 @@ func init() { // Add subcommands rootCmd.AddCommand(createCaCmd) + rootCmd.AddCommand(infoCmd) rootCmd.AddCommand(signCmd) } From e0e1e1ed24716208fc098cdd49e2e2dd681028e0 Mon Sep 17 00:00:00 2001 From: xiexianbin Date: Sun, 20 Jul 2025 19:12:48 +0800 Subject: [PATCH 3/5] feat: version subcommand --- README.md | 5 +++++ cmd/root.go | 17 ++++------------- cmd/version.go | 30 ++++++++++++++++++++++++++++++ version.go | 2 +- 4 files changed, 40 insertions(+), 14 deletions(-) create mode 100644 cmd/version.go diff --git a/README.md b/README.md index a0df7ba..d29a76e 100644 --- a/README.md +++ b/README.md @@ -176,3 +176,8 @@ xca create-ca --help xca sign --help ``` + + +1. readme +2. 根据不同的系统,设置不同的目录 +3. version bug diff --git a/cmd/root.go b/cmd/root.go index 04131c4..a89b160 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -18,11 +18,6 @@ import ( "os" "github.com/spf13/cobra" - xca "go.xiexianbin.cn/x-ca" -) - -var ( - showVersion bool ) // rootCmd represents the base command @@ -41,22 +36,18 @@ Examples: xca create-ca --key-type ec --curve P256 xca sign example.com --domains "example.com,www.example.com" xca sign 192.168.1.1 --ips "192.168.1.1"`, - PersistentPreRun: func(cmd *cobra.Command, args []string) { - if showVersion { - v := xca.GetVersion() - xca.PrintVersion("xca", v, false) - os.Exit(0) - } + Run: func(cmd *cobra.Command, args []string) { + // If no subcommand, show help + cmd.Help() }, } func init() { - rootCmd.PersistentFlags().BoolVar(&showVersion, "version", false, "show version information") - // Add subcommands rootCmd.AddCommand(createCaCmd) rootCmd.AddCommand(infoCmd) rootCmd.AddCommand(signCmd) + rootCmd.AddCommand(versionCmd) } func initCommands() { diff --git a/cmd/version.go b/cmd/version.go new file mode 100644 index 0000000..e4598e7 --- /dev/null +++ b/cmd/version.go @@ -0,0 +1,30 @@ +/* +Copyright © 2025 xiexianbin.cn +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "github.com/spf13/cobra" + xca "go.xiexianbin.cn/x-ca" +) + +// versionCmd represents the version command +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Show version information", + Long: `Show version information for XCA including build date, git commit, and platform details.`, + Run: func(cmd *cobra.Command, args []string) { + v := xca.GetVersion() + xca.PrintVersion("xca", v, false) + }, +} diff --git a/version.go b/version.go index ac2a8b1..40a180b 100644 --- a/version.go +++ b/version.go @@ -57,7 +57,7 @@ func GetVersion() Version { } func PrintVersion(cliName string, version Version, short bool) { - fmt.Printf("%s: %s\n", cliName, version.Version) + fmt.Printf("%s(https://github.com/x-ca/go-ca): %s\n", cliName, version.Version) if short { return } From e875bf635ce4db80e752338ce3b0371c700a0fb2 Mon Sep 17 00:00:00 2001 From: xiexianbin Date: Sun, 20 Jul 2025 20:41:52 +0800 Subject: [PATCH 4/5] fix: docs update, some bugfix --- ca/{rootca.go => ca.go} | 3 ++- ca/common.go | 22 ++++++++++++++++++++-- cmd/createca.go | 13 +++++++------ cmd/info.go | 2 +- cmd/root.go | 11 +++++++++-- cmd/sign.go | 9 +++++---- 6 files changed, 44 insertions(+), 16 deletions(-) rename ca/{rootca.go => ca.go} (96%) diff --git a/ca/rootca.go b/ca/ca.go similarity index 96% rename from ca/rootca.go rename to ca/ca.go index 9091e6a..65afc4c 100644 --- a/ca/rootca.go +++ b/ca/ca.go @@ -22,7 +22,8 @@ import ( ) var ( - supportPemType = []string{"ECDSA PRIVATE KEY", "RSA PRIVATE KEY"} + // sort.StringsAreSorted(supportPemType) == true + supportPemType = []string{"EC PRIVATE KEY", "ECDSA PRIVATE KEY", "RSA PRIVATE KEY"} ) // RootCA represents a root certificate authority diff --git a/ca/common.go b/ca/common.go index 0fb3ea3..f8358c4 100644 --- a/ca/common.go +++ b/ca/common.go @@ -30,11 +30,13 @@ import ( "net" "os" "path" + "path/filepath" "regexp" ) // Common CA constants const ( + DefaultKeyType = "ec" DefaultKeyBits = 2048 DefaultCurve = "P256" @@ -43,6 +45,8 @@ const ( RootCertOrganizationalUnit = "www.xiexianbin.cn" RootCertCN = "X Root CA - R1" RootCertYears = 60 + + XCARootPath = "XCA_ROOT_PATH" ) // CreateCertificateChain writes a certificate chain to file @@ -163,8 +167,6 @@ func ParseDomains(domainStr []string) ([]string, error) { for _, s := range domainStr { if re.MatchString(s) { domainSlice = append(domainSlice, s) - } else { - return nil, fmt.Errorf("invalid domain %s", s) } } @@ -184,3 +186,19 @@ func ParseIPs(ipStr []string) (ipSlice []net.IP, err error) { } return ipSlice, nil } + +func GetEnvDefault(key, defVal string) string { + val, ex := os.LookupEnv(key) + if !ex { + return defVal + } + return val +} + +func ExecPath() (string, error) { + ex, err := os.Executable() + if err != nil { + return "", err + } + return filepath.Dir(ex), nil +} diff --git a/cmd/createca.go b/cmd/createca.go index ffea0a6..dbde5d2 100644 --- a/cmd/createca.go +++ b/cmd/createca.go @@ -52,12 +52,13 @@ Examples: } func initCreateCACmd() { - createCaCmd.Flags().StringVar(&createCaRootCert, "root-cert", "x-ca/ca/root-ca.crt", "Root certificate file path") - createCaCmd.Flags().StringVar(&createCaRootKey, "root-key", "x-ca/ca/root-ca/private/root-ca.key", "Root private key file path") - createCaCmd.Flags().StringVar(&createCaTlsCert, "tls-cert", "x-ca/ca/tls-ca.crt", "TLS certificate file path") - createCaCmd.Flags().StringVar(&createCaTlsKey, "tls-key", "x-ca/ca/tls-ca/private/tls-ca.key", "TLS private key file path") - createCaCmd.Flags().StringVar(&createCaTlsChain, "tls-chain", "x-ca/ca/tls-ca-chain.pem", "TLS CA chain file path") - createCaCmd.Flags().StringVar(&createCaKeyType, "key-type", "rsa", "Key type (rsa or ec)") + xcarootpath := ca.GetEnvDefault(ca.XCARootPath, "x-ca") + createCaCmd.Flags().StringVar(&createCaRootCert, "root-cert", xcarootpath+"/ca/root-ca.crt", "Root certificate file path") + createCaCmd.Flags().StringVar(&createCaRootKey, "root-key", xcarootpath+"/ca/root-ca/private/root-ca.key", "Root private key file path") + createCaCmd.Flags().StringVar(&createCaTlsCert, "tls-cert", xcarootpath+"/ca/tls-ca.crt", "TLS certificate file path") + createCaCmd.Flags().StringVar(&createCaTlsKey, "tls-key", xcarootpath+"/ca/tls-ca/private/tls-ca.key", "TLS private key file path") + createCaCmd.Flags().StringVar(&createCaTlsChain, "tls-chain", xcarootpath+"/ca/tls-ca-chain.pem", "TLS CA chain file path") + createCaCmd.Flags().StringVar(&createCaKeyType, "key-type", ca.DefaultKeyType, "Key type (rsa or ec)") createCaCmd.Flags().IntVar(&createCaKeyBits, "key-bits", ca.DefaultKeyBits, "RSA key bits") createCaCmd.Flags().StringVar(&createCaCurve, "curve", ca.DefaultCurve, "EC curve (P224, P256, P384, P521)") } diff --git a/cmd/info.go b/cmd/info.go index e16fd13..97c7a05 100644 --- a/cmd/info.go +++ b/cmd/info.go @@ -319,7 +319,7 @@ func printHexBlock(prefix string, data []byte, wrap int) { // infoCmd represents the info command var infoCmd = &cobra.Command{ Use: "info", - Short: "Display information about the XCA tool", + Short: "Display information about Certificates", Long: `Display information about Certificate, like 'openssl x509 -noout -text -in xxx.crt' including version, and basic information. Examples: diff --git a/cmd/root.go b/cmd/root.go index a89b160..3bc941c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -24,18 +24,25 @@ import ( var rootCmd = &cobra.Command{ Use: "xca", Short: "X Certificate Authority management tool", - Long: `XCA is a command-line tool for creating and managing Certificate Authorities (CAs) + Long: `XCA is a command-line tool for creating and managing Root/Second-Level Certificate Authorities (CAs) and signing certificates for domains and IP addresses. Available Commands: create-ca Create root and TLS CA certificates + info Display information about Certificates sign Sign a certificate for domains and/or IPs version Show version information +Environment: + XCA_ROOT_PATH Which path to store Root/Second-Level/TLS cert, default is "$(pwd)/x-ca" + Examples: xca create-ca --key-type ec --curve P256 xca sign example.com --domains "example.com,www.example.com" - xca sign 192.168.1.1 --ips "192.168.1.1"`, + xca sign 192.168.1.1 --ips "192.168.1.1" + +Source Code: + https://github.com/x-ca/go-ca `, Run: func(cmd *cobra.Command, args []string) { // If no subcommand, show help cmd.Help() diff --git a/cmd/sign.go b/cmd/sign.go index 00947ba..84b8a99 100644 --- a/cmd/sign.go +++ b/cmd/sign.go @@ -59,12 +59,13 @@ Examples: } func initSignCmd() { + xcarootpath := ca.GetEnvDefault(ca.XCARootPath, "x-ca") signCmd.Flags().StringVar(&signDomains, "domains", "", "Comma-separated domain names") signCmd.Flags().StringVar(&signIPs, "ips", "", "Comma-separated IP addresses") - signCmd.Flags().StringVar(&signTlsKey, "tls-key", "x-ca/ca/tls-ca/private/tls-ca.key", "TLS CA private key file path") - signCmd.Flags().StringVar(&signTlsCert, "tls-cert", "x-ca/ca/tls-ca.crt", "TLS CA certificate file path") - signCmd.Flags().StringVar(&signTlsChain, "tls-chain", "x-ca/ca/tls-ca-chain.pem", "TLS CA chain file path") - signCmd.Flags().StringVar(&signKeyType, "key-type", "ec", "Key type (rsa or ec)") + signCmd.Flags().StringVar(&signTlsKey, "tls-key", xcarootpath+"/ca/tls-ca/private/tls-ca.key", "TLS CA private key file path") + signCmd.Flags().StringVar(&signTlsCert, "tls-cert", xcarootpath+"/ca/tls-ca.crt", "TLS CA certificate file path") + signCmd.Flags().StringVar(&signTlsChain, "tls-chain", xcarootpath+"/ca/tls-ca-chain.pem", "TLS CA chain file path") + signCmd.Flags().StringVar(&signKeyType, "key-type", ca.DefaultKeyType, "Key type (rsa or ec)") signCmd.Flags().IntVar(&signKeyBits, "key-bits", ca.DefaultKeyBits, "RSA key bits") signCmd.Flags().StringVar(&signCurve, "curve", ca.DefaultCurve, "EC curve (P224, P256, P384, P521)") signCmd.Flags().IntVar(&signDays, "days", 825, "Certificate validity in days") From 8a87f4ddaa4283979774fb2012f72f83007d8c27 Mon Sep 17 00:00:00 2001 From: xiexianbin Date: Sun, 20 Jul 2025 21:00:37 +0800 Subject: [PATCH 5/5] fix: docs and so on Signed-off-by: xiexianbin --- README.md | 176 ++++++++++++++++++--------------------------------- ca/common.go | 2 +- ca/tls.go | 6 +- 3 files changed, 66 insertions(+), 118 deletions(-) diff --git a/README.md b/README.md index d29a76e..c23a932 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ golang x-ca client, which can simple Sign Self Root/Second-Level CA, and sign for Domains and IPs. -shell implement at [x-ca/x-ca](https://github.com/x-ca/x-ca) +- shell implement at [x-ca/x-ca](https://github.com/x-ca/x-ca) +- [import Self Sign CA To System](https://www.xiexianbin.cn/http/ssl/2017-02-15-openssl-self-sign-ca/#导出导入自签名证书) `x-ca/ca/root-ca.crt` and `x-ca/ca/tls-ca.crt` to trust Your CA. ## install @@ -18,48 +19,30 @@ mv xca /usr/local/bin/ ## Help +``` +xca --help +xca create-ca --help +xca sign --help +``` + ``` $ xca --help -Create Root CA and TLS CA: -xca -create-ca \ - -root-cert x-ca/ca/root-ca.crt \ - -root-key x-ca/ca/root-ca/private/root-ca.key \ - -tls-cert x-ca/ca/tls-ca.crt \ - -tls-key x-ca/ca/tls-ca/private/tls-ca.key \ - -tls-chain x-ca/ca/tls-ca-chain.pem - -Sign Domains or Ips: -xca -cn xxxx \ - --domains "xxx,xxx" --ips "xxx,xxx" \ - -tls-cert x-ca/ca/tls-ca.crt \ - -tls-key x-ca/ca/tls-ca/private/tls-ca.key \ - -tls-chain x-ca/ca/tls-ca-chain.pem - -Usage: - -cn string - sign cert common name. - -create-ca - Create Root CA. - -domains string - Comma-Separated domain names. - -help - show help message - -ips string - Comma-Separated IP addresses. - -root-cert string - Root certificate file path, PEM format. (default "x-ca/ca/root-ca.crt") - -root-key string - Root private key file path, PEM format. (default "x-ca/ca/root-ca/private/root-ca.key") - -tls-cert string - Second-Level certificate file path, PEM format. (default "x-ca/ca/tls-ca.crt") - -tls-chain string - Root/Second-Level CA Chain file path, PEM format. (default "x-ca/ca/tls-ca-chain.pem") - -tls-key string - Second-Level private key file path, PEM format. (default "x-ca/ca/tls-ca/private/tls-ca.key") - -tls-key-password string - tls key password, only work for load github.com/x-ca/x-ca. - -version - show version info. +XCA is a command-line tool for creating and managing Root/Second-Level Certificate Authorities (CAs) +and signing certificates for domains and IP addresses. + +Available Commands: + create-ca Create root and TLS CA certificates + info Display information about Certificates + sign Sign a certificate for domains and/or IPs + version Show version information + +Environment: + XCA_ROOT_PATH Which path to store Root/Second-Level/TLS cert, default is "$(pwd)/x-ca" + +Examples: + xca create-ca --key-type ec --curve P256 + xca sign example.com --domains "example.com,www.example.com" + xca sign 192.168.1.1 --ips "192.168.1.1" Source Code: https://github.com/x-ca/go-ca @@ -67,60 +50,40 @@ Source Code: ## Usage Demo -### create EC CA - You can specify the key type (`-key-type`) and curve (`-curve`) to create an EC root CA and TLS CA: ``` -./xca -create-ca \ - -root-cert x-ca/ca/root-ca.crt \ - -root-key x-ca/ca/root-ca/private/root-ca.key \ - -tls-cert x-ca/ca/tls-ca.crt \ - -tls-key x-ca/ca/tls-ca/private/tls-ca.key \ - -tls-chain x-ca/ca/tls-ca-chain.pem \ - -key-type ec \ - -curve P256 -``` - -To sign a certificate with an EC key: - -``` -./xca -cn example.com \ - --domains "example.com" \ - -tls-cert x-ca/ca/tls-ca.crt \ - -tls-key x-ca/ca/tls-ca/private/tls-ca.key \ - -tls-chain x-ca/ca/tls-ca-chain.pem \ - -key-type ec \ - -curve P256 -``` +# Create EC CA +$ xca create-ca --key-type ec --curve P256 -### create RSA CA - -``` -xca -create-ca \ - -root-cert x-ca/ca/root-ca.crt \ - -root-key x-ca/ca/root-ca/private/root-ca.key \ - -tls-cert x-ca/ca/tls-ca.crt \ - -tls-key x-ca/ca/tls-ca/private/tls-ca.key -``` +# default out `x-ca/...` +$ tree x-ca +x-ca +└── ca + ├── root-ca + │ └── private + │ └── root-ca.key + ├── root-ca.crt + ├── tls-ca + │ └── private + │ └── tls-ca.key + ├── tls-ca-chain.pem + └── tls-ca.crt -[install](https://www.xiexianbin.cn/http/ssl/2017-02-15-openssl-self-sign-ca/#导出导入自签名证书) `x-ca/ca/root-ca.crt` and `x-ca/ca/tls-ca.crt` to trust Your CA. +6 directories, 5 files -- or use x-ca +# Show CA info +$ xca info ./x-ca/ca/root-ca.crt +$ xca info ./x-ca/ca/tls-ca.crt -``` -mkdir path -git clone git@github.com:x-ca/ca.git x-ca -``` +# Sign Domains certificate +xca sign example.com --domains "example.com,www.example.com" -- sign domain +# Sign Domains and IPs certificate +$ xca sign xiexianbin.cn --ips "192.168.1.1,*.xiexianbin.cn,*.dev.xiexianbin.cn" -``` -xca -cn xiexianbin.cn \ - --domains "*.xiexianbin.cn,*.80.xyz" \ - --ips 100.80.0.128 \ - -tls-cert x-ca/ca/tls-ca.crt \ - -tls-key x-ca/ca/tls-ca/private/[tls-ca.key | tls-ca-des3.key] +# Show TLS cert info +$ xca info ./x-ca/certs/xiexianbin.cn/xiexianbin.cn.crt ``` - test cert @@ -134,16 +97,15 @@ docker run -it -d \ nginx ``` -visit https://dev.xiexianbin.cn:8443/ - -## FaQ +- to verify, visit https://dev.xiexianbin.cn:8443/ in brower or run command: -if CA Cert begin with `BEGIN ENCRYPTED PRIVATE KEY`(raise `Error: fromPEMBytes: x509: no DEK-Info header in block`), -Use `openssl rsa -in root-ca.key -des3` change cipher +``` +curl -i -v -k https://dev.xiexianbin.cn:8443/ --resolve dev.xiexianbin.cn:8443:127.0.0.1 +``` -## Ref +## Dev -- [基于OpenSSL签署根CA、二级CA](https://www.xiexianbin.cn/s/ca/) +- core file ``` go.mod - Added cobra dependency @@ -155,29 +117,11 @@ cmd/root.go - root cobra command cmd/xca.go - main entry point (refactored) ``` -## Usage Examples - -``` - -go build -o bin/xca ./cmd/... - -# Create CA certificates -xca create-ca --key-type ec --curve P256 - -# Sign domain certificate -xca sign example.com --domains "example.com,www.example.com" - -# Sign IP certificate -xca sign 192.168.1.1 --ips "192.168.1.1" - -# Get help -xca --help -xca create-ca --help -xca sign --help +## FaQ -``` +if CA Cert begin with `BEGIN ENCRYPTED PRIVATE KEY`(raise `Error: fromPEMBytes: x509: no DEK-Info header in block`), +Use `openssl rsa -in root-ca.key -des3` change cipher +## Ref -1. readme -2. 根据不同的系统,设置不同的目录 -3. version bug +- [基于OpenSSL签署根CA、二级CA](https://www.xiexianbin.cn/s/ca/) diff --git a/ca/common.go b/ca/common.go index f8358c4..cff7abe 100644 --- a/ca/common.go +++ b/ca/common.go @@ -180,7 +180,7 @@ func ParseIPs(ipStr []string) (ipSlice []net.IP, err error) { } p := net.ParseIP(s) if p == nil { - return nil, fmt.Errorf("invalid IP %s", s) + continue } ipSlice = append(ipSlice, p) } diff --git a/ca/tls.go b/ca/tls.go index d323cba..b0a6e83 100644 --- a/ca/tls.go +++ b/ca/tls.go @@ -88,7 +88,11 @@ func LoadTLSCA(keyPath, certPath, password string) (*TLSCA, error) { keyBlock, _ := pem.Decode(keyBytes) if keyBlock == nil { return nil, fmt.Errorf("decode key is nil") - } else if supportPemType[sort.SearchStrings(supportPemType, keyBlock.Type)] != keyBlock.Type { + } + + // Check if PEM type is supported + index := sort.SearchStrings(supportPemType, keyBlock.Type) + if index >= len(supportPemType) || supportPemType[index] != keyBlock.Type { return nil, fmt.Errorf("unsupport PEM type %s", keyBlock.Type) }