├── .github └── workflows │ └── main.yml ├── LICENSE.txt ├── README.md ├── go.mod └── main.go /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | - name: Go Release Binary 2 | uses: ngs/go-release.action@v1.0.2 3 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Jacob Hoffman-Andrews 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Minica is a simple CA intended for use in situations where the CA operator 2 | also operates each host where a certificate will be used. It automatically 3 | generates both a key and a certificate when asked to produce a certificate. 4 | It does not offer OCSP or CRL services. Minica is appropriate, for instance, 5 | for generating certificates for RPC systems or microservices. 6 | 7 | On first run, minica will generate a keypair and a root certificate in the 8 | current directory, and will reuse that same keypair and root certificate 9 | unless they are deleted. 10 | 11 | On each run, minica will generate a new keypair and sign an end-entity (leaf) 12 | certificate for that keypair. The certificate will contain a list of DNS names 13 | and/or IP addresses from the command line flags. The key and certificate are 14 | placed in a new directory whose name is chosen as the first domain name from 15 | the certificate, or the first IP address if no domain names are present. It 16 | will not overwrite existing keys or certificates. 17 | 18 | The certificate will have a validity of 2 years and 30 days. 19 | 20 | # Installation 21 | 22 | First, install the [Go tools](https://golang.org/dl/) and set up your `$GOPATH`. 23 | Then, run: 24 | 25 | `go install github.com/jsha/minica@latest` 26 | 27 | When using Go 1.11 or newer you don't need a $GOPATH and can instead do the 28 | following: 29 | 30 | ``` 31 | cd /ANY/PATH 32 | git clone https://github.com/jsha/minica.git 33 | go build 34 | ## or 35 | # go install 36 | ``` 37 | 38 | Mac OS users could alternatively use Homebrew: `brew install minica` 39 | 40 | # Example usage 41 | 42 | ``` 43 | # Generate a root key and cert in minica-key.pem, and minica.pem, then 44 | # generate and sign an end-entity key and cert, storing them in ./foo.com/ 45 | $ minica --domains foo.com 46 | 47 | # Wildcard 48 | $ minica --domains '*.foo.com' 49 | ``` 50 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jsha/minica 2 | 3 | go 1.15 4 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "crypto" 6 | "crypto/ecdsa" 7 | "crypto/elliptic" 8 | "crypto/rand" 9 | "crypto/rsa" 10 | "crypto/sha1" 11 | "crypto/x509" 12 | "crypto/x509/pkix" 13 | "encoding/asn1" 14 | "encoding/hex" 15 | "encoding/pem" 16 | "flag" 17 | "fmt" 18 | "io/ioutil" 19 | "log" 20 | "math" 21 | "math/big" 22 | "net" 23 | "os" 24 | "regexp" 25 | "strings" 26 | "time" 27 | ) 28 | 29 | func main() { 30 | err := main2() 31 | if err != nil { 32 | log.Fatal(err) 33 | } 34 | } 35 | 36 | type issuer struct { 37 | key crypto.Signer 38 | cert *x509.Certificate 39 | } 40 | 41 | func getIssuer(keyFile, certFile string, alg x509.PublicKeyAlgorithm) (*issuer, error) { 42 | keyContents, keyErr := ioutil.ReadFile(keyFile) 43 | certContents, certErr := ioutil.ReadFile(certFile) 44 | if os.IsNotExist(keyErr) && os.IsNotExist(certErr) { 45 | err := makeIssuer(keyFile, certFile, alg) 46 | if err != nil { 47 | return nil, err 48 | } 49 | return getIssuer(keyFile, certFile, alg) 50 | } else if keyErr != nil { 51 | return nil, fmt.Errorf("%s (but %s exists)", keyErr, certFile) 52 | } else if certErr != nil { 53 | return nil, fmt.Errorf("%s (but %s exists)", certErr, keyFile) 54 | } 55 | key, err := readPrivateKey(keyContents) 56 | if err != nil { 57 | return nil, fmt.Errorf("reading private key from %s: %s", keyFile, err) 58 | } 59 | 60 | cert, err := readCert(certContents) 61 | if err != nil { 62 | return nil, fmt.Errorf("reading CA certificate from %s: %s", certFile, err) 63 | } 64 | 65 | equal, err := publicKeysEqual(key.Public(), cert.PublicKey) 66 | if err != nil { 67 | return nil, fmt.Errorf("comparing public keys: %s", err) 68 | } else if !equal { 69 | return nil, fmt.Errorf("public key in CA certificate %s doesn't match private key in %s", 70 | certFile, keyFile) 71 | } 72 | return &issuer{key, cert}, nil 73 | } 74 | 75 | func readPrivateKey(keyContents []byte) (crypto.Signer, error) { 76 | block, _ := pem.Decode(keyContents) 77 | if block == nil { 78 | return nil, fmt.Errorf("no PEM found") 79 | } else if block.Type == "PRIVATE KEY" { 80 | signer, err := x509.ParsePKCS8PrivateKey(block.Bytes) 81 | if err != nil { 82 | return nil, fmt.Errorf("failed to parse PKCS8: %w", err) 83 | } 84 | switch t := signer.(type) { 85 | case *rsa.PrivateKey: 86 | return signer.(*rsa.PrivateKey), nil 87 | case *ecdsa.PrivateKey: 88 | return signer.(*ecdsa.PrivateKey), nil 89 | default: 90 | return nil, fmt.Errorf("unsupported PKCS8 key type: %t", t) 91 | } 92 | } else if block.Type == "RSA PRIVATE KEY" { 93 | return x509.ParsePKCS1PrivateKey(block.Bytes) 94 | } else if block.Type == "EC PRIVATE KEY" || block.Type == "ECDSA PRIVATE KEY" { 95 | return x509.ParseECPrivateKey(block.Bytes) 96 | } 97 | return nil, fmt.Errorf("incorrect PEM type %s", block.Type) 98 | } 99 | 100 | func readCert(certContents []byte) (*x509.Certificate, error) { 101 | block, _ := pem.Decode(certContents) 102 | if block == nil { 103 | return nil, fmt.Errorf("no PEM found") 104 | } else if block.Type != "CERTIFICATE" { 105 | return nil, fmt.Errorf("incorrect PEM type %s", block.Type) 106 | } 107 | return x509.ParseCertificate(block.Bytes) 108 | } 109 | 110 | func makeIssuer(keyFile, certFile string, alg x509.PublicKeyAlgorithm) error { 111 | key, err := makeKey(keyFile, alg) 112 | if err != nil { 113 | return err 114 | } 115 | _, err = makeRootCert(key, certFile) 116 | if err != nil { 117 | return err 118 | } 119 | return nil 120 | } 121 | 122 | func makeKey(filename string, alg x509.PublicKeyAlgorithm) (crypto.Signer, error) { 123 | var key crypto.Signer 124 | var err error 125 | switch { 126 | case alg == x509.RSA: 127 | key, err = rsa.GenerateKey(rand.Reader, 2048) 128 | if err != nil { 129 | return nil, err 130 | } 131 | case alg == x509.ECDSA: 132 | key, err = ecdsa.GenerateKey(elliptic.P384(), rand.Reader) 133 | if err != nil { 134 | return nil, err 135 | } 136 | } 137 | der, err := x509.MarshalPKCS8PrivateKey(key) 138 | if err != nil { 139 | return nil, err 140 | } 141 | file, err := os.OpenFile(filename, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) 142 | if err != nil { 143 | return nil, err 144 | } 145 | defer file.Close() 146 | err = pem.Encode(file, &pem.Block{ 147 | Type: "PRIVATE KEY", 148 | Bytes: der, 149 | }) 150 | if err != nil { 151 | return nil, err 152 | } 153 | return key, nil 154 | } 155 | 156 | func makeRootCert(key crypto.Signer, filename string) (*x509.Certificate, error) { 157 | serial, err := rand.Int(rand.Reader, big.NewInt(math.MaxInt64)) 158 | if err != nil { 159 | return nil, err 160 | } 161 | skid, err := calculateSKID(key.Public()) 162 | if err != nil { 163 | return nil, err 164 | } 165 | template := &x509.Certificate{ 166 | Subject: pkix.Name{ 167 | CommonName: "minica root ca " + hex.EncodeToString(serial.Bytes()[:3]), 168 | }, 169 | SerialNumber: serial, 170 | NotBefore: time.Now(), 171 | NotAfter: time.Now().AddDate(100, 0, 0), 172 | 173 | SubjectKeyId: skid, 174 | AuthorityKeyId: skid, 175 | KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, 176 | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, 177 | BasicConstraintsValid: true, 178 | IsCA: true, 179 | MaxPathLenZero: true, 180 | } 181 | 182 | der, err := x509.CreateCertificate(rand.Reader, template, template, key.Public(), key) 183 | if err != nil { 184 | return nil, err 185 | } 186 | file, err := os.OpenFile(filename, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) 187 | if err != nil { 188 | return nil, err 189 | } 190 | defer file.Close() 191 | err = pem.Encode(file, &pem.Block{ 192 | Type: "CERTIFICATE", 193 | Bytes: der, 194 | }) 195 | if err != nil { 196 | return nil, err 197 | } 198 | return x509.ParseCertificate(der) 199 | } 200 | 201 | func parseIPs(ipAddresses []string) ([]net.IP, error) { 202 | var parsed []net.IP 203 | for _, s := range ipAddresses { 204 | p := net.ParseIP(s) 205 | if p == nil { 206 | return nil, fmt.Errorf("invalid IP address %s", s) 207 | } 208 | parsed = append(parsed, p) 209 | } 210 | return parsed, nil 211 | } 212 | 213 | func publicKeysEqual(a, b interface{}) (bool, error) { 214 | aBytes, err := x509.MarshalPKIXPublicKey(a) 215 | if err != nil { 216 | return false, err 217 | } 218 | bBytes, err := x509.MarshalPKIXPublicKey(b) 219 | if err != nil { 220 | return false, err 221 | } 222 | return bytes.Compare(aBytes, bBytes) == 0, nil 223 | } 224 | 225 | func calculateSKID(pubKey crypto.PublicKey) ([]byte, error) { 226 | spkiASN1, err := x509.MarshalPKIXPublicKey(pubKey) 227 | if err != nil { 228 | return nil, err 229 | } 230 | 231 | var spki struct { 232 | Algorithm pkix.AlgorithmIdentifier 233 | SubjectPublicKey asn1.BitString 234 | } 235 | _, err = asn1.Unmarshal(spkiASN1, &spki) 236 | if err != nil { 237 | return nil, err 238 | } 239 | skid := sha1.Sum(spki.SubjectPublicKey.Bytes) 240 | return skid[:], nil 241 | } 242 | 243 | func sign(iss *issuer, domains []string, ipAddresses []string, alg x509.PublicKeyAlgorithm) (*x509.Certificate, error) { 244 | var cn string 245 | if len(domains) > 0 { 246 | cn = domains[0] 247 | } else if len(ipAddresses) > 0 { 248 | cn = ipAddresses[0] 249 | } else { 250 | return nil, fmt.Errorf("must specify at least one domain name or IP address") 251 | } 252 | var cnFolder = strings.Replace(cn, "*", "_", -1) 253 | err := os.Mkdir(cnFolder, 0700) 254 | if err != nil && !os.IsExist(err) { 255 | return nil, err 256 | } 257 | key, err := makeKey(fmt.Sprintf("%s/key.pem", cnFolder), alg) 258 | if err != nil { 259 | return nil, err 260 | } 261 | parsedIPs, err := parseIPs(ipAddresses) 262 | if err != nil { 263 | return nil, err 264 | } 265 | serial, err := rand.Int(rand.Reader, big.NewInt(math.MaxInt64)) 266 | if err != nil { 267 | return nil, err 268 | } 269 | template := &x509.Certificate{ 270 | DNSNames: domains, 271 | IPAddresses: parsedIPs, 272 | Subject: pkix.Name{ 273 | CommonName: cn, 274 | }, 275 | SerialNumber: serial, 276 | NotBefore: time.Now(), 277 | // Set the validity period to 2 years and 30 days, to satisfy the iOS and 278 | // macOS requirements that all server certificates must have validity 279 | // shorter than 825 days: 280 | // https://derflounder.wordpress.com/2019/06/06/new-tls-security-requirements-for-ios-13-and-macos-catalina-10-15/ 281 | NotAfter: time.Now().AddDate(2, 0, 30), 282 | 283 | KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, 284 | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, 285 | BasicConstraintsValid: true, 286 | IsCA: false, 287 | } 288 | der, err := x509.CreateCertificate(rand.Reader, template, iss.cert, key.Public(), iss.key) 289 | if err != nil { 290 | return nil, err 291 | } 292 | file, err := os.OpenFile(fmt.Sprintf("%s/cert.pem", cnFolder), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) 293 | if err != nil { 294 | return nil, err 295 | } 296 | defer file.Close() 297 | err = pem.Encode(file, &pem.Block{ 298 | Type: "CERTIFICATE", 299 | Bytes: der, 300 | }) 301 | if err != nil { 302 | return nil, err 303 | } 304 | return x509.ParseCertificate(der) 305 | } 306 | 307 | func split(s string) (results []string) { 308 | if len(s) > 0 { 309 | return strings.Split(s, ",") 310 | } 311 | return nil 312 | } 313 | 314 | func main2() error { 315 | var caKey = flag.String("ca-key", "minica-key.pem", "Root private key filename, PEM encoded.") 316 | var caCert = flag.String("ca-cert", "minica.pem", "Root certificate filename, PEM encoded.") 317 | var caAlg = flag.String("ca-alg", "ecdsa", "Algorithm for any new keypairs: RSA or ECDSA.") 318 | var domains = flag.String("domains", "", "Comma separated domain names to include as Server Alternative Names.") 319 | var ipAddresses = flag.String("ip-addresses", "", "Comma separated IP addresses to include as Server Alternative Names.") 320 | flag.Usage = func() { 321 | fmt.Fprintf(os.Stderr, "Usage of %s:\n", os.Args[0]) 322 | fmt.Fprintf(os.Stderr, ` 323 | Minica is a simple CA intended for use in situations where the CA operator 324 | also operates each host where a certificate will be used. It automatically 325 | generates both a key and a certificate when asked to produce a certificate. 326 | It does not offer OCSP or CRL services. Minica is appropriate, for instance, 327 | for generating certificates for RPC systems or microservices. 328 | 329 | On first run, minica will generate a keypair and a root certificate in the 330 | current directory, and will reuse that same keypair and root certificate 331 | unless they are deleted. 332 | 333 | On each run, minica will generate a new keypair and sign an end-entity (leaf) 334 | certificate for that keypair. The certificate will contain a list of DNS names 335 | and/or IP addresses from the command line flags. The key and certificate are 336 | placed in a new directory whose name is chosen as the first domain name from 337 | the certificate, or the first IP address if no domain names are present. It 338 | will not overwrite existing keys or certificates. 339 | 340 | `) 341 | flag.PrintDefaults() 342 | } 343 | flag.Parse() 344 | if *domains == "" && *ipAddresses == "" { 345 | flag.Usage() 346 | os.Exit(1) 347 | } 348 | alg := x509.RSA 349 | if strings.ToLower(*caAlg) == "ecdsa" { 350 | alg = x509.ECDSA 351 | } else if strings.ToLower(*caAlg) != "rsa" { 352 | fmt.Printf("Unrecognized algorithm: %s (use RSA or ECDSA)\n", *caAlg) 353 | os.Exit(1) 354 | } 355 | if len(flag.Args()) > 0 { 356 | fmt.Printf("Extra arguments: %s (maybe there are spaces in your domain list?)\n", flag.Args()) 357 | os.Exit(1) 358 | } 359 | domainSlice := split(*domains) 360 | domainRe := regexp.MustCompile("^[A-Za-z0-9.*-]+$") 361 | for _, d := range domainSlice { 362 | if !domainRe.MatchString(d) { 363 | fmt.Printf("Invalid domain name %q\n", d) 364 | os.Exit(1) 365 | } 366 | } 367 | ipSlice := split(*ipAddresses) 368 | for _, ip := range ipSlice { 369 | if net.ParseIP(ip) == nil { 370 | fmt.Printf("Invalid IP address %q\n", ip) 371 | os.Exit(1) 372 | } 373 | } 374 | issuer, err := getIssuer(*caKey, *caCert, alg) 375 | if err != nil { 376 | return err 377 | } 378 | _, err = sign(issuer, domainSlice, ipSlice, alg) 379 | return err 380 | } 381 | --------------------------------------------------------------------------------