181 lines
5.3 KiB
Go
181 lines
5.3 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
pkcs12 "software.sslmate.com/src/go-pkcs12"
|
|
)
|
|
|
|
// clientCertificate: the admin client certificate from ROLLCALL_CERT/ROLLCALL_KEY
|
|
// or client.json, nil when there is none.
|
|
func clientCertificate() *tls.Certificate {
|
|
certFile, keyFile := os.Getenv("ROLLCALL_CERT"), os.Getenv("ROLLCALL_KEY")
|
|
if certFile == "" {
|
|
if cc, ok := loadClientConf(); ok {
|
|
certFile, keyFile = cc.Cert, cc.Key
|
|
}
|
|
}
|
|
if certFile == "" || keyFile == "" {
|
|
return nil
|
|
}
|
|
pair, err := tls.LoadX509KeyPair(certFile, keyFile)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "warning: admin certificate %s: %v\n", certFile, err)
|
|
return nil
|
|
}
|
|
return &pair
|
|
}
|
|
|
|
// rollcall admin-cert request NAME | list | revoke NAME|SERIAL
|
|
func (c *client) adminCertCmd(args []string) error {
|
|
sub := ""
|
|
if len(args) > 0 {
|
|
sub = args[0]
|
|
}
|
|
switch sub {
|
|
case "", "list":
|
|
var cs []AdminCert
|
|
if err := c.do("GET", "/api/v1/admin-certs", nil, &cs); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("%-20s %-34s %-11s %-11s %s\n", "NAME", "SERIAL", "EXPIRES", "STATE", "ISSUED BY")
|
|
for _, x := range cs {
|
|
st := "valid"
|
|
if x.Revoked != nil {
|
|
st = "revoked"
|
|
} else if time.Now().After(x.Expires) {
|
|
st = "expired"
|
|
}
|
|
fmt.Printf("%-20s %-34s %-11s %-11s %s\n", x.Name, x.Serial, x.Expires.Format("2006-01-02"), st, x.IssuedBy)
|
|
}
|
|
return nil
|
|
case "revoke":
|
|
if len(args) < 2 {
|
|
usage()
|
|
}
|
|
var res struct {
|
|
Revoked int `json:"revoked"`
|
|
}
|
|
if err := c.do("DELETE", "/api/v1/admin-certs/"+args[1], nil, &res); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("revoked %d certificate(s) for %s\n", res.Revoked, args[1])
|
|
return nil
|
|
case "request":
|
|
if len(args) < 2 {
|
|
usage()
|
|
}
|
|
return c.requestAdminCert(strings.ToLower(args[1]))
|
|
default:
|
|
usage()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *client) requestAdminCert(name string) error {
|
|
if !strings.HasPrefix(c.base, "https://") {
|
|
return errors.New("request the certificate over HTTPS (ROLLCALL_URL=https://..., or connect the console to https://)")
|
|
}
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
der, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{Subject: pkix.Name{CommonName: "admin:" + name}}, key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var res struct {
|
|
Serial string `json:"serial"`
|
|
Expires time.Time `json:"expires"`
|
|
Cert string `json:"cert"`
|
|
CA string `json:"ca"`
|
|
}
|
|
if err := c.do("POST", "/api/v1/admin-certs", obj{"name": name, "csr": string(pemEncode("CERTIFICATE REQUEST", der))}, &res); err != nil {
|
|
return err
|
|
}
|
|
dir := filepath.Dir(clientConfPath())
|
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
return err
|
|
}
|
|
certFile := filepath.Join(dir, "admin-"+name+".pem")
|
|
keyFile := filepath.Join(dir, "admin-"+name+"-key.pem")
|
|
p12File := filepath.Join(dir, "admin-"+name+".p12")
|
|
caFile := filepath.Join(dir, "rollcall-ca.pem")
|
|
kder, _ := x509.MarshalECPrivateKey(key)
|
|
if err := os.WriteFile(keyFile, pemEncode("EC PRIVATE KEY", kder), 0o600); err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(certFile, []byte(res.Cert), 0o600); err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(caFile, []byte(res.CA), 0o644); err != nil {
|
|
return err
|
|
}
|
|
// a PKCS#12 bundle for the browser / macOS keychain (3DES: every keychain imports it)
|
|
blk, _ := pem.Decode([]byte(res.Cert))
|
|
leaf, err := x509.ParseCertificate(blk.Bytes)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cab, _ := pem.Decode([]byte(res.CA))
|
|
ca, err := x509.ParseCertificate(cab.Bytes)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
pass := randomToken(9)
|
|
p12, err := pkcs12.LegacyDES.Encode(key, leaf, []*x509.Certificate{ca}, pass)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(p12File, p12, 0o600); err != nil {
|
|
return err
|
|
}
|
|
// the CLI and console use it from now on
|
|
cc, _ := loadClientConf()
|
|
if cc.URL == "" {
|
|
cc.URL = c.base
|
|
}
|
|
if cc.CA == "" || strings.TrimRight(cc.URL, "/") == strings.TrimRight(c.base, "/") {
|
|
cc.CA = res.CA // the region's current CA (it may have been rotated since)
|
|
}
|
|
cc.Cert, cc.Key = certFile, keyFile
|
|
if err := saveClientConf(cc); err != nil {
|
|
return err
|
|
}
|
|
// prove it: a request with the certificate alone
|
|
pair, err := tls.LoadX509KeyPair(certFile, keyFile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
probe := &client{base: c.base, http: newHTTPClient(res.CA), ca: res.CA}
|
|
probe.http.Transport.(*httpTransport).TLSClientConfig.Certificates = []tls.Certificate{pair}
|
|
verdict := "works without the token"
|
|
if err := probe.do("GET", "/api/v1/admin-certs", nil, nil); err != nil {
|
|
verdict = "NOT accepted yet: " + err.Error()
|
|
}
|
|
fmt.Printf(`admin certificate "admin:%s" serial %s, valid until %s: %s
|
|
%s
|
|
%s (key, stays here)
|
|
%s (for the browser, password: %s)
|
|
%s (the installation's CA)
|
|
client.json now uses the certificate; the token can be removed from it.
|
|
macOS browser:
|
|
security import %s -k ~/Library/Keychains/login.keychain-db -P %s
|
|
security add-trusted-cert -r trustRoot -k ~/Library/Keychains/login.keychain-db %s
|
|
then open %s and pick "admin:%s" when the browser asks for a certificate.
|
|
`, name, res.Serial, res.Expires.Format("2006-01-02"), verdict, certFile, keyFile, p12File, pass, caFile, p12File, pass, caFile, c.base, name)
|
|
return nil
|
|
}
|