blob: f34062695265ea09b682cf0d466153e288339229 [file]
// Copyright 2023 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Command genbotcert can both generate a CSR and private key for a LUCI bot
// and generate a certificate from a CSR. It accepts two arguments, the
// bot hostname, and the path to the CSR. If it only receives the hostname then
// it writes the PEM-encoded CSR to the current working directory along with
// a private key. If it receives both the hostname and CSR path then it
// validates that the hostname is what is what is expected in the CSR and
// generates a certificate. The certificate is written to the current working
// directory.
package main
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"encoding/pem"
"flag"
"fmt"
"log"
"os"
"time"
privateca "cloud.google.com/go/security/privateca/apiv1"
"cloud.google.com/go/security/privateca/apiv1/privatecapb"
"github.com/google/go-cmp/cmp"
"golang.org/x/build/buildenv"
"google.golang.org/protobuf/types/known/durationpb"
)
func main() {
var (
botHostname = flag.String("bot-hostname", "", "Hostname for the bot (required)")
csrPath = flag.String("csr-path", "", "Path to the certificate signing request (required for certificate)")
)
flag.Usage = func() {
fmt.Fprintln(os.Stderr, "Usage: genbotcert -bot-hostname <bot-hostname> [-csr-path <csr-path>]")
flag.PrintDefaults()
}
flag.Parse()
if *botHostname == "" {
flag.Usage()
os.Exit(2)
}
ctx := context.Background()
var err error
if *csrPath == "" {
err = createCSRAndKey(ctx, *botHostname)
} else {
err = generateCert(ctx, *csrPath, *botHostname)
}
if err != nil {
log.Fatalln(err)
}
}
func createCSRAndKey(_ context.Context, cn string) error {
key, err := rsa.GenerateKey(rand.Reader, 4096)
if err != nil {
return err
}
privPem := pem.EncodeToMemory(
&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(key),
},
)
if err := os.WriteFile(cn+".key", privPem, 0600); err != nil {
return err
}
subj := pkix.Name{
CommonName: cn + ".bots.golang.org",
Organization: []string{"Google"},
}
template := x509.CertificateRequest{
Subject: subj,
DNSNames: []string{subj.CommonName},
SignatureAlgorithm: x509.SHA256WithRSA,
}
csrBytes, err := x509.CreateCertificateRequest(rand.Reader, &template, key)
if err != nil {
return err
}
csrPem := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrBytes})
if err := os.WriteFile(cn+".csr", csrPem, 0600); err != nil {
return err
}
fmt.Printf("Wrote CSR to %s.csr and key to %[1]s.key\n", cn)
return nil
}
func generateCert(ctx context.Context, csrPath, hostname string) error {
csr, err := readAndCheckCSR(csrPath, hostname)
if err != nil {
return err
}
certID := fmt.Sprintf("%s-%d", hostname, time.Now().Unix()) // A unique name for the certificate.
caClient, err := privateca.NewCertificateAuthorityClient(ctx)
if err != nil {
return fmt.Errorf("NewCertificateAuthorityClient creation failed: %v", err)
}
defer caClient.Close()
fullCaPoolName := fmt.Sprintf("projects/%s/locations/%s/caPools/%s", buildenv.LUCIProduction.ProjectName, "us-central1", "default-pool")
// Create the CreateCertificateRequest.
// See https://pkg.go.dev/cloud.google.com/go/security/privateca/apiv1/privatecapb#CreateCertificateRequest.
req := &privatecapb.CreateCertificateRequest{
Parent: fullCaPoolName,
CertificateId: certID,
Certificate: &privatecapb.Certificate{
CertificateConfig: &privatecapb.Certificate_PemCsr{
PemCsr: string(csr),
},
Lifetime: &durationpb.Duration{
Seconds: 315360000, // Seconds in 10 years.
},
},
IssuingCertificateAuthorityId: "luci-bot-ca", // The name of the certificate authority which issues the certificate.
}
resp, err := caClient.CreateCertificate(ctx, req)
if err != nil {
return fmt.Errorf("CreateCertificate failed: %v", err)
}
log.Printf("Certificate %s created", certID)
if err := os.WriteFile(certID+".cert", []byte(resp.PemCertificate), 0600); err != nil {
return fmt.Errorf("unable to write certificate to disk: %s", err)
}
fmt.Printf("Wrote certificate to %s.cert\n", certID)
return nil
}
// readAndCheckCSR reads a .csr file located at csrPath,
// checks that it was generated by genbotcert for the right hostname,
// and returns the .csr file bytes if so.
func readAndCheckCSR(csrPath, hostname string) (csr []byte, _ error) {
csr, err := os.ReadFile(csrPath)
if err != nil {
return nil, fmt.Errorf("unable to read file %q: %s", csrPath, err)
}
// Check that the CSR was generated by genbotcert
// with the right hostname.
pb, _ := pem.Decode(csr)
cr, err := x509.ParseCertificateRequest(pb.Bytes)
if err != nil {
return nil, fmt.Errorf("unable to parse certificate request: %v", err)
}
if err := cr.CheckSignature(); err != nil {
return nil, fmt.Errorf("error checking certificate request signature: %v", err)
}
want := x509.CertificateRequest{
Subject: pkix.Name{
CommonName: hostname + ".bots.golang.org",
Organization: []string{"Google"},
Names: []pkix.AttributeTypeAndValue{
{Type: asn1.ObjectIdentifier{2, 5, 4, 10}, Value: "Google"},
{Type: asn1.ObjectIdentifier{2, 5, 4, 3}, Value: hostname + ".bots.golang.org"},
},
},
DNSNames: []string{hostname + ".bots.golang.org"},
SignatureAlgorithm: x509.SHA256WithRSA,
}
if diff := cmp.Diff(want.Subject, cr.Subject); diff != "" {
return nil, fmt.Errorf("certificate request Subject mismatch (-want +got):\n%s", diff)
}
if diff := cmp.Diff(want.DNSNames, cr.DNSNames); diff != "" {
return nil, fmt.Errorf("certificate request DNSNames mismatch (-want +got):\n%s", diff)
}
if diff := cmp.Diff(want.SignatureAlgorithm, cr.SignatureAlgorithm); diff != "" {
return nil, fmt.Errorf("certificate request SignatureAlgorithm mismatch (-want +got):\n%s", diff)
}
return csr, nil
}