Files
zeekay 3c39dc1f89 Enroll identities under our own name
An identity's enrollment CSR named upstream's organization, and the
controller signs the subject a CSR asks for, so every identity
certificate carried it. It now asks for O=Hanzo AI, OU=ZT.

Claude-Session: https://claude.ai/code/session_01HuMDfgFssPUptYUzgZ6Cp8
2026-09-23 14:46:36 -07:00

620 lines
17 KiB
Go

/*
Copyright 2019 NetFoundry Inc.
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
https://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 enroll
import (
"bytes"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"github.com/go-openapi/runtime"
httptransport "github.com/go-openapi/runtime/client"
"github.com/hanzozt/edge-api/rest_client_api_client"
"github.com/hanzozt/edge-api/rest_client_api_client/well_known"
edge_apis "github.com/hanzozt/sdk-golang/edge-apis"
"github.com/hanzozt/sdk-golang/zt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"github.com/Jeffail/gabs"
"github.com/fullsailor/pkcs7"
"github.com/golang-jwt/jwt/v5"
"github.com/michaelquigley/pfxlog"
nfpem "github.com/hanzozt/foundation/v2/pem"
nfx509 "github.com/hanzozt/foundation/v2/x509"
"github.com/hanzozt/identity"
"github.com/hanzozt/identity/certtools"
"github.com/pkg/errors"
)
type EnrollmentFlags struct {
Token *zt.EnrollmentClaims
JwtToken *jwt.Token
JwtString string
CertFile string
KeyFile string
KeyAlg zt.KeyAlgVar
IDName string
AdditionalCAs string
Username string
Password string
Verbose bool
}
func (enFlags *EnrollmentFlags) GetCertPool() (*x509.CertPool, []*x509.Certificate) {
pool := x509.NewCertPool()
var certs []*x509.Certificate
if strings.TrimSpace(enFlags.AdditionalCAs) != "" {
pfxlog.Logger().Debug("adding certificates from the provided ca override file")
caPEMs, _ := os.ReadFile(enFlags.AdditionalCAs)
for _, xcert := range nfpem.PemStringToCertificates(string(caPEMs)) {
certs = append(certs, xcert)
pool.AddCert(xcert)
}
}
return pool, certs
}
func ParseToken(tokenStr string) (*zt.EnrollmentClaims, *jwt.Token, error) {
parser := jwt.NewParser()
enrollmentClaims := &zt.EnrollmentClaims{}
tokenStr = strings.TrimSpace(tokenStr)
jwtToken, err := parser.ParseWithClaims(tokenStr, enrollmentClaims, ValidateToken)
if err != nil {
return nil, nil, err
}
return enrollmentClaims, jwtToken, nil
}
func ValidateToken(token *jwt.Token) (interface{}, error) {
if token == nil {
return nil, errors.New("could not validate token, token is nil")
}
claims, ok := token.Claims.(*zt.EnrollmentClaims)
if !ok {
return nil, errors.New("could not validate token, token is not EnrollmentClaims")
}
if claims == nil {
return nil, errors.New("could not validate token, EnrollmentClaims are nil")
}
if claims.Issuer == "" {
return nil, errors.New("could not validate token, issuer is empty")
}
_, err := url.Parse(claims.Issuer)
if err != nil {
return nil, errors.Errorf("could not validate token, issuer [%s] is not a valid url ", claims.Issuer)
}
cert, err := FetchServerCert(claims.Issuer)
claims.SignatureCert = cert
if err != nil || cert == nil {
return nil, errors.Errorf("could not retrieve token URL certificate: %s", err)
}
return cert.PublicKey, nil
}
func EnrollUpdb(enFlags EnrollmentFlags) (string, error) {
caPool, allowedCerts := enFlags.GetCertPool()
ztApiRoot := enFlags.Token.Issuer
resultUsername := ""
var err error
if resultUsername, err = enrollUpdb(enFlags.Username, enFlags.Password, enFlags.Token, caPool); err != nil {
pfxlog.Logger().Debug("fetching certificates from server")
rootCaPool := x509.NewCertPool()
rootCaPool.AddCert(enFlags.Token.SignatureCert)
for _, xcert := range FetchCertificates(ztApiRoot, rootCaPool) {
allowedCerts = append(allowedCerts, xcert)
caPool.AddCert(xcert)
}
if resultUsername, err = enrollUpdb(enFlags.Username, enFlags.Password, enFlags.Token, caPool); err != nil {
return "", fmt.Errorf("unable to enroll after fetching server certs: %v", err)
} else {
return resultUsername, nil
}
}
return resultUsername, nil
}
func Enroll(enFlags EnrollmentFlags) (*zt.Config, error) {
var key crypto.PrivateKey
var err error
cfg := &zt.Config{
ZtAPI: edge_apis.ClientUrl(enFlags.Token.Issuer),
}
if enFlags.Token.EnrollmentMethod != "updb" {
if strings.TrimSpace(enFlags.KeyFile) != "" {
stat, err := os.Stat(enFlags.KeyFile)
if stat != nil && !os.IsNotExist(err) {
if stat.IsDir() {
return nil, errors.Errorf("specified key is a directory (%s)", enFlags.KeyFile)
}
if absPath, fileErr := filepath.Abs(enFlags.KeyFile); fileErr != nil {
return nil, fileErr
} else {
cfg.ID.Key = "file://" + absPath
}
} else {
cfg.ID.Key = enFlags.KeyFile
pfxlog.Logger().Infof("using engine : %s\n", strings.Split(enFlags.KeyFile, ":")[0])
}
} else {
var asnBytes []byte
var keyPem []byte
if enFlags.KeyAlg.EC() {
key, err = generateECKey()
asnBytes, _ := x509.MarshalECPrivateKey(key.(*ecdsa.PrivateKey))
keyPem = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: asnBytes})
} else if enFlags.KeyAlg.RSA() {
key, err = generateRSAKey()
asnBytes = x509.MarshalPKCS1PrivateKey(key.(*rsa.PrivateKey))
keyPem = pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: asnBytes})
} else {
panic(fmt.Sprintf("invalid KeyAlg specified: %s", enFlags.KeyAlg.Get()))
}
cfg.ID.Key = "pem:" + string(keyPem)
if err != nil {
return nil, err
}
}
if enFlags.CertFile != "" {
enFlags.CertFile, _ = filepath.Abs(enFlags.CertFile)
cfg.ID.Cert = "file://" + enFlags.CertFile
}
}
caPool, allowedCerts := enFlags.GetCertPool()
//fetch so CA bundles
pfxlog.Logger().Debug("fetching certificates from server")
serverOnlyCaPool := x509.NewCertPool()
serverOnlyCaPool.AddCert(enFlags.Token.SignatureCert)
controllerCas := FetchCertificates(cfg.ZtAPI, serverOnlyCaPool)
if len(controllerCas) == 0 {
return nil, errors.New("expected 1 or more CAs from controller, got 0")
}
for _, cert := range controllerCas {
allowedCerts = append(allowedCerts, cert)
caPool.AddCert(cert)
}
resultUsername := ""
var enrollErr error
switch enFlags.Token.EnrollmentMethod {
case "ott":
enrollErr = enrollOTT(enFlags.Token, cfg, caPool)
case "ottca":
enrollErr = enrollCA(enFlags.Token, cfg, caPool)
case "ca":
enrollErr = enrollCAAuto(enFlags, cfg, caPool)
case "updb":
resultUsername, enrollErr = enrollUpdb(enFlags.Username, enFlags.Password, enFlags.Token, caPool)
//v1.5.0 and earlier controllers do not confirm the username in their response (e.g. if set by admin and an override isn't provided)
if resultUsername == "" {
resultUsername = enFlags.Username
}
default:
enrollErr = errors.Errorf("enrollment method '%s' is not supported", enFlags.Token.EnrollmentMethod)
}
if enrollErr != nil {
return nil, enrollErr
}
if len(allowedCerts) > 0 {
var buf bytes.Buffer
err := nfx509.MarshalToPem(allowedCerts, &buf)
if err != nil {
return nil, err
}
cfg.ID.CA = "pem:" + buf.String()
}
if enFlags.Token.EnrollmentMethod == "updb" {
cfg.Credentials = &edge_apis.UpdbCredentials{
BaseCredentials: edge_apis.BaseCredentials{
CaPool: caPool,
},
Username: resultUsername,
Password: enFlags.Password,
}
} else {
cfg.Credentials = edge_apis.NewIdentityCredentialsFromConfig(cfg.ID)
}
return cfg, nil
}
func generateECKey() (crypto.PrivateKey, error) {
p384 := elliptic.P384()
pfxlog.Logger().Infof("generating %s EC key", p384.Params().Name)
return ecdsa.GenerateKey(p384, rand.Reader)
}
func generateRSAKey() (crypto.PrivateKey, error) {
bitSize := 4096
pfxlog.Logger().Infof("generating %d bit RSA key", bitSize)
return rsa.GenerateKey(rand.Reader, bitSize)
}
func useSystemCasIfEmpty(caPool *x509.CertPool) *x509.CertPool {
if len(caPool.Subjects()) < 1 { //nolint:staticcheck
pfxlog.Logger().Debugf("no cas provided in caPool. using system provided cas")
//this means that there were no ca's in the jwt and none fetched and added... fallback to using
//the system defined ca pool in this case
return nil
} else {
return caPool
}
}
func enrollUpdb(username, password string, token *zt.EnrollmentClaims, caPool *x509.CertPool) (string, error) {
caPool = useSystemCasIfEmpty(caPool)
client := http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
RootCAs: caPool,
},
Proxy: http.ProxyFromEnvironment,
},
}
body := gabs.New()
_, _ = body.Set(password, "password")
if username != "" {
_, _ = body.Set(username, "username")
}
resp, err := client.Post(token.EnrolmentUrl(), "application/json", bytes.NewBuffer(body.EncodeJSON()))
if err != nil {
return "", err
}
if resp.StatusCode == http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
if respContainer, err := gabs.ParseJSON(respBody); err == nil {
username, _ = respContainer.Path("data.username").Data().(string)
}
return username, nil
}
respBody, _ := io.ReadAll(resp.Body)
if respContainer, err := gabs.ParseJSON(respBody); err == nil {
code := respContainer.Path("error.code").Data().(string)
message := respContainer.Path("error.message").Data().(string)
return "", errors.Errorf("enroll error: %s: %s: %s", resp.Status, code, message)
} else {
return "", errors.Errorf("enroll error: %s: %s", resp.Status, body)
}
}
func enrollOTT(token *zt.EnrollmentClaims, cfg *zt.Config, caPool *x509.CertPool) error {
pk, err := identity.LoadKey(cfg.ID.Key)
if err != nil {
return errors.Errorf("failed to load private key '%s': %s", cfg.ID.Key, err.Error())
}
request, err := certtools.NewCertRequest(map[string]string{
"C": "US", "O": "Hanzo AI", "OU": "ZT", "CN": token.Subject,
}, nil)
if err != nil {
return err
}
csr, err := x509.CreateCertificateRequest(rand.Reader, request, pk)
if err != nil {
return err
}
csrPem := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csr})
caPool = useSystemCasIfEmpty(caPool)
client := http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
RootCAs: caPool,
},
Proxy: http.ProxyFromEnvironment,
},
}
resp, err := client.Post(token.EnrolmentUrl(), "application/x-pem-file", bytes.NewReader(csrPem))
if err != nil {
return err
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return errors.Errorf("enroll error: %s: could not read body: %s", resp.Status, body)
}
if resp.StatusCode == http.StatusOK {
contentTypes := resp.Header.Values("content-type")
if len(contentTypes) == 1 {
if contentTypes[0] == "application/json" {
container, err := gabs.ParseJSON(body)
if err != nil {
return fmt.Errorf("could not parse json enrollment response: %v", err)
}
certPem, ok := container.Path("data.cert").Data().(string)
if !ok {
return errors.New("could not find data.cert in enrollment response")
}
cfg.ID.Cert = "pem:" + certPem
return nil
} else {
cfg.ID.Cert = "pem:" + string(body)
}
} else {
pfxlog.Logger().Warnf("more than one content-type detected. Using response as pem. content-types: %s", strings.Join(contentTypes, ", "))
cfg.ID.Cert = "pem:" + string(body)
}
return nil
}
jsonBody, err := gabs.ParseJSON(body)
if err != nil {
return errors.Errorf("enroll error: %s: could not parse body: %s", resp.Status, body)
}
if jsonBody.Exists("error", "message") {
message := jsonBody.Search("error", "message").Data().(string)
code := jsonBody.Search("error", "code").Data().(string)
//todo: remove causeMessage support when removed from API
cause := ""
if jsonBody.Exists("error", "cause", "message") {
cause = jsonBody.Search("error", "cause", "message").Data().(string)
}
if cause == "" && jsonBody.Exists("error", "causeMessage") {
cause = jsonBody.Search("error", "causeMessage").Data().(string)
}
return errors.Errorf("enroll error: %s - code: %s - message: %s - cause: %s", resp.Status, code, message, cause)
}
return errors.Errorf("enroll error: %s: unrecognized response: %s", resp.Status, body)
}
func enrollCA(token *zt.EnrollmentClaims, cfg *zt.Config, caPool *x509.CertPool) error {
if id, err := identity.LoadIdentity(cfg.ID); err != nil {
return err
} else {
clientCert := id.Cert()
caPool = useSystemCasIfEmpty(caPool)
client := http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
RootCAs: caPool,
Certificates: []tls.Certificate{*clientCert},
},
Proxy: http.ProxyFromEnvironment,
},
}
resp, err := client.Post(token.EnrolmentUrl(), "text/plain", bytes.NewReader([]byte{}))
if err != nil {
return err
}
if resp.StatusCode != http.StatusOK {
if resp.StatusCode == http.StatusConflict {
return errors.Errorf("the provided identity has already been enrolled")
} else {
return errors.Errorf("enroll error: %s", resp.Status)
}
}
return nil
}
}
type autoEnrollInput struct {
Name string `json:"name"`
}
func enrollCAAuto(enFlags EnrollmentFlags, cfg *zt.Config, caPool *x509.CertPool) error {
if id, err := identity.LoadIdentity(cfg.ID); err != nil {
return err
} else {
clientCert := id.Cert()
caPool = useSystemCasIfEmpty(caPool)
client := http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
RootCAs: caPool,
Certificates: []tls.Certificate{*clientCert},
},
Proxy: http.ProxyFromEnvironment,
},
}
var postBody []byte
if strings.TrimSpace(enFlags.IDName) != "" {
user := autoEnrollInput{
Name: strings.TrimSpace(enFlags.IDName),
}
pb, merr := json.Marshal(user)
if merr != nil {
pfxlog.Logger().Warnf("problem converting name to json. Using the default name: %s", merr)
}
postBody = pb
}
resp, postErr := client.Post(enFlags.Token.EnrolmentUrl(), "application/json", bytes.NewReader(postBody))
if postErr != nil {
return postErr
}
if resp.StatusCode != http.StatusOK {
if resp.StatusCode == http.StatusConflict {
return errors.New("the provided identity has already been enrolled")
} else {
body, err := io.ReadAll(resp.Body)
if err != nil {
return errors.Errorf("enroll error: %s", resp.Status)
}
if respContainer, err := gabs.ParseJSON(body); err == nil {
code := respContainer.Path("error.code").Data().(string)
message := respContainer.Path("error.message").Data().(string)
return errors.Errorf("enroll error: %s: %s: %s", resp.Status, code, message)
} else {
return errors.Errorf("enroll error: %s: %s", resp.Status, body)
}
}
}
return nil
}
}
func FetchServerCert(urlRoot string) (*x509.Certificate, error) {
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
Proxy: http.ProxyFromEnvironment,
}
client := &http.Client{Transport: tr}
resp, err := client.Get(urlRoot)
if err != nil {
return nil, errors.Errorf("could not contact remote server [%s]: %s", urlRoot, err)
}
if resp.TLS == nil || len(resp.TLS.PeerCertificates) == 0 {
return nil, errors.New("peer certificate information is missing")
}
return resp.TLS.PeerCertificates[0], nil
}
// FetchCertificates will access the server insecurely to pull down the latest CA to be used to communicate with the
// server adding certificates to the provided pool
func FetchCertificates(urlRoot string, rootCaPool *x509.CertPool) []*x509.Certificate {
ctrlUrl, err := url.Parse(urlRoot)
if err != nil {
pfxlog.Logger().Errorf("could not parse url root: %s", err)
return nil //@todo figure out what the impact is here of returning an error on other callers
}
path := rest_client_api_client.DefaultBasePath
if ctrlUrl.Path != "" && ctrlUrl.Path != "/" {
path = ctrlUrl.Path
}
httpClient := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{RootCAs: rootCaPool},
Proxy: http.ProxyFromEnvironment,
},
}
clientRuntime := httptransport.NewWithClient(ctrlUrl.Host, path, rest_client_api_client.DefaultSchemes, httpClient)
clientRuntime.Consumers["application/pkcs7-mime"] = runtime.ConsumerFunc(func(reader io.Reader, i interface{}) error {
out := i.(*string)
buff, err := io.ReadAll(reader)
if err != nil {
return err
}
*out = string(buff)
return nil
})
client := rest_client_api_client.New(clientRuntime, nil)
resp, err := client.WellKnown.ListWellKnownCas(well_known.NewListWellKnownCasParams())
if err != nil {
return nil
}
if resp.Payload == "" {
pfxlog.Logger().Debug("no certificates returned from well know ca store")
return nil
}
pkcs7Certs, _ := base64.StdEncoding.DecodeString(string(resp.Payload))
if pkcs7Certs != nil {
certs, parseErr := pkcs7.Parse(pkcs7Certs)
if parseErr != nil {
pfxlog.Logger().Warnf("could not parse certificates. no certificates added from %s", urlRoot)
return nil
}
return certs.Certificates
}
return nil
}